Relationships — one-to-one, one-to-many, many-to-many
belongs_to + many_to_many cover every cardinality. Three runnable examples.
Real apps have entities that point at each other. A User has one Profile; a Contact belongs to a Group; a Post has many Tags. This lesson covers all three relationship cardinalities Grit supports — one-to-one, one-to-many, and many-to-many — using just two field types: belongs_to (with or without a unique constraint) and many_to_many. Each section is a complete, runnable example you can drop into a fresh Grit project.
The three cardinalities at a glance
| Cardinality | Example | Field spec | DB shape |
|---|---|---|---|
| one-to-one | User ↔ Profile | belongs_to + unique | FK on child with uniqueIndex |
| one-to-many | Group → Contacts | belongs_to | FK on child, regular index |
| many-to-many | Post ↔ Tags | many_to_many | Auto-generated join table |
Example 1 — One-to-many: Group → many Contacts
Imagine a CRM. Every Contact belongs to exactly one Group (Clients, Leads, Vendors), and every Group has many Contacts. That's the textbook one-to-many — modelled in Grit with one belongs_to field on the child (Contact), not two declarations.
Group Contact───── ───────────────id (UUID) ←─── group_id (FK to Group)name namedescription emailphone
Step 1 — Generate the parent first (Group)
Order matters: generate Group before Contact, so the Contact resource's belongs_to field has something to point at.
$grit generate resource Group \$ --fields "name:string:unique,description:text:optional"$grit migrate
That writes the same 8 files you saw in Lesson 3 — model, service, handler, routes, schema, type, hook, admin page. Now we add the child.
Step 2 — Generate the child with belongs_to
$grit generate resource Contact \$ --fields "name:string,email:string:unique,phone:string:optional,group:belongs_to:Group"$grit migrate
Three colons in the relationship spec, each meaningful:
group : belongs_to : Group└──┬─┘ └────┬────┘ └──┬──┘│ │ ││ │ └── Related model. PascalCase, singular. Must already exist.│ ││ └── Type. Tells Grit to make this a foreign key, not a plain string.│└── Field name. Convention: name it after the relation, NOT "group_id".The generator appends "_id" automatically when writing the column.
group:belongs_to alone infers :Group. Be explicit when the field name diverges (e.g. parent:belongs_to:Group).What the generator produced — Contact side
Open apps/api/internal/models/contact.go after regenerating and you'll see a new column plus an eager-load association:
type Contact struct {ID string `gorm:"primarykey;size:36" json:"id"`Name string `gorm:"size:255" json:"name" binding:"required"`Email string `gorm:"size:255;uniqueIndex" json:"email" binding:"required"`Phone string `gorm:"size:255" json:"phone"`// Foreign key column — what GORM stores in the contacts table.GroupID string `gorm:"size:36;index" json:"group_id" binding:"required"`// Association — populated when you call .Preload("Group").Group *Group `json:"group,omitempty"`Version int `gorm:"not null;default:1" json:"version"`CreatedAt time.Time `json:"created_at"`UpdatedAt time.Time `json:"updated_at"`DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`}
Two fields for the same relationship — that's the GORM idiom:
GroupID string— the actual column on thecontactstable. UUID string, indexed. This is what Create/Update endpoints accept and what the database stores.Group *Group— the populated association. Empty by default; only loaded when the service doesdb.Preload("Group"). The generator'sListandGetByIDmethods preload it for you.
The frontend types stay clean
export interface Contact {id: string;name: string;email: string;phone: string;group_id: string;group?: Group; // present when preloaded by the APIversion: number;created_at: string;updated_at: string;}
The admin form gets a dropdown
Open the regenerated apps/admin/app/resources/contacts/page.tsx and you'll see:
form: [{ name: "name", type: "text", label: "Name", required: true },{ name: "email", type: "text", label: "Email", required: true },{ name: "phone", type: "text", label: "Phone" },{name: "group_id",type: "relationship-select",label: "Group",relation: { model: "groups", labelField: "name" },required: true,},],
The relationship-select field is a server-backed dropdown — it hits GET /api/groups, lists each group by its name field, and writes the selected group's UUID into group_id. No glue needed.
The list page shows the parent name
columns: [{ key: "name", label: "Name", sortable: true, format: "text" },{ key: "email", label: "Email", sortable: true, format: "text" },{ key: "group.name", label: "Group", format: "text" }, // dotted path reads from preload{ key: "created_at", label: "Created", format: "relative" },],
Showing "has many" on the parent — a manual addition
The generator only writes the child side of belongs_to. Group doesn't automatically know about its contacts. That's deliberate — listing contacts on the Group is usually a separate API call (paginated, filtered, searchable), not a preload.
Two ways to add the "has many" side:
Option A — Add a Contacts association to Group manually
If you want GET /api/groups/:id to embed the contact list, add the field by hand:
type Group struct {ID string `gorm:"primarykey;size:36" json:"id"`Name string `gorm:"size:255;uniqueIndex" json:"name"`Description string `gorm:"type:text" json:"description"`// Added manually — has-many. GORM infers the FK is "group_id" on Contact.Contacts []Contact `json:"contacts,omitempty"`// auto-generated boilerplate stays the same}
Then in the service for GetByID: db.Preload("Contacts").First(&group, "id = ?", id). Run grit sync and the TS type picks up contacts?: Contact[].
Option B — Filter the existing endpoint (recommended)
Use the auto-generated GET /api/contacts?group_id=:id endpoint. The generator wires a search/filter clause on every column, including FKs:
$curl "http://localhost:8080/api/contacts?group_id=01HX...&page=1&page_size=20" \$ -H "Authorization: Bearer $TOKEN"
Cleaner separation — Group endpoints handle Groups, Contact endpoints handle Contacts. Easier to paginate, easier to cache.
Example 2 — One-to-one: User ↔ Profile
One-to-one is just one-to-many with a unique constrainton the foreign key. Pick the side that's "optional / added later" (Profile, Settings, KycRecord) as the child and put the FK there. Each Profile points at one User; the unique index guarantees no User has more than one Profile.
Because the inline --fields syntax doesn't accept :unique on relationship fields, this case is a great fit for the YAML long-form (covered in the next lesson):
name: Profilefields:- name: usertype: belongs_torelated_model: Userrequired: trueunique: true # ← this is what turns it into one-to-one- name: biotype: text- name: avatartype: string # URL field — auto-becomes VARCHAR(500)- name: twitter_handletype: stringrequired: false
$grit generate resource Profile --from profile.yaml$grit migrate
The generated Go model is almost identical to the one-to-many case — but the GORM tag is different:
type Profile struct {ID string `gorm:"primarykey;size:36" json:"id"`Bio string `gorm:"type:text" json:"bio"`Avatar string `gorm:"size:500" json:"avatar"`// Note the uniqueIndex — that's what makes it 1-to-1.UserID string `gorm:"size:36;index;uniqueIndex" json:"user_id" binding:"required"`User *User `json:"user,omitempty"`// auto-generated boilerplate}
Try inserting two profiles for the same user — the second insert fails with a UNIQUE constraint failed error. That's the database guaranteeing the relationship rather than your app code having to check.
Loading the pair
To get a User with their Profile embedded, query from the Profile side and preload, or add a HasOne association on User manually:
// In services/user.go — add a method that joins the optional Profile.func (s *UserService) GetWithProfile(id string) (*models.User, error) {var u models.Usererr := s.db.Preload("Profile").First(&u, "id = ?", id).Errorreturn &u, err}
And add the association field to models/user.go:
type User struct {// ...existing fields...Profile *Profile `gorm:"foreignKey:UserID" json:"profile,omitempty"`}
Run grit sync after the manual addition — the TS type picks up the optional profile?: Profile.
Example 3 — Many-to-many: Post ↔ Tags
Use many_to_many when an entity has many related items and each related item can belong to many of these entities. The classic case: a Post has many Tags, and each Tag is on many Posts.
$grit generate resource Tag \$ --fields "name:string:unique,slug:slug:name,color:string:optional"$grit generate resource Post \$ --fields "title:string,slug:slug,body:richtext,tags:many_to_many:Tag"$grit migrate
The field spec works the same:
tags : many_to_many : Tag└─┬─┘ └─────┬──────┘ └─┬─┘│ │ ││ │ └── Related model name. REQUIRED for many_to_many (unlike belongs_to).│ ││ └── Type. Creates a join table behind the scenes.│└── Field name. Usually plural. Becomes a []string of UUIDs in Go.
What you get:
- A join table named
post_tagswithpost_id+tag_idcolumns, indexed both ways. Post.Tagsis[]stringon the Go side (the array of related UUIDs) andstring[]in TypeScript.- The admin Form gets a
multi-relationship-select— a multi-select dropdown backed byGET /api/tags.
Picking the right relationship
| If… | Use | Where to put it |
|---|---|---|
| Each parent has at most one child (User ↔ Profile, Order ↔ Receipt) | belongs_to + unique | On the "optional" side — uses YAML (inline can't set unique on belongs_to) |
| Child has exactly one parent (Contact → Group, Order → Customer) | belongs_to | On the child |
| Parent owns N children, children are not shared (BlogPost → Comment) | belongs_to on the child | On the child + filter ?post_id= |
| Both sides can have many of each other (Post ↔ Tag, User ↔ Role) | many_to_many | On whichever side reads it more — usually both |
| Loose freeform list (5–10 tags users type by hand, not managed centrally) | string_array | A column on the parent (no join table) |
Quick check
Try it
Try all three cardinalities on your machine. Each part is self-contained — feel free to do them one at a time.
Part A — one-to-many (Group → Contacts)
$grit generate resource Group \$ --fields "name:string:unique,description:text:optional"$grit generate resource Contact \$ --fields "name:string,email:string:unique,phone:string:optional,group:belongs_to:Group"$grit migrate
In the admin, create one Group then two Contacts pointing at it. Hit GET /api/contacts?group_id=<the-group-uuid> — both contacts come back, each with the embedded group object.
Part B — one-to-one (User → Profile)
Drop this YAML in profile.yaml at the project root and generate from it:
name: Profilefields:- name: usertype: belongs_torelated_model: Userrequired: trueunique: true- name: biotype: text- name: avatartype: string
$grit generate resource Profile --from profile.yaml$grit migrate
Create one Profile for your admin user. Then try to create a second Profile for the same user — the API should return a 500 with a unique-constraint error.
Part C — many-to-many (Post ↔ Tag)
$grit generate resource Tag \$ --fields "name:string:unique,slug:slug:name,color:string:optional"$grit generate resource Post \$ --fields "title:string,slug:slug,body:richtext,tags:many_to_many:Tag"$grit migrate
In the admin, create 3 Tags then a Post with all three attached via the multi-select. Hit GET /api/posts/<id> — the tags field comes back as an array of UUIDs (or full Tag objects if you tweak the service to preload).
Paste the three API responses into notes.md.
What's next
You've seen the inline form for every field type Grit supports. The next lesson is about choosing between the inline --fields string and the long-form YAML file — when each shines, and what extra knobs YAML gives you (defaults, the single source of truth you can re-run, anything beyond five fields).
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub