Models & Database
Grit uses GORM as its ORM layer. Models are Go structs that map directly to database tables, with struct tags controlling schema behavior, JSON serialization, and request validation.
GORM Model Pattern
Every model in Grit is a plain Go struct decorated with three categories of struct tags:
gorm:"..."-- controls the database schema (column type, indexes, constraints).json:"..."-- controls how the field is serialized in API responses.binding:"..."-- controls request validation when Gin binds JSON input.
Here is a minimal model that demonstrates all three:
package models
import (
"time"
"gorm.io/gorm"
)
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"size:255;not null" json:"title" binding:"required,min=3"`
Slug string `gorm:"size:255;uniqueIndex;not null" json:"slug"`
Body string `gorm:"type:text" json:"body"`
Published bool `gorm:"default:false" json:"published"`
AuthorID uint `gorm:"not null;index" json:"author_id"`
Author User `gorm:"foreignKey:AuthorID" json:"author,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}Built-in User Model
Every Grit project ships with a User model out of the box. It includes authentication fields, role-based access, soft deletes, and a password-hashing hook.
package models
import (
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// Role constants
const (
RoleAdmin = "admin"
RoleEditor = "editor"
RoleUser = "user"
)
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"`
Avatar string `gorm:"size:500" json:"avatar"`
Active bool `gorm:"default:true" json:"active"`
EmailVerifiedAt *time.Time `json:"email_verified_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}Key things to note about the User model:
Passwordusesjson:"-"so it is never included in API responses.DeletedAtalso usesjson:"-"and enables GORM soft deletes.Emailhas auniqueIndexconstraint to prevent duplicate accounts.- Three built-in roles are defined as constants:
admin,editor,user.
Password Hashing Hook
The User model uses a GORM BeforeCreate hook to automatically hash the password with bcrypt before it is saved to the database. A CheckPassword method is provided for login verification.
// BeforeCreate hashes the password before saving.
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword(
[]byte(u.Password), bcrypt.DefaultCost,
)
if err != nil {
return err
}
u.Password = string(hashedPassword)
}
return nil
}
// CheckPassword compares the given password with the stored hash.
func (u *User) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword(
[]byte(u.Password), []byte(password),
)
return err == nil
}Upload Model
Grit includes an Upload model for tracking files stored in S3-compatible storage. Each upload is associated with a user and records the file metadata.
package models
import (
"time"
"gorm.io/gorm"
)
type Upload struct {
ID uint `gorm:"primarykey" json:"id"`
Filename string `gorm:"size:255;not null" json:"filename"`
OriginalName string `gorm:"size:255;not null" json:"original_name"`
MimeType string `gorm:"size:100;not null" json:"mime_type"`
Size int64 `gorm:"not null" json:"size"`
Path string `gorm:"size:500;not null" json:"path"`
URL string `gorm:"size:500" json:"url"`
ThumbnailURL string `gorm:"size:500" json:"thumbnail_url"`
UserID uint `gorm:"index;not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}Creating Custom Models
To create a new model, add a new file in apps/api/internal/models/. Follow the naming convention: one model per file, file name in snake_case, struct name in PascalCase.
package models
import (
"time"
"gorm.io/gorm"
)
type Invoice struct {
ID uint `gorm:"primarykey" json:"id"`
Number string `gorm:"size:50;uniqueIndex;not null" json:"number" binding:"required"`
CustomerID uint `gorm:"not null;index" json:"customer_id" binding:"required"`
Customer User `gorm:"foreignKey:CustomerID" json:"customer,omitempty"`
Amount float64 `gorm:"not null" json:"amount" binding:"required,gt=0"`
Status string `gorm:"size:20;default:pending" json:"status"`
DueDate time.Time `json:"due_date"`
Notes string `gorm:"type:text" json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}When using grit generate resource Invoice, this file is created automatically along with the handler, hooks, Zod schema, and admin page.
Field Types & GORM Tags
GORM maps Go types to database column types automatically. Use struct tags to fine-tune the schema.
| Go Type | PostgreSQL | Example Tag |
|---|---|---|
| string | varchar(256) | gorm:"size:255" |
| string | text | gorm:"type:text" |
| int / int64 | bigint | gorm:"not null" |
| uint | bigint unsigned | gorm:"primarykey" |
| float64 | double precision | gorm:"not null" |
| bool | boolean | gorm:"default:false" |
| time.Time | timestamp | json:"created_at" |
| *time.Time | timestamp (nullable) | json:"verified_at" |
| gorm.DeletedAt | timestamp (nullable) | gorm:"index" json:"-" |
Common GORM Tags
| Tag | Description |
|---|---|
| primarykey | Marks the field as the primary key |
| size:255 | Sets varchar length |
| not null | Adds NOT NULL constraint |
| uniqueIndex | Creates a unique index on the column |
| index | Creates a regular index on the column |
| default:value | Sets the default value for the column |
| type:text | Overrides the column type (e.g., text, jsonb) |
| foreignKey:FieldName | Specifies the foreign key for a relationship |
Relationships
GORM supports all standard relationship types. In Grit, the two most common arebelongsTo and hasMany.
Belongs To
A belongsTo relationship means the current model holds the foreign key. For example, a Post belongs to a User (the author):
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"size:255;not null" json:"title"`
// Foreign key field
AuthorID uint `gorm:"not null;index" json:"author_id"`
// Relationship -- GORM loads the User when you Preload("Author")
Author User `gorm:"foreignKey:AuthorID" json:"author,omitempty"`
}
// Usage in a handler or service:
var post Post
db.Preload("Author").First(&post, 1)The json:"author,omitempty" tag means the author object is only included in the JSON response when it has been preloaded. This avoids empty nested objects.
Has Many
A hasMany relationship means another model holds a foreign key pointing back to this model. For example, a User has many Posts:
type User struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:255;not null" json:"name"`
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
// Has many posts
Posts []Post `gorm:"foreignKey:AuthorID" json:"posts,omitempty"`
}
// Usage: load a user with all their posts
var user User
db.Preload("Posts").First(&user, 1)Many to Many
For many-to-many relationships, GORM creates a join table automatically:
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"size:255" json:"title"`
Tags []Tag `gorm:"many2many:post_tags;" json:"tags,omitempty"`
}
type Tag struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:100;uniqueIndex" json:"name"`
}
// GORM auto-creates a "post_tags" join table with post_id and tag_id columns.Soft Deletes
All Grit models include a DeletedAt field of type gorm.DeletedAt. When you call db.Delete(&record), GORM does not actually remove the row. Instead, it sets deleted_at to the current timestamp.
// Add this field to any model for soft deletes: DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // Soft-delete a record (sets deleted_at, does NOT remove the row) db.Delete(&user) // Normal queries automatically exclude soft-deleted records db.Find(&users) // only returns records where deleted_at IS NULL // To include soft-deleted records: db.Unscoped().Find(&users) // To permanently delete a record: db.Unscoped().Delete(&user)
The json:"-" tag on DeletedAt ensures the field is never exposed in API responses.
AutoMigrate
Grit uses GORM's AutoMigrate to keep the database schema in sync with your Go structs. When the server starts, it automatically creates or alters tables to match your models.
// AutoMigrate runs database migrations for all models.
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
&User{},
&Upload{},
// grit:models -- new models are added here by the generator
)
}This function is called in cmd/server/main.go during startup:
// Auto-migrate models
if err := models.AutoMigrate(db); err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}How AutoMigrate Works
AutoMigrate is safe to run repeatedly. It will:
- Create tables that do not exist yet.
- Add new columns if you add fields to a struct.
- Create indexes defined in struct tags.
AutoMigrate will not:
- Delete columns that no longer exist in the struct.
- Change a column's type if you change the Go type.
- Drop tables.
For destructive changes, use grit migrate:fresh in development (this drops all tables and re-migrates). In production, write manual SQL migrations.
Adding a New Model (Step by Step)
The fastest way to add a model is with the code generator. But if you want to do it manually, follow these steps:
- Create a new file in
apps/api/internal/models/(e.g.,product.go). - Define your struct with
gorm,json, andbindingtags. - Add
&Product{}to theAutoMigratecall inuser.go. - Restart the server -- GORM will create the table automatically.
package models
import (
"time"
"gorm.io/gorm"
)
type Product struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:255;not null" json:"name" binding:"required"`
Description string `gorm:"type:text" json:"description"`
Price float64 `gorm:"not null" json:"price" binding:"required,gt=0"`
SKU string `gorm:"size:100;uniqueIndex" json:"sku" binding:"required"`
Stock int `gorm:"default:0" json:"stock"`
Active bool `gorm:"default:true" json:"active"`
CategoryID uint `gorm:"index" json:"category_id"`
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}Or use the generator and let Grit do the work for you: