Go for Grit Developers
Everything you need to know about Go to work with Grit's backend. This guide assumes you know another language like JavaScript or Python and walks you through Go's key concepts as they apply to building full-stack applications with Grit.
Want to practice as you learn?
Try the code examples in our interactive Go Playground.
1. Go Basics
Go (often called Golang) is a statically typed, compiled language created at Google. It compiles to a single binary with no runtime dependencies, starts up in milliseconds, and handles concurrency natively. These qualities make it ideal for building API servers.
Every Go file belongs to a package. The special package main is the entry point for executables. The func main() function insidepackage main is where your program starts. You import other packages using the import keyword.
Go uses modules for dependency management. You initialize a module with go mod init and run your program with go run .
package mainimport "fmt"func main() {fmt.Println("Hello, Grit!")}
In Grit
The entry point for every Grit backend is apps/api/cmd/server/main.go. This file initializes the database connection, sets up middleware, registers routes, and starts the Gin HTTP server. You rarely edit it directly -- the code generator handles injecting new routes and models automatically.
Try This
Print your name and the current year using fmt.Printf with format verbs (%s and %d).
2. Variables & Types
Go is statically typed -- every variable has a fixed type determined at compile time. You can declare variables with var (explicit) or := (short assignment, which infers the type). The short form is used inside functions and is by far the most common style in Go code.
The basic types you will encounter are string, int,bool, and float64. Go also has uint (unsigned integer, used for database IDs), byte, and rune (for Unicode characters). Constants are declared with const and cannot be changed after assignment.
package mainimport "fmt"const AppName = "my-saas"func main() {// Explicit declarationvar name string = "Grit"var port int = 8080// Short assignment (type inferred)host := "localhost"debug := trueprice := 29.99// Multiple assignmentwidth, height := 1920, 1080fmt.Println(name, host, port, debug, price, width, height)fmt.Println("App:", AppName)}
Format Specifiers
Go's fmt.Printf and fmt.Sprintf use format verbs to control how values are printed. You will use these constantly when logging, building strings, and debugging. Here are the ones you need to know:
| Specifier | Use | Example |
|---|---|---|
%s | String | fmt.Printf("%s", "text") |
%d | Integer | fmt.Printf("%d", 42) |
%f | Float | fmt.Printf("%.2f", 3.14159) |
%t | Boolean | fmt.Printf("%t", true) |
%v | Any value | fmt.Printf("%v", anything) |
%+v | Struct with field names | fmt.Printf("%+v", person) |
%T | Type of value | fmt.Printf("%T", variable) |
\n | Newline | fmt.Printf("line1\nline2") |
In Grit
You will see := everywhere in handlers and services. Config values loaded from .env are stored in typed struct fields (like Port int,JWTSecret string). Constants are used for role names (RoleAdmin = "ADMIN") and error codes. Format specifiers are used in error wrapping (fmt.Errorf("failed to create user: %w", err)) and logging throughout the codebase.
Try This
Declare variables of different types (string, int, float64, bool), convert an int to float64, and print all values with their types.
3. Structs & Tags
A struct is Go's way of defining a custom data type -- similar to a class in other languages, but without inheritance. Structs group related fields together. Each field has a name, a type, and optional struct tags (metadata in backtick strings after the type).
Grit models use three kinds of tags:
json:"name"-- controls how the field appears in JSON responses. Usejson:"-"to hide a field entirely.gorm:"..."-- controls the database schema (column type, indexes, constraints, foreign keys).binding:"required"-- tells Gin to validate incoming request data. If validation fails, Gin returns a 400 error automatically.
package modelsimport ("time""gorm.io/gorm")type User struct {ID uint `gorm:"primarykey" json:"id"`Name string `gorm:"size:255;not null" json:"name" binding:"required"`Email string `gorm:"size:255;uniqueIndex;not null" json:"email" binding:"required,email"`Password string `gorm:"size:255;not null" json:"-"`Role string `gorm:"size:20;default:USER" json:"role"`Active bool `gorm:"default:true" json:"active"`CreatedAt time.Time `json:"created_at"`UpdatedAt time.Time `json:"updated_at"`DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`}
In Grit
Every model in internal/models/ is a struct with these three tag types. When you run grit generate resource Product, the CLI creates a struct with properly tagged fields, registers it for migration, and generates the matching Zod schema and TypeScript type on the frontend.
Try This
Create a Product struct with Name (string), Price (float64), and InStock (bool) fields. Create two products and print them.
4. Functions & Error Handling
Go functions can return multiple values. This is fundamental to Go's error handling: instead of throwing exceptions, functions return anerror value as the last return. If the error is nil, the operation succeeded. If not, you handle it immediately.
The if err != nil pattern appears on nearly every line that calls another function. It may look verbose at first, but it makes error flow explicit and easy to trace. Use fmt.Errorf("context: %w", err) to wrap errors with additional context as they bubble up the call stack.
package mainimport ("errors""fmt")// Functions return (result, error)func divide(a, b float64) (float64, error) {if b == 0 {return 0, errors.New("cannot divide by zero")}return a / b, nil}func calculateDiscount(price, percent float64) (float64, error) {result, err := divide(price * percent, 100)if err != nil {// Wrap the error with contextreturn 0, fmt.Errorf("calculating discount: %w", err)}return result, nil}func main() {discount, err := calculateDiscount(100.0, 20.0)if err != nil {fmt.Println("Error:", err)return}fmt.Println("Discount:", discount) // 20.0}
In Grit
Every service function in internal/services/ returns (result, error). Handlers call services, check for errors, and return the appropriate HTTP response. For example, user, err := service.GetUserByID(id) followed by an if err != nil block that sends a 404 or 500 JSON response.
Try This
Write a sqrt function that returns an error for negative numbers. Test it with both positive and negative inputs.
5. Methods
A method is a function attached to a type. The difference between a function and a method is one thing: the receiver. A function stands alone, but a method has a receiver parameter before the function name that binds it to a specific type.
The receiver can be a value receiver (func (u User) FullName()) or a pointer receiver (func (u *User) SetName(name string)). Use a pointer receiver when the method needs to modify the struct or when the struct is large (to avoid copying). In practice, most methods in Grit use pointer receivers.
Methods are how Go achieves object-oriented behavior without classes. Instead ofclass User {...}, you define a struct and attach methods to it.
package mainimport "fmt"type User struct {FirstName stringLastName stringEmail string}// A regular function — takes User as an argumentfunc getFullName(u User) string {return u.FirstName + " " + u.LastName}// A method — attached to User with a value receiver// Use value receiver when you only READ the structfunc (u User) FullName() string {return u.FirstName + " " + u.LastName}// A method with a pointer receiver// Use pointer receiver when you MODIFY the structfunc (u *User) SetEmail(email string) {u.Email = email // Modifies the original, not a copy}func main() {user := User{FirstName: "John", LastName: "Doe"}// Calling a function — pass the struct as argumentfmt.Println(getFullName(user)) // "John Doe"// Calling a method — use dot notation on the structfmt.Println(user.FullName()) // "John Doe"// Pointer receiver method modifies the originaluser.SetEmail("john@example.com")fmt.Println(user.Email) // "john@example.com"}
In Grit
Methods are the foundation of Grit's architecture. Services are structs with a DB *gorm.DB field, and all their operations are methods:func (s *ProductService) GetByID(id uint). Handlers are the same pattern:func (h *AuthHandler) Login(c *gin.Context). GORM hooks are also methods:func (u *User) BeforeCreate(tx *gorm.DB) error runs automatically before inserting a user into the database.
Try This
Create a Rectangle struct with Width and Height, then add Area() and Perimeter() methods. Use a pointer receiver to add a Scale() method.
6. Slices & Maps
A slice is Go's dynamic array. Unlike arrays (which have a fixed size), slices can grow and shrink. You create them with []Type{} ormake([]Type, length) and add items with append().
A map is a key-value data structure (like a JavaScript object or Python dictionary). The type map[string]interface{} (or the modern alias map[string]any) can hold any value type -- this is what Gin uses for JSON responses.
The range keyword iterates over slices and maps, giving you both the index/key and value on each iteration.
package mainimport "fmt"func main() {// Slicesnames := []string{"Alice", "Bob", "Charlie"}names = append(names, "Diana")for i, name := range names {fmt.Printf("%d: %s\n", i, name)}// Mapsuser := map[string]any{"id": 1,"name": "Alice","email": "alice@example.com",}for key, value := range user {fmt.Printf("%s = %v\n", key, value)}// Access a single valuefmt.Println("Name:", user["name"])}
In Grit
GORM query results are always slices: var users []models.User. Gin JSON responses use gin.H{} which is just a shortcut for map[string]any. For example, c.JSON(200, gin.H{"data": users, "message": "success"}).
Try This
Build a word frequency counter: split a sentence into words, count how many times each word appears using a map, and print the results.
7. Interfaces
An interface defines a set of method signatures. Any type that implements all those methods automatically satisfies the interface -- there is noimplements keyword. This is called implicit implementation, and it is one of Go's most powerful features.
Interfaces enable polymorphism and are essential for testing. You can swap a real database service for a mock that implements the same interface, making unit tests fast and isolated.
package mainimport "fmt"// Define an interfacetype Notifier interface {Send(to string, message string) error}// EmailNotifier implements Notifier (implicitly)type EmailNotifier struct {From string}func (e *EmailNotifier) Send(to string, message string) error {fmt.Printf("Email from %s to %s: %s\n", e.From, to, message)return nil}// SlackNotifier also implements Notifiertype SlackNotifier struct {Channel string}func (s *SlackNotifier) Send(to string, message string) error {fmt.Printf("Slack #%s -> %s: %s\n", s.Channel, to, message)return nil}// Works with ANY Notifierfunc alert(n Notifier, user string) {n.Send(user, "Your report is ready")}func main() {email := &EmailNotifier{From: "noreply@app.com"}slack := &SlackNotifier{Channel: "alerts"}alert(email, "alice@example.com")alert(slack, "alice")}
In Grit
Grit services can implement interfaces for testability. For example, you could define a UserService interface with methods like GetByID,Create, and Delete, then swap in a mock implementation during tests. The built-in mailer and storage services also follow this pattern.
Try This
Define a Describable interface with a Describe() string method. Implement it for a Book and a Movie type, then write a function that accepts any Describable.
8. Pointers
A pointer holds the memory address of a value. Use & to get the address of a variable and * to read the value at that address (dereference). Pointers let you modify a value in place without copying it, and they indicate that a value might be nil (absent).
In Go, function arguments are passed by value (copied). If you want a function to modify the original value, pass a pointer. This is also why GORM methods take pointers to structs: db.Create(&user) writes the new ID back into your user variable.
package mainimport "fmt"func doubleValue(n int) {n = n * 2 // Modifies the COPY, not the original}func doublePointer(n *int) {*n = *n * 2 // Modifies the ORIGINAL via pointer}func main() {x := 10doubleValue(x)fmt.Println(x) // Still 10 — the copy was doubleddoublePointer(&x)fmt.Println(x) // Now 20 — modified through pointer// Nil pointer: indicates "no value"var name *string = nilif name == nil {fmt.Println("Name is not set")}}
In Grit
GORM uses pointers for nullable database fields. A regular string defaults to "" (empty), but *string can be nil -- meaning the database column is NULL. You will see *time.Time for optional timestamps like EmailVerifiedAt and gorm.DeletedAt for soft deletes. All GORM operations take pointers: db.Create(&user), db.First(&user, id).
Try This
Write a tripleValue function that uses a pointer to modify the original variable, and a swap function that swaps two integers using pointers.
9. Goroutines & Channels
A goroutine is a lightweight thread managed by the Go runtime. You start one by putting go before a function call. Goroutines are extremely cheap -- you can run thousands simultaneously, unlike OS threads.
Channels are the way goroutines communicate. A channel is a typed pipe: one goroutine sends a value in, another receives it out. Use sync.WaitGroupwhen you need to wait for multiple goroutines to finish before proceeding.
package mainimport ("fmt""sync""time")func fetchData(source string, wg *sync.WaitGroup) {defer wg.Done() // Signal completion when function returnstime.Sleep(100 * time.Millisecond) // Simulate workfmt.Println("Fetched from:", source)}func main() {var wg sync.WaitGroupsources := []string{"database", "cache", "api"}for _, src := range sources {wg.Add(1)go fetchData(src, &wg) // Run concurrently}wg.Wait() // Wait for all goroutines to finishfmt.Println("All data fetched!")// Channel examplech := make(chan string)go func() {ch <- "hello from goroutine" // Send}()msg := <-ch // Receivefmt.Println(msg)}
In Grit
Grit's background job system (powered by asynq) uses goroutines under the hood to process tasks like sending emails, resizing images, and running cleanup jobs. The Gin web server itself handles each HTTP request in its own goroutine. You generally do not need to write goroutine code directly -- asynq and Gin manage concurrency for you.
Try This
Create 3 goroutines that each compute a result and send it through a channel. Collect all results in main and print the total.
10. Packages & Project Structure
Go organizes code into packages. Each directory is a package, and the package name matches the directory name. A name that starts with an uppercase letter(like GetUser) is exported (public) -- accessible from other packages. A lowercase name (like parseToken) is unexported (private) -- only accessible within the same package.
The internal/ directory is special in Go: packages inside it cannot be imported by code outside the parent module. This is a convention enforced by the compiler, not just a naming pattern. It keeps your application logic private.
apps/api/├── cmd/server/│ └── main.go # Entry point (package main)├── cmd/migrate/│ └── main.go # Migration CLI (go run cmd/migrate)├── cmd/seed/│ └── main.go # Seeder CLI (go run cmd/seed)├── internal/│ ├── config/│ │ └── config.go # package config — Config struct, Load()│ ├── database/│ │ ├── database.go # package database — Connect()│ │ ├── migrate.go # DropAll() for fresh migrations│ │ └── seed.go # Seed() — populate dev data│ ├── models/│ │ ├── user.go # package models — User struct (exported)│ │ └── upload.go # package models — Upload struct (exported)│ ├── handlers/│ │ ├── auth.go # package handlers — Login(), Register()│ │ └── user.go # package handlers — UserHandler CRUD│ ├── services/│ │ └── auth.go # package services — AuthService (JWT)│ ├── middleware/│ │ ├── auth.go # package middleware — Auth(), RequireRole()│ │ ├── cors.go # CORS configuration│ │ └── logger.go # Request logging│ └── routes/│ └── routes.go # package routes — Setup() wires everything└── go.mod # Module definition
In Grit
Grit follows Go's standard project layout exactly. All application code lives inside internal/: models, handlers, services, middleware, routes, and config. The cmd/ directory contains entry points for different commands (server, migrate, seed). When you import a package, you use the full module path:import "my-app/apps/api/internal/models".
11. Environment Variables
Go reads environment variables with os.Getenv("KEY"). For local development, you store variables in a .env file and load them with the godotenv package. A common pattern is to define a Configstruct that holds all your settings in one place, loaded once at startup.
This pattern keeps configuration centralized, type-safe, and easy to override per environment (development, staging, production).
package configimport ("os""strconv""github.com/joho/godotenv")type Config struct {Port intDBHost stringDBPort intDBName stringDBUser stringDBPassword stringJWTSecret stringDebug bool}func Load() *Config {// Load .env file (ignored in production)godotenv.Load()port, _ := strconv.Atoi(getEnv("PORT", "8080"))dbPort, _ := strconv.Atoi(getEnv("DB_PORT", "5432"))return &Config{Port: port,DBHost: getEnv("DB_HOST", "localhost"),DBPort: dbPort,DBName: getEnv("DB_NAME", "grit_dev"),DBUser: getEnv("DB_USER", "postgres"),DBPassword: getEnv("DB_PASSWORD", "postgres"),JWTSecret: getEnv("JWT_SECRET", "change-me"),Debug: getEnv("DEBUG", "false") == "true",}}func getEnv(key, fallback string) string {if value := os.Getenv(key); value != "" {return value}return fallback}
In Grit
Grit's config lives in internal/config/config.go. It loads settings for the database, Redis, S3 storage, Resend email, AI keys, Sentinel security, and more -- all from the .env file. The Config struct is created once in main.goand passed to every service that needs it. A .env.example file is scaffolded with every project to document all available variables.
12. Gin Framework
Gin is Go's most popular HTTP framework. It provides a fast router, middleware support, JSON binding, validation, and route groups. Understanding Gin is essential because every handler you write receives a *gin.Context -- the single object that holds the request, response, URL parameters, query strings, and more.
Creating a Server
You create a Gin engine with gin.New() (bare) or gin.Default()(includes logger and recovery middleware). Then you define routes and start the server.
package mainimport ("net/http""github.com/gin-gonic/gin")func main() {// Create a Gin engine (bare, no default middleware)r := gin.New()// Add middleware globallyr.Use(gin.Logger()) // Log every requestr.Use(gin.Recovery()) // Recover from panics// Simple router.GET("/api/health", func(c *gin.Context) {c.JSON(http.StatusOK, gin.H{"status": "ok",})})// Start server on port 8080r.Run(":8080")}
Route Groups & Middleware
Route groups let you organize related routes under a common prefix and apply middleware to all routes in the group at once. This is how Grit separates public routes (no auth), protected routes (login required), and admin routes (admin role required).
// Public routes — no authenticationauth := r.Group("/api/auth"){auth.POST("/register", authHandler.Register)auth.POST("/login", authHandler.Login)auth.POST("/refresh", authHandler.Refresh)}// Protected routes — requires valid JWT tokenprotected := r.Group("/api")protected.Use(middleware.Auth(db, authService)) // Apply auth middleware{protected.GET("/auth/me", authHandler.Me)protected.GET("/users/:id", userHandler.GetByID)}// Admin routes — requires ADMIN roleadmin := r.Group("/api")admin.Use(middleware.Auth(db, authService))admin.Use(middleware.RequireRole("ADMIN")) // Stack middleware{admin.GET("/users", userHandler.List)admin.POST("/users", userHandler.Create)admin.PUT("/users/:id", userHandler.Update)admin.DELETE("/users/:id", userHandler.Delete)}
The gin.Context Object
Every handler receives *gin.Context. Here are the methods you will use most:
func exampleHandler(c *gin.Context) {// ── Reading the request ──────────────────────────────id := c.Param("id") // URL param: /users/:idpage := c.Query("page") // Query string: ?page=2page = c.DefaultQuery("page", "1") // With default valuevar input CreateUserInputerr := c.ShouldBindJSON(&input) // Parse + validate JSON bodytoken := c.GetHeader("Authorization") // Read a header// ── Sending responses ────────────────────────────────c.JSON(200, gin.H{"data": "hello"}) // Send JSONc.JSON(404, gin.H{ // Send error"error": gin.H{"code": "NOT_FOUND","message": "User not found",},})// ── Middleware data ──────────────────────────────────c.Set("user_id", uint(42)) // Store data (middleware → handler)userID, _ := c.Get("user_id") // Retrieve data// ── Control flow ────────────────────────────────────c.Abort() // Stop the middleware chainc.Next() // Continue to next middleware/handler}
Input Validation with Binding Tags
Gin uses struct tags to validate incoming JSON. When you call c.ShouldBindJSON(&input), Gin parses the request body, checks the binding tags, and returns an error if validation fails. No manual validation code needed.
// Gin validates this struct automaticallytype CreateUserInput struct {Name string `json:"name" binding:"required"` // Must be presentEmail string `json:"email" binding:"required,email"` // Must be valid emailPassword string `json:"password" binding:"required,min=8"` // Min 8 charactersAge int `json:"age" binding:"gte=18,lte=120"` // Between 18-120Role string `json:"role" binding:"oneof=USER EDITOR"` // Must be one of these}func createUser(c *gin.Context) {var input CreateUserInputif err := c.ShouldBindJSON(&input); err != nil {// Gin returns detailed validation errors automaticallyc.JSON(422, gin.H{"error": gin.H{"code": "VALIDATION_ERROR","message": err.Error(),},})return}// input is now validated and safe to usefmt.Println(input.Name, input.Email)}
In Grit
All API routes are defined in internal/routes/routes.go. The Setup()function creates a Gin engine, applies global middleware (Logger, Recovery, CORS), then organizes routes into groups: public auth, protected, profile, and admin. Middleware like Auth() and RequireRole("ADMIN") are applied per-group. When you generate a new resource, the CLI injects routes into the correct group using marker comments.
13. Middleware
Middleware is a function that runs before (or after) your handler. It sits in the request chain and can inspect, modify, or reject requests. Think of it as a pipeline: each request passes through a series of middleware functions before reaching the handler.
In Gin, middleware is a gin.HandlerFunc -- the same type as a handler. The difference is that middleware calls c.Next() to pass control to the next function in the chain, or c.Abort() to stop the chain entirely (e.g., when authentication fails).
// A middleware is just a gin.HandlerFunc that calls c.Next()func Logger() gin.HandlerFunc {return func(c *gin.Context) {start := time.Now()c.Next() // ← Run the next handler/middleware// This runs AFTER the handler returnsduration := time.Since(start)status := c.Writer.Status()log.Printf("%s %s → %d (%v)", c.Request.Method, c.Request.URL.Path, status, duration)}}// Middleware that blocks requests (c.Abort)func RequireAPIKey() gin.HandlerFunc {return func(c *gin.Context) {key := c.GetHeader("X-API-Key")if key != "valid-key" {c.JSON(401, gin.H{"error": "Invalid API key"})c.Abort() // ← Stop the chain, handler never runsreturn}c.Next()}}
The Middleware Chain
Middleware runs in the order you add it. When a request comes in, it flows through each middleware, then the handler, and back out through the middleware in reverse:
Request → Logger → CORS → Auth → RequireRole → Handler↓Response ← Logger ← CORS ← Auth ← RequireRole ← Handler// If Auth calls c.Abort():Request → Logger → CORS → Auth ✗ (returns 401, handler never runs)
r := gin.New()// Global middleware — runs on EVERY requestr.Use(middleware.Logger())r.Use(gin.Recovery())r.Use(middleware.CORS(cfg.CORSOrigins))// Group middleware — runs only on routes in this groupprotected := r.Group("/api")protected.Use(middleware.Auth(db, authService)) // Only protected routes{protected.GET("/users/:id", userHandler.GetByID)}// Stacking middleware — multiple on one groupadmin := r.Group("/api")admin.Use(middleware.Auth(db, authService)) // Must be logged inadmin.Use(middleware.RequireRole("ADMIN")) // AND must be admin{admin.DELETE("/users/:id", userHandler.Delete)}
In Grit
Grit scaffolds four middleware functions: Logger (request timing),CORS (cross-origin access), Auth (JWT validation), and RequireRole (role-based access). They are applied in routes.go: Logger and CORS are global, Auth is per-group, and RequireRole stacks on top of Auth for admin routes.
14. CORS
CORS (Cross-Origin Resource Sharing) is a browser security feature that blocks web pages from making requests to a different domain than the one that served them. Your Next.js frontend runs on localhost:3000 but your Go API runs on localhost:8080 -- that's a different origin, so the browser blocks the request by default.
To fix this, the API must send special headers (Access-Control-Allow-Origin) telling the browser which origins are allowed. This is handled by CORS middleware.
package middlewareimport ("strings""github.com/gin-gonic/gin")// CORS returns middleware that allows cross-origin requests.func CORS(allowedOrigins string) gin.HandlerFunc {origins := strings.Split(allowedOrigins, ",")return func(c *gin.Context) {origin := c.GetHeader("Origin")// Check if the request origin is allowedfor _, allowed := range origins {if strings.TrimSpace(allowed) == origin {c.Header("Access-Control-Allow-Origin", origin)c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")c.Header("Access-Control-Allow-Credentials", "true")break}}// Handle preflight requestsif c.Request.Method == "OPTIONS" {c.AbortWithStatus(204)return}c.Next()}}
Preflight requests: Before making a POST or PUT request, the browser sends an OPTIONS request first (called a "preflight") to check if CORS is allowed. The middleware handles this by returning a 204 with the correct headers.
# Comma-separated list of allowed frontend originsCORS_ORIGINS=http://localhost:3000,http://localhost:3001
In Grit
Grit's CORS middleware reads allowed origins from the CORS_ORIGINS environment variable. By default, it allows localhost:3000 (web app) and localhost:3001 (admin panel). In production, update this to your actual domain. CORS is applied globally in routes.go so every endpoint is accessible from the frontend.
15. Handlers
A handler is the function that runs when an HTTP request matches a route. In Grit, handlers follow the thin handler pattern: they do four things and nothing more:
- Parse the request (URL params, query strings, JSON body)
- Validate the input (using binding tags)
- Delegate to a service or database call
- Respond with the appropriate JSON and status code
Handlers do NOT contain business logic. They don't hash passwords, calculate totals, send emails, or query related data. All of that goes in services. This separation makes your code testable and keeps each layer focused on one job.
package handlersimport ("net/http""github.com/gin-gonic/gin""gorm.io/gorm""myapp/apps/api/internal/models""myapp/apps/api/internal/services")// Handler struct holds dependenciestype AuthHandler struct {DB *gorm.DBAuthService *services.AuthService}// Request struct — what the client sendstype loginRequest struct {Email string `json:"email" binding:"required,email"`Password string `json:"password" binding:"required"`}// Login authenticates a user and returns JWT tokens.func (h *AuthHandler) Login(c *gin.Context) {// 1. Parse & validatevar req loginRequestif err := c.ShouldBindJSON(&req); err != nil {c.JSON(http.StatusUnprocessableEntity, gin.H{"error": gin.H{"code": "VALIDATION_ERROR","message": err.Error(),},})return}// 2. Find user in databasevar user models.Userif err := h.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "INVALID_CREDENTIALS","message": "Invalid email or password",},})return}// 3. Check password (delegate to model method)if !user.CheckPassword(req.Password) {c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "INVALID_CREDENTIALS","message": "Invalid email or password",},})return}// 4. Generate tokens (delegate to auth service)tokens, err := h.AuthService.GenerateTokenPair(user.ID, user.Email, user.Role)if err != nil {c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "TOKEN_ERROR","message": "Failed to generate tokens",},})return}// 5. Respondc.JSON(http.StatusOK, gin.H{"data": gin.H{"user": user,"tokens": tokens,},"message": "Logged in successfully",})}
In Grit
Grit scaffolds auth handlers (Login, Register, Refresh,ForgotPassword, Me) and a user handler (List, Create,GetByID, Update, Delete). When you generate a resource, the CLI creates a handler with all five CRUD methods plus pagination, search, and sorting -- all following the same thin pattern.
16. Services & The Service Pattern
A service is a struct with methods that contain your business logic. It sits between the handler (HTTP layer) and the database (data layer). But why not just put the logic directly in the handler?
Why Services Exist
- Separation of concerns -- handlers deal with HTTP, services deal with logic. Each layer has one job.
- Testability -- you can test business logic without spinning up an HTTP server. Just create a service with a test database and call its methods.
- Reusability -- the same service method can be called from a handler, a background job, a CLI command, or a cron task. If the logic was in the handler, you'd have to duplicate it.
- Maintainability -- when business rules change, you update one service method instead of hunting through handlers.
package servicesimport ("fmt""math""gorm.io/gorm""myapp/apps/api/internal/models")// Service struct — holds the database connectiontype ProductService struct {DB *gorm.DB}// All operations are methods on the service// List returns paginated products with search and sorting.func (s *ProductService) List(page, pageSize int, search, sortBy, sortOrder string) ([]models.Product, int64, int, error) {query := s.DB.Model(&models.Product{})if search != "" {query = query.Where("name ILIKE ?", "%"+search+"%")}var total int64query.Count(&total)var items []models.Productoffset := (page - 1) * pageSizeerr := query.Order(sortBy + " " + sortOrder).Offset(offset).Limit(pageSize).Find(&items).Errorif err != nil {return nil, 0, 0, fmt.Errorf("fetching products: %w", err)}pages := int(math.Ceil(float64(total) / float64(pageSize)))return items, total, pages, nil}// GetByID returns a single product.func (s *ProductService) GetByID(id uint) (*models.Product, error) {var item models.Productif err := s.DB.First(&item, id).Error; err != nil {return nil, fmt.Errorf("product not found: %w", err)}return &item, nil}// Create adds a new product.func (s *ProductService) Create(item *models.Product) error {if err := s.DB.Create(item).Error; err != nil {return fmt.Errorf("creating product: %w", err)}return nil}// Delete soft-deletes a product.func (s *ProductService) Delete(id uint) error {var item models.Productif err := s.DB.First(&item, id).Error; err != nil {return fmt.Errorf("product not found: %w", err)}return s.DB.Delete(&item).Error}
How Handlers Call Services
The handler creates or receives a service instance, then calls its methods. The handler's only job is to translate between HTTP and the service layer:
type ProductHandler struct {Service *services.ProductService}func (h *ProductHandler) GetByID(c *gin.Context) {id, _ := strconv.ParseUint(c.Param("id"), 10, 64)// Delegate to serviceproduct, err := h.Service.GetByID(uint(id))if err != nil {c.JSON(404, gin.H{"error": gin.H{"code": "NOT_FOUND", "message": "Product not found"}})return}// Respondc.JSON(200, gin.H{"data": product})}
In Grit
Every generated resource gets a service in internal/services/ withList, GetByID, Create, Update, and Delete methods. The auth service (AuthService) handles JWT token generation and validation. Background job workers also call services -- the same ProductService.Create() method can be called from an HTTP handler or an async job worker.
17. GORM In Depth
GORM is Go's most popular ORM. It maps Go structs to database tables and provides a chainable API for queries. Let's cover the key operations you'll use daily.
Database Connection
GORM connects to PostgreSQL using the gorm.io/driver/postgres driver. You open a connection once at startup and pass it everywhere via dependency injection.
package databaseimport ("fmt""log""gorm.io/driver/postgres""gorm.io/gorm""gorm.io/gorm/logger")func Connect(dsn string) (*gorm.DB, error) {db, err := gorm.Open(postgres.New(postgres.Config{DSN: dsn,PreferSimpleProtocol: true,}), &gorm.Config{Logger: logger.Default.LogMode(logger.Info),})if err != nil {return nil, fmt.Errorf("failed to connect: %w", err)}// Configure connection poolsqlDB, _ := db.DB()sqlDB.SetMaxIdleConns(10)sqlDB.SetMaxOpenConns(100)log.Println("Database connected successfully")return db, nil}
CRUD Operations
GORM provides a chainable API for all database operations. Each method returns the same *gorm.DB, so you can chain them together.
// ── CREATE ─────────────────────────────────────────────product := models.Product{Name: "Widget", Price: 29.99}db.Create(&product) // INSERT INTO products ...fmt.Println(product.ID) // ID is auto-set after create// ── READ — single record ──────────────────────────────var found models.Productdb.First(&found, 42) // WHERE id = 42db.Where("email = ?", "alice@test.com").First(&found) // WHERE email = ...// ── READ — multiple records ───────────────────────────var products []models.Productdb.Find(&products) // SELECT * FROM productsdb.Where("price > ?", 20.0).Find(&products) // With condition// ── READ — pagination and sorting ─────────────────────db.Order("created_at desc").Offset(0). // Skip 0 records (page 1)Limit(20). // Take 20 recordsFind(&products)// ── READ — count ──────────────────────────────────────var total int64db.Model(&models.Product{}).Count(&total)// ── READ — search with ILIKE (case-insensitive) ──────search := "widget"db.Where("name ILIKE ?", "%"+search+"%").Find(&products)// ── UPDATE ────────────────────────────────────────────db.Model(&found).Update("price", 34.99) // Single fielddb.Model(&found).Updates(map[string]any{ // Multiple fields"name": "Super Widget","price": 39.99,})// ── DELETE (soft delete) ──────────────────────────────db.Delete(&found) // Sets deleted_at, doesn't remove row// To permanently delete: db.Unscoped().Delete(&found)
Relationships & Preloading
When a model has relationships (belongs-to, has-many), GORM does NOT load related data automatically. You must use Preload() to eagerly load them.
// Models with relationshipstype Category struct {ID uint `gorm:"primarykey" json:"id"`Name string `json:"name"`Products []Product `json:"products"` // has many}type Product struct {ID uint `gorm:"primarykey" json:"id"`Name string `json:"name"`CategoryID uint `json:"category_id"` // foreign keyCategory Category `json:"category"` // belongs to}// Without Preload — category field will be empty {}db.First(&product, 1)fmt.Println(product.Category.Name) // "" (empty!)// With Preload — category is loadeddb.Preload("Category").First(&product, 1)fmt.Println(product.Category.Name) // "Electronics"
Hooks (Lifecycle Callbacks)
GORM hooks are methods on your model that run automatically at specific points in the lifecycle. The most common hook is BeforeCreate, used to hash passwords before they are stored in the database.
import "golang.org/x/crypto/bcrypt"// BeforeCreate runs automatically before INSERTfunc (u *User) BeforeCreate(tx *gorm.DB) error {if u.Password != "" {hashed, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost,)if err != nil {return err}u.Password = string(hashed)}return nil}// CheckPassword compares plaintext against stored hashfunc (u *User) CheckPassword(password string) bool {err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password),)return err == nil}// Usage — password is hashed automaticallyuser := models.User{Email: "alice@example.com",Password: "mypassword123", // Plaintext here}db.Create(&user) // BeforeCreate hashes it before INSERT
In Grit
Every resource generated by grit generate resource gets a service file with these exact GORM operations: Create, List (with pagination, search, and sorting), GetByID (with Preload),Update, and Delete. The database connection is established once in internal/database/database.go and passed to all services via dependency injection.
18. Migrations & Seeding
Migrations create database tables from your Go structs.Seeding populates tables with initial data for development. Both are essential for getting a working database up and running.
AutoMigrate
GORM's AutoMigrate reads your struct fields and creates or updates the corresponding database table. It will add new columns but will NOT delete removed columns or change existing column types (to prevent data loss).
package modelsimport ("log""gorm.io/gorm")// Models returns ALL models in migration order.// Models with no foreign key dependencies come first.func Models() []interface{} {return []interface{}{&User{},&Upload{},&Blog{},// grit:models ← new models are injected here}}// Migrate creates tables that don't exist yet.func Migrate(db *gorm.DB) error {models := Models()for _, model := range models {// Skip if table already existsif db.Migrator().HasTable(model) {log.Printf(" ✓ %T — already exists, skipping", model)continue}if err := db.AutoMigrate(model); err != nil {return fmt.Errorf("migrating %T: %w", model, err)}log.Printf(" ✓ %T — created", model)}return nil}
Running Migrations
Grit provides a dedicated CLI command for migrations with a --freshflag that drops all tables before recreating them (useful during development).
Seeding
Seeders create test data for development. A good seeder is idempotent -- it checks if data already exists before creating it, so you can run it multiple times safely.
package databaseimport ("log""myapp/apps/api/internal/models""gorm.io/gorm")// Seed populates the database with initial data.func Seed(db *gorm.DB) error {if err := seedAdminUser(db); err != nil {return fmt.Errorf("seeding admin: %w", err)}if err := seedDemoUsers(db); err != nil {return fmt.Errorf("seeding users: %w", err)}// grit:seeders ← new seeders injected herereturn nil}// Idempotent seeder — checks before creatingfunc seedAdminUser(db *gorm.DB) error {var count int64db.Model(&models.User{}).Where("email = ?", "admin@example.com").Count(&count)if count > 0 {log.Println("Admin already exists, skipping...")return nil}admin := models.User{FirstName: "Admin",LastName: "User",Email: "admin@example.com",Password: "password", // Hashed by BeforeCreate hookRole: "ADMIN",Active: true,}if err := db.Create(&admin).Error; err != nil {return fmt.Errorf("creating admin: %w", err)}log.Println("Created admin: admin@example.com / password")return nil}
In Grit
Grit scaffolds both cmd/migrate/main.go and cmd/seed/main.goout of the box. The seed file includes an admin user, demo users with different roles, and sample blog posts. When you generate a new resource, the model is automatically registered in Models() for migration. You can also use grit migrateand grit seed CLI commands.
19. JWT & Authentication
JWT (JSON Web Token) is how Grit authenticates users. Understanding this flow is critical because it connects the frontend, the API, the middleware, and the database. Let's break it down step by step.
What is a JWT?
A JWT is a signed string that contains data (called claims). The server creates a token by encoding claims (user ID, email, role) and signing it with a secret key. The client stores this token and sends it with every request. The server validates the signature to verify the token hasn't been tampered with.
// 1. JWT contains "claims" — data about the usertype Claims struct {UserID uint `json:"user_id"`Email string `json:"email"`Role string `json:"role"`jwt.RegisteredClaims // Expiry, issued-at, etc.}// 2. Server creates a token by signing claims with a secretclaims := &Claims{UserID: 42,Email: "alice@example.com",Role: "ADMIN",RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),IssuedAt: jwt.NewNumericDate(time.Now()),},}token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)tokenString, _ := token.SignedString([]byte("my-secret-key"))// tokenString = "eyJhbGciOiJIUzI1NiIs..."// 3. Later, server validates the tokenparsed, _ := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {return []byte("my-secret-key"), nil})claims = parsed.Claims.(*Claims)fmt.Println(claims.UserID) // 42
The Authentication Flow
Here is the complete flow from registration to authenticated requests. Understanding this will make the entire auth system click.
┌─────────────────────────────────────────────────────────────────┐│ 1. REGISTER ││ ││ Client sends: POST /api/auth/register ││ { "email": "alice@test.com", ││ "password": "mypassword" } ││ ││ Server does: ① Validate input (binding tags) ││ ② Check email doesn't already exist ││ ③ Create user (BeforeCreate hashes password) ││ ④ Generate access token (15min) + refresh ││ token (7 days) ││ ⑤ Return { user, tokens } │└─────────────────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────────────────┐│ 2. LOGIN ││ ││ Client sends: POST /api/auth/login ││ { "email": "alice@test.com", ││ "password": "mypassword" } ││ ││ Server does: ① Find user by email (db.Where) ││ ② Check password (bcrypt.CompareHashAndPassword)││ ③ Check account is active ││ ④ Generate new token pair ││ ⑤ Return { user, tokens } │└─────────────────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────────────────┐│ 3. AUTHENTICATED REQUEST ││ ││ Client sends: GET /api/users ││ Authorization: Bearer eyJhbGciOi... ││ ││ Middleware does: ① Extract token from "Bearer <token>" ││ ② Validate signature + check expiry ││ ③ Load user from DB by claims.UserID ││ ④ Set user data in context ││ ⑤ Call c.Next() → handler runs ││ ││ Handler does: Read c.Get("user") → return response │└─────────────────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────────────────┐│ 4. TOKEN REFRESH ││ ││ When access token expires (15min), client sends: ││ POST /api/auth/refresh { "refresh_token": "eyJ..." } ││ ││ Server validates the refresh token and returns new tokens. ││ Client never needs to log in again until the refresh ││ token expires (7 days). │└─────────────────────────────────────────────────────────────────┘
The Auth Service
The auth service handles all token operations. It's a struct with the JWT secret and expiry durations, with methods for generating and validating tokens.
type AuthService struct {Secret stringAccessExpiry time.Duration // e.g., 15 minutesRefreshExpiry time.Duration // e.g., 7 days}type TokenPair struct {AccessToken string `json:"access_token"`RefreshToken string `json:"refresh_token"`ExpiresAt int64 `json:"expires_at"`}// GenerateTokenPair creates both access and refresh tokens.func (s *AuthService) GenerateTokenPair(userID uint, email, role string) (*TokenPair, error) {// Access token — short-lived, used for API requestsaccessToken, expiresAt, err := s.generateToken(userID, email, role, s.AccessExpiry)if err != nil {return nil, fmt.Errorf("generating access token: %w", err)}// Refresh token — long-lived, used only to get new access tokensrefreshToken, _, err := s.generateToken(userID, email, role, s.RefreshExpiry)if err != nil {return nil, fmt.Errorf("generating refresh token: %w", err)}return &TokenPair{AccessToken: accessToken,RefreshToken: refreshToken,ExpiresAt: expiresAt,}, nil}// ValidateToken parses and verifies a token string.func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {token, err := jwt.ParseWithClaims(tokenString, &Claims{},func(token *jwt.Token) (interface{}, error) {// Verify the signing method is HMAC (prevent algorithm attacks)if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {return nil, fmt.Errorf("unexpected signing method")}return []byte(s.Secret), nil},)if err != nil {return nil, fmt.Errorf("parsing token: %w", err)}claims, ok := token.Claims.(*Claims)if !ok || !token.Valid {return nil, fmt.Errorf("invalid token")}return claims, nil}
In Grit
The auth service is created in routes.go with the JWT secret and expiry durations from the config. It's passed to the auth handler and the auth middleware. On the frontend, React Query stores the tokens and automatically refreshes them when the access token expires. The api-client.ts intercepts 401 responses and tries a silent refresh before showing the login page.
20. RBAC & Middleware
RBAC (Role-Based Access Control) controls who can do what. Grit uses three default roles: ADMIN, EDITOR, and USER. This is enforced through two middleware functions that work together.
Auth Middleware
The Auth middleware runs on every protected route. It extracts the JWT from the Authorization header, validates it, loads the user from the database, and stores the user data in the Gin context so handlers can access it.
func Auth(db *gorm.DB, authService *services.AuthService) gin.HandlerFunc {return func(c *gin.Context) {// 1. Get the Authorization headerauthHeader := c.GetHeader("Authorization")if authHeader == "" {c.JSON(401, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "Authorization header required"}})c.Abort() // Stop the chain — handler never runsreturn}// 2. Extract "Bearer <token>"parts := strings.SplitN(authHeader, " ", 2)if len(parts) != 2 || parts[0] != "Bearer" {c.JSON(401, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "Invalid header format"}})c.Abort()return}// 3. Validate the tokenclaims, err := authService.ValidateToken(parts[1])if err != nil {c.JSON(401, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "Invalid or expired token"}})c.Abort()return}// 4. Load user from databasevar user models.Userif err := db.First(&user, claims.UserID).Error; err != nil {c.JSON(401, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "User not found"}})c.Abort()return}// 5. Store user data in context for handlersc.Set("user", user)c.Set("user_id", user.ID)c.Set("user_role", user.Role)c.Next() // Continue to the handler}}
RequireRole Middleware
The RequireRole middleware stacks on top of Auth. It reads the role that Auth stored in the context and checks if it matches one of the allowed roles. If not, it returns a 403 Forbidden.
// RequireRole checks if the authenticated user has one of the required roles.// Uses variadic args — you can pass one or more roles.func RequireRole(roles ...string) gin.HandlerFunc {return func(c *gin.Context) {// Read the role that Auth middleware stored in contextuserRole, exists := c.Get("user_role")if !exists {c.JSON(401, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "Not authenticated"}})c.Abort()return}role := userRole.(string)// Check if user's role matches any allowed rolefor _, r := range roles {if role == r {c.Next() // Role matches — continuereturn}}// No match — forbiddenc.JSON(403, gin.H{"error": gin.H{"code": "FORBIDDEN", "message": "You do not have permission"}})c.Abort()}}// Usage in routes:// admin.Use(middleware.RequireRole("ADMIN")) // Only admins// editor.Use(middleware.RequireRole("ADMIN", "EDITOR")) // Admins + editors
How c.Set / c.Get Passes Data
The *gin.Context acts as a shared data bag between middleware and handlers in the same request. Middleware uses c.Set() to store data, and handlers use c.Get() to retrieve it. This is how the user object flows from the auth middleware to any handler:
// In Auth middleware:c.Set("user", user) // Store the full user structc.Set("user_id", user.ID) // Store just the ID (convenience)c.Set("user_role", user.Role)// In any handler on a protected route:func (h *UserHandler) GetProfile(c *gin.Context) {// Get the user object stored by middlewareuserData, _ := c.Get("user")user := userData.(models.User) // Type assert from any → User// Or get just the IDuserID, _ := c.Get("user_id")id := userID.(uint)c.JSON(200, gin.H{"data": user})}
In Grit
Grit scaffolds three route groups: public (no auth), protected (Auth middleware), and admin (Auth + RequireRole). You can add custom role-restricted groups withgrit generate resource --roles ADMIN,EDITOR. The grit add role MODERATORcommand adds a new role across the entire codebase (Go constants, Zod schemas, TypeScript types, sidebar visibility, form options) in one step.
21. Important Packages
These are the Go packages used in every Grit backend. You don't need to memorize them -- they are all pre-configured when you scaffold a project. But knowing what they do helps you understand the generated code.
| Package | What It Does |
|---|---|
| github.com/gin-gonic/gin | HTTP framework — router, middleware, JSON binding, validation |
| gorm.io/gorm | ORM — maps Go structs to database tables, chainable queries |
| gorm.io/driver/postgres | PostgreSQL driver for GORM |
| github.com/golang-jwt/jwt/v5 | JWT creation and validation for authentication |
| golang.org/x/crypto/bcrypt | Password hashing (used in User model's BeforeCreate hook) |
| github.com/joho/godotenv | Load .env files into environment variables |
| github.com/redis/go-redis/v9 | Redis client for caching and session storage |
| github.com/hibiken/asynq | Background job queue and cron scheduler (Redis-backed) |
| github.com/aws/aws-sdk-go-v2 | S3-compatible file storage (AWS S3, Cloudflare R2, MinIO) |
| github.com/resend/resend-go/v2 | Transactional email service |
| github.com/disintegration/imaging | Image resizing and thumbnail generation |
| github.com/MUKE-coder/gorm-studio | Visual database browser embedded at /studio |
| github.com/MUKE-coder/sentinel | Security suite — WAF, rate limiting, threat dashboard |
The standard library packages you will encounter most often:
| Package | What It Does |
|---|---|
| fmt | Formatted printing and string formatting |
| net/http | HTTP status codes (http.StatusOK, http.StatusNotFound, etc.) |
| os | Environment variables, file operations, process exit |
| time | Timestamps, durations, token expiry |
| strings | String manipulation (Split, Contains, ToLower, etc.) |
| strconv | String-to-number conversion (Atoi for page params) |
| log | Logging (log.Println, log.Fatalf) |
| errors | Error creation and wrapping |
| math | math.Ceil for pagination page count |
22. Putting It Together
Now you understand all the Go concepts that power a Grit backend. Here is how they connect in the request lifecycle. When an HTTP request hits your API, it flows through a predictable chain:
- main.go -- loads config, connects to the database, initializes services, starts the server
- routes.go -- matches the URL to a handler, runs middleware (auth, CORS, logging)
- Auth middleware -- extracts JWT, validates token, loads user from DB, sets
c.Set("user", ...) - RequireRole middleware -- checks
c.Get("user_role")against allowed roles - handler -- parses the request, validates input with struct tags, calls the service
- service -- contains business logic, uses GORM to query the database, returns (result, error)
- handler -- checks the error, sends the JSON response with the correct status code
GET /api/products/42│▼┌─── main.go ───────────────────────────────┐│ cfg := config.Load() ││ db := database.Connect(cfg) ││ svc := &services.ProductService{DB: db} ││ routes.Setup(db, cfg, svc) │└───────────────────────────────────────────┘│▼┌─── routes.go ─────────────────────────────┐│ protected := r.Group("/api") ││ protected.Use(middleware.Auth(db, auth)) ││ protected.GET("/products/:id", h.GetByID) │└───────────────────────────────────────────┘│▼┌─── middleware/auth.go ────────────────────┐│ token := c.GetHeader("Authorization") ││ claims := authService.ValidateToken(token)││ user := db.First(&user, claims.UserID) ││ c.Set("user", user) ││ c.Set("user_role", user.Role) ││ c.Next() │└───────────────────────────────────────────┘│▼┌─── handlers/product.go ──────────────────┐│ id := c.Param("id") ││ product, err := svc.GetByID(id) ││ if err != nil { c.JSON(404, ...) } ││ c.JSON(200, gin.H{"data": product}) │└───────────────────────────────────────────┘│▼┌─── services/product.go ──────────────────┐│ func (s *ProductService) GetByID(id) { ││ var product models.Product ││ err := s.DB.Preload("Category"). ││ First(&product, id).Error ││ return product, err ││ } │└───────────────────────────────────────────┘
In Grit
This entire flow is generated for you. When you run grit generate resource Product, it creates the model, service, handler, routes, and injects everything into the right files. You get a fully working CRUD API with pagination, filtering, authentication, and role-based access in seconds. Understanding this flow helps you customize the generated code and build features beyond basic CRUD.