Skip to content

Models & CRUD

What a model is, what it is not, and the four operations everything else is built out of.

Models and the schema they describe

core.IModel asks for one method. The interesting decisions are the ones around it: money as an integer count of the smallest unit, enums as strings with constants in front of them, UTC timestamps, and what gorm.DeletedAt does to a unique index. AutoMigrate appears once, so the example runs — see Schema & migrations for who owns the schema in production.

go
package main

import (
	"time"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/utils"
	"gorm.io/gorm"
)

// --- Example 1: models, column types, and who owns the schema ---------------
//
// core.IModel asks for exactly one method — TableName on the value, which is
// what makes repository.New[User] work rather than New[*User]. Everything else
// is ordinary GORM tagging.
//
// The struct *describes* a table a migration already created; it does not own
// one. devMigrate at the bottom of this file exists so `go run .` works against
// an empty sqlite database and for no other reason.

// A status is a string with constants in front of it, not an int. An int enum
// is unreadable in a psql session and silently reassigns itself the day someone
// inserts a value in the middle of the list.
type UserStatus string

const (
	UserActive  UserStatus = "active"
	UserTrial   UserStatus = "trial"
	UserDormant UserStatus = "dormant"
)

type OrderStatus string

const (
	OrderPending   OrderStatus = "pending"
	OrderPaid      OrderStatus = "paid"
	OrderCancelled OrderStatus = "cancelled"
)

type User struct {
	ID     string     `json:"id"     gorm:"column:id;primaryKey"`
	Email  string     `json:"email"  gorm:"column:email;uniqueIndex"`
	Name   string     `json:"name"   gorm:"column:name"`
	Status UserStatus `json:"status" gorm:"column:status;index"`

	// Money is an integer count of the smallest unit, never a float. 0.1 + 0.2
	// is not 0.3 in binary floating point, and a balance that drifts by a
	// satang every few thousand transactions is a bug nobody can reproduce.
	CreditsSatang int64 `json:"credits_satang" gorm:"column:credits_satang"`

	// GORM maintains these two, in UTC — core.NewDatabase sets NowFunc — so what
	// is written does not depend on the server's timezone. Convert to the user's
	// zone at the very top layer, never in the database.
	//
	// They are pointers because that is the house rule for every time column: a
	// value type cannot tell "not set yet" apart from year 1, and the zero time
	// is what a nullable column decodes into. utils.ToNonPointer reads one back
	// when the call site wants a value.
	CreatedAt *time.Time `json:"created_at" gorm:"column:created_at"`
	UpdatedAt *time.Time `json:"updated_at" gorm:"column:updated_at"`

	// gorm.DeletedAt is what turns Delete() into a soft delete and adds
	// "deleted_at IS NULL" to every query. Worth knowing before you add it: the
	// uniqueIndex on email above still sees the dead rows, so a deleted address
	// can never be registered again. On postgres that wants to be a partial
	// index instead — CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL.
	DeletedAt gorm.DeletedAt `json:"-" gorm:"column:deleted_at;index"`

	Profile *Profile `json:"profile,omitempty" gorm:"foreignKey:UserID"`
	Orders  []Order  `json:"orders,omitempty"  gorm:"foreignKey:UserID"`
}

func (User) TableName() string { return "users" }

// BeforeCreate is the one job hooks are good for: deriving a field from the row
// itself. A hook that sends mail, publishes an event or queries another table
// makes every write an invisible side effect that runs inside whatever
// transaction happens to be open — that belongs in a service method.
func (u *User) BeforeCreate(*gorm.DB) error {
	if u.ID == "" {
		u.ID = utils.NewUUID()
	}
	return nil
}

type Profile struct {
	ID     string `json:"id"      gorm:"column:id;primaryKey"`
	UserID string `json:"user_id" gorm:"column:user_id;uniqueIndex"`
	City   string `json:"city"    gorm:"column:city"`
}

func (Profile) TableName() string { return "profiles" }

func (p *Profile) BeforeCreate(*gorm.DB) error {
	if p.ID == "" {
		p.ID = utils.NewUUID()
	}
	return nil
}

type Order struct {
	ID     string      `json:"id"      gorm:"column:id;primaryKey"`
	UserID string      `json:"user_id" gorm:"column:user_id;index"`
	Status OrderStatus `json:"status"  gorm:"column:status;index"`
	// The pair (user_id, created_at) is the index this table actually wants:
	// one composite answers both "this user's orders" and "newest first",
	// which two single-column indexes do not.
	TotalSatang int64      `json:"total_satang" gorm:"column:total_satang"`
	CreatedAt   *time.Time `json:"created_at"   gorm:"column:created_at;index"`

	User  *User       `json:"user,omitempty"  gorm:"foreignKey:UserID"`
	Items []OrderItem `json:"items,omitempty" gorm:"foreignKey:OrderID"`
}

func (Order) TableName() string { return "orders" }

func (o *Order) BeforeCreate(*gorm.DB) error {
	if o.ID == "" {
		o.ID = utils.NewUUID()
	}
	return nil
}

type OrderItem struct {
	ID          string `json:"id"       gorm:"column:id;primaryKey"`
	OrderID     string `json:"order_id" gorm:"column:order_id;index"`
	SKU         string `json:"sku"      gorm:"column:sku"`
	Qty         int64  `json:"qty"      gorm:"column:qty"`
	PriceSatang int64  `json:"price_satang" gorm:"column:price_satang"`
}

func (OrderItem) TableName() string { return "order_items" }

func (i *OrderItem) BeforeCreate(*gorm.DB) error {
	if i.ID == "" {
		i.ID = utils.NewUUID()
	}
	return nil
}

// allModels is the list devMigrate and the seeds walk, in one place so a new
// table is one edit rather than three.
func allModels() []any { return []any{&User{}, &Profile{}, &Order{}, &OrderItem{}} }

// devMigrate builds the schema from the structs above.
//
// This is a development and test convenience only. AutoMigrate never drops a
// column, never changes a type, never renames, and knows nothing about partial
// indexes, check constraints or backfills — so the schema it produces drifts
// away from the one you think you have, silently, until a query fails in
// production for a reason that is nowhere in git history. Real schema belongs
// to a migration tool run as its own deploy step (v2/docs/migrations.md).
func devMigrate(db *gorm.DB) core.IError {
	if err := db.AutoMigrate(allModels()...); err != nil {
		return core.Wrap(err, "database example: dev migrate")
	}
	return nil
}

Create, read, update, delete

The repository takes its connection and its deadline from the context, so no query method needs a ctx. Also here: why a missing row is errmsgs.NotFound rather than a driver sentinel, why Updates with a struct silently ignores your false, and why branching a base query cannot leak conditions the way v1 did.

go
package main

import (
	"errors"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/errmsgs"
	"gitlab.finema.co/finema/idin-core/v2/repository"
	"gorm.io/gorm"
)

// --- Example 2: create, read, update, delete --------------------------------
//
// repository.New[M](ctx) takes both the connection and the deadline from the
// context, so no query method needs a ctx argument — and a client that hangs up
// cancels the query it started. Nothing here returns a raw error: a missing row
// is errmsgs.NotFound, everything else is DATABASE_ERROR, and both already
// carry the status the HTTP layer will use.

func crudTour(ctx core.IContext) core.IError {
	users := repository.New[User](ctx)

	// Create takes a pointer because the driver writes back into it: the id from
	// BeforeCreate and the timestamps GORM fills are only visible that way.
	u := User{Email: "[email protected]", Name: "Ann", Status: UserTrial, CreditsSatang: 25_000}
	if err := users.Create(&u); err != nil {
		return err
	}
	ctx.Log().Info("created", "id", u.ID, "created_at", u.CreatedAt)

	// A missing row is a named outcome, not a driver sentinel. errors.Is works
	// through the wrapping, so this holds however the error travelled.
	if _, err := users.Where("email = ?", "[email protected]").FindOne(); !errors.Is(err, errmsgs.NotFound) {
		return core.Newf(500, "EXAMPLE_FAILED", "expected NOT_FOUND, got %v", err)
	}

	// Count, Exists and FindAll never report emptiness as an error — zero rows
	// is an answer. Only FindOne, Take and Last can miss.
	n, err := users.Where("status = ?", UserTrial).Count()
	if err != nil {
		return err
	}
	ctx.Log().Info("trial users", "count", n)

	if err := crudCopyOnWrite(ctx); err != nil {
		return err
	}
	return crudUpdateAndDelete(ctx, u.ID)
}

// crudCopyOnWrite is the property that removes a whole class of v1 bugs: every
// chainable method clones, so a base query can be branched without one branch's
// conditions leaking into the next.
func crudCopyOnWrite(ctx core.IContext) core.IError {
	base := repository.New[User](ctx).Where("credits_satang > ?", 0)

	trial, err := base.Where("status = ?", UserTrial).Count()
	if err != nil {
		return err
	}
	active, err := base.Where("status = ?", UserActive).Count()
	if err != nil {
		return err
	}
	// Neither count saw the other's condition, and base is still just
	// "credits > 0". A Repo value is a query, not a connection — it is safe to
	// keep on a service struct and share between goroutines.
	ctx.Log().Info("branched off one base query", "trial", trial, "active", active)
	return nil
}

func crudUpdateAndDelete(ctx core.IContext, id string) core.IError {
	users := repository.New[User](ctx)

	// One column.
	if err := users.Where("id = ?", id).Update("name", "Ann B."); err != nil {
		return err
	}

	// Several columns. Use a map whenever a zero value is a value you mean:
	// Updates with a *struct* skips zero fields, so Status: "" and
	// CreditsSatang: 0 are indistinguishable from "not set" and are left alone.
	if err := users.Where("id = ?", id).Updates(map[string]any{
		"status":         UserActive,
		"credits_satang": 0,
	}); err != nil {
		return err
	}

	// Arithmetic belongs in the database. Read-modify-write in Go loses updates
	// the moment two requests do it at once.
	if err := users.Where("id = ?", id).
		Update("credits_satang", gorm.Expr("credits_satang + ?", 500)); err != nil {
		return err
	}

	// When the row count *is* the answer — a compare-and-swap, "did that
	// actually change anything" — take it from DB(). Putting the precondition
	// in the WHERE makes check-then-act atomic with no transaction and no lock.
	res := users.Where("id = ? AND status = ?", id, UserActive).
		DB().Update("status", UserDormant)
	if res.Error != nil {
		return ctx.NewError(res.Error, errmsgs.DBError)
	}
	if res.RowsAffected == 0 {
		return ctx.NewError(nil, errmsgs.NotFound)
	}

	// Soft delete: the row stays, and every later query filters it out for you.
	if err := users.Where("id = ?", id).Delete(); err != nil {
		return err
	}
	still, err := users.Unscoped().Where("id = ?", id).Count()
	if err != nil {
		return err
	}
	ctx.Log().Info("soft deleted", "rows_still_on_disk", still)

	// HardDelete is the only way to free the unique email again.
	return users.Where("id = ?", id).HardDelete()
}

Maintained by Passakon Puttasuwan & Dev Core Team.