Touring what got generated

Read every one of the 8 generated files for Contact and connect them mentally.

10 minmedium

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.

apps/api/internal/models/contact.go
package models
import (
"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 a BeforeCreate hook β€” IDs can't be guessed (good IDOR defence) and the same value works across SQLite, Postgres, and MySQL.
  • Soft delete is baked in. gorm.DeletedAt means DELETE /api/contacts/:id sets deleted_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.)

apps/api/internal/services/contact.go (abridged)
package services
import (
"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 int64
q.Count(&total)
var contacts []models.Contact
err := q.Limit(pageSize).Offset((page - 1) * pageSize).Order("created_at DESC").Find(&contacts).Error
return contacts, total, err
}
// GetByID, Update, Delete β€” same pattern: just GORM calls with error wrapping.
Notice the auto-generated search clause covers 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.

apps/api/internal/handlers/contact.go (Create only β€” others mirror it)
package handlers
import (
"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.CreateContactInput
if 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:

apps/api/internal/routes/routes.go (excerpt)
// Contact resource β€” auto-generated, edit freely
contacts := 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

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

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;
}
Hand-edits to this file are wiped on the next 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

apps/web/hooks/use-contacts.ts (abridged)
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.

apps/admin/resources/contacts.ts
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

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 of CreateContactSchema / UpdateContactSchema.
  • packages/shared/types/index.ts β€” the Contact type 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 exported resources array. That's what the sidebar nav, the dashboard widget grid, and the resource search dialog all read from.
The generator is a starting point, not a final word. Once the files exist, edit them. Add custom service methods, extend the admin columns with a status badge, change the form to a multi-step wizard. The generator runs once; the code is yours from there.

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

You want the admin Contacts page to show a 'Total contacts' card at the top. Where do you add it?

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:bool to 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