Touring what got generated
Read every one of the 8 generated files for Contact and connect them mentally.
Eight files appeared when you ran grit generate resource Contact β¦ β plus ~10 marker-fenced injections into existing files. This lesson walks through each new file with the full source the generator wrote, so you stop seeing them as a black box and start treating them as your own code. Once you've read each layer, you'll know exactly where to make any future change.
1. The Go model β apps/api/internal/models/contact.go
The GORM struct that maps to the contacts table. Every field becomes a column; the struct tags tell GORM and the JSON encoder how to translate.
package modelsimport ("time""github.com/google/uuid""gorm.io/gorm")// Contact represents a contact in the system.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"`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:"-"`}// BeforeCreate generates a UUID before inserting.func (m *Contact) BeforeCreate(tx *gorm.DB) error {if m.ID == "" {m.ID = uuid.New().String()}return nil}// BeforeUpdate increments Version so offline clients can detect server-side updates.func (m *Contact) BeforeUpdate(tx *gorm.DB) error {m.Version++return nil}
Three things worth noticing:
- UUID string primary key. Grit uses
uuid.New().String()set in aBeforeCreatehook β IDs can't be guessed (good IDOR defence) and the same value works across SQLite, Postgres, and MySQL. - Soft delete is baked in.
gorm.DeletedAtmeansDELETE /api/contacts/:idsetsdeleted_at = now()instead of actually dropping the row. Queries hide soft-deleted rows by default. - Optimistic concurrency via Version. Every server-side update bumps
version. Offline-first clients can detect "someone else moved this" and merge cleanly.
2. The service β apps/api/internal/services/contact.go
All the actual logic. The service owns the database β handlers call into it; it never touches Gin or HTTP. (This is the convention that makes Grit handlers thin and services testable.)
package servicesimport ("fmt""gorm.io/gorm""myapp/internal/models")type ContactService struct{ db *gorm.DB }func NewContactService(db *gorm.DB) *ContactService { return &ContactService{db: db} }// CreateContactInput is what handlers bind requests into.type CreateContactInput struct {Name string `json:"name" binding:"required"`Email string `json:"email" binding:"required,email"`Phone string `json:"phone"`}func (s *ContactService) Create(in CreateContactInput) (*models.Contact, error) {c := &models.Contact{Name: in.Name, Email: in.Email, Phone: in.Phone}if err := s.db.Create(c).Error; err != nil {return nil, fmt.Errorf("creating contact: %w", err)}return c, nil}// List supports pagination + search across string-shaped fields.func (s *ContactService) List(page, pageSize int, search string) ([]models.Contact, int64, error) {q := s.db.Model(&models.Contact{})if search != "" {like := "%" + search + "%"q = q.Where("name ILIKE ? OR email ILIKE ? OR phone ILIKE ?", like, like, like)}var total int64q.Count(&total)var contacts []models.Contacterr := q.Limit(pageSize).Offset((page - 1) * pageSize).Order("created_at DESC").Find(&contacts).Errorreturn contacts, total, err}// GetByID, Update, Delete β same pattern: just GORM calls with error wrapping.
name, email, and phone β every string-shaped column. That's why GET /api/contacts?search=alice just works after generation: the service already knows which columns to scan.3. The handler β apps/api/internal/handlers/contact.go
Thin layer that bridges HTTP and the service. Five methods, one per CRUD verb.
package handlersimport ("net/http""strconv""github.com/gin-gonic/gin""myapp/internal/respond""myapp/internal/services")type ContactHandler struct{ svc *services.ContactService }func NewContactHandler(svc *services.ContactService) *ContactHandler {return &ContactHandler{svc: svc}}func (h *ContactHandler) Create(c *gin.Context) {var in services.CreateContactInputif err := c.ShouldBindJSON(&in); err != nil {respond.Error(c, http.StatusUnprocessableEntity, "VALIDATION_ERROR", err.Error())return}out, err := h.svc.Create(in)if err != nil {respond.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error())return}respond.Created(c, out, "Contact created")}
respond.Error and respond.Created shape the JSON to match Grit's response envelope β so the frontend always gets { data, message } or { error: { code, message } }, no matter which handler it called.
4. The route injection β apps/api/internal/routes/routes.go
The generator edits the existing routes file rather than creating a new one. It finds the marker comment // grit:routes and slots the new mounting block in before it:
// Contact resource β auto-generated, edit freelycontacts := api.Group("/contacts")contacts.Use(middleware.Auth(cfg)){contacts.GET("", contactHandler.List)contacts.POST("", contactHandler.Create)contacts.GET("/:id", contactHandler.GetByID)contacts.PUT("/:id", contactHandler.Update)contacts.DELETE("/:id", contactHandler.Delete)}// grit:routes
The Services struct higher up the file also gets a new field (Contact *ContactService), and the handler is instantiated wherever NewServices() lives. Open the file and search for Contact β you'll see every site the generator touched.
5. The Zod schema β packages/shared/src/schemas/contact.ts
import { z } from "zod";export const ContactSchema = z.object({name: z.string().min(1, "Required"),email: z.string().min(1, "Required").email("Invalid email"),phone: z.string().optional(),});export const CreateContactSchema = ContactSchema;export const UpdateContactSchema = ContactSchema.partial();export type CreateContactInput = z.infer<typeof CreateContactSchema>;export type UpdateContactInput = z.infer<typeof UpdateContactSchema>;
Same shape lives in two places: as Go struct tags (validated by Gin's binding) and as a Zod schema (validated on the frontend before the request even leaves). One source of truth β fields described to the generator β emits both.
6. The TypeScript type β packages/shared/src/types/contact.ts
// Auto-generated by grit β DO NOT EDIT MANUALLY.// Run `grit sync` to regenerate after changing the Go model.export interface Contact {id: string;name: string;email: string;phone: string;version: number;created_at: string;updated_at: string;}
grit sync. If you want a custom property, add it to the Go model and let the sync flow propagate it β that's the next lesson's topic.7. The React Query hook β apps/web/hooks/use-contacts.ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { apiClient } from "@/lib/api-client";import type { Contact } from "@repo/shared/types";const KEY = ["contacts"] as const;export function useContacts(params?: { page?: number; search?: string }) {return useQuery({queryKey: [...KEY, params],queryFn: async () => {const { data } = await apiClient.get<{ data: Contact[]; meta: { total: number } }>("/api/contacts",{ params },);return data;},});}export function useCreateContact() {const qc = useQueryClient();return useMutation({mutationFn: (input: Partial<Contact>) =>apiClient.post<{ data: Contact }>("/api/contacts", input).then((r) => r.data.data),onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),});}// useContact(id), useUpdateContact, useDeleteContact β same pattern.
Pagination, search, and invalidation-after-mutate are wired up out of the box. Drop useContacts() into any page and you have a live list.
8. The admin resource definition β apps/admin/resources/contacts.ts
Since v3.31.x the generator splits the admin output in two: a declarative definition that describes the resource (columns, form fields, badge colors, export rules, stats cards) and a thin page that hands the definition to a generic <ResourcePage> component. The split exists so you can mount the same definition in a different layout (custom shell, custom analytics widgets) without forking the whole page.
import { defineResource } from "@/lib/resource";export const contactsResource = defineResource({name: "Contact",slug: "contacts",endpoint: "/api/contacts",icon: "User",// formView controls how Create/Edit opens. Defaults to "sheet"// (right drawer). Pick "modal" for short forms, "page" for long// forms, or "modal-steps"/"page-steps" for wizards.// formView: "sheet",table: {columns: [{ key: "name", label: "Name", sortable: true, searchable: true },{ key: "email", label: "Email", sortable: true, format: "email" },{ key: "phone", label: "Phone" },{ key: "created_at", label: "Created", sortable: true, format: "relative" },],searchable: true,bulkActions: ["delete", "export"],// v3.31.34 β date-window filter on the toolbar.dateFilter: { enabled: true, field: "created_at", label: "Created" },// v3.31.35 β export menu + import modal. Both default to on.// export: { csv: true, excel: true, json: true },// import: { excel: true },},form: {fields: [{ key: "name", type: "text", label: "Name", required: true },{ key: "email", type: "text", label: "Email", required: true },{ key: "phone", type: "text", label: "Phone" },],},});
9. The admin page β apps/admin/app/(dashboard)/resources/contacts/page.tsx
"use client";import { ResourcePage } from "@/components/resource/resource-page";import { contactsResource } from "@/resources/contacts";export default function ContactsPage() {return <ResourcePage resource={contactsResource} />;}
Six lines. That's the page. ResourcePage is the generic component that reads the definition and renders everything: the stats cards above the table, the toolbar with search + date filter + export menu + import modal, the DataTable with column sort and bulk-select, the Create/Edit/View modals, and the delete confirmation. No HTML, no form wiring. To customise behaviour, edit the definition in step 8; only drop down to a hand-written page when you need a completely different layout.
Alongside the file writes, the generator slots new entries into anchor-comment-fenced regions of existing files. The sidebar isn't one of them β it reads from the registry array dynamically, so adding the resource to resources/index.ts is enough. The injection list in v3.31.x:
apps/api/internal/routes/routes.goβ the handler init block, the route group (public + protected + admin splits as needed), and the AutoMigrate / GORM Studio model registrations.packages/shared/schemas/index.tsβ re-exports ofCreateContactSchema/UpdateContactSchema.packages/shared/types/index.tsβ theContacttype re-export.packages/shared/constants/index.tsβ API route path constants the web hooks reference.apps/admin/resources/index.tsβ the import + the entry in the exportedresourcesarray. That's what the sidebar nav, the dashboard widget grid, and the resource search dialog all read from.
How the eight files talk to each other
Browser β Go APIβapps/admin/.../contacts/page.tsx (6 lines) ββ ResourcePage reads ββ resources/contacts.ts definition ββΌ βDataTable + form + toolbar β(via ResourcePage β useResource()) ββ axios GET /api/contacts ββ€β β βββββββββββββββββββββββββββββββββ ββββΆβ routes.go ββ β β contactHandler.List ββ β ββββββββββββββββββββββββββββββββ€β β β handler β service.List() ββ β ββββββββββββββββββββββββββββββββ€β β β service β db.Model(Contact) ββ β ββββββββββββββββββββββββββββββββ€β validates response against β β model β contacts table ββΌ packages/shared/types/contact.ts β ββββββββββββββββββββββββββββββββUI re-renders with typed data(Contact[] from @repo/shared/types)
Quick check
Try it
Open three of the eight generated files (your pick β try one Go, one TS, one TSX) and answer in notes.md:
- Which file?
- What do the first 10 lines do?
- If you were to extend it (e.g., add
is_active:boolto Contact), what would you change in this file?
What's next
You've seen the file tour with three plain string fields. The next lesson goes wide β slug auto-gen, the image / images / file / files field types, tag inputs via string_array, date and datetime pickers, and the heuristic names that quietly upgrade your column storage from VARCHAR to TEXT or DECIMAL without you asking.
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