Guide 2 of 12

Models and migrations

Rails's Post is one class that is a table, a type and a query builder. Here that is three files, each with one job, plus the migration and a test. One command writes all five:

bogie g model post title:string body:text
      create  db/migrate/20261003053152_create_posts.sql
      create  db/queries/posts.sql
      create  app/domain/post.go
      create  app/models/posts.go
      create  app/models/posts_test.go
         run  sqlc generate

The migration

goose SQL, timestamped, embedded in the binary:

-- +goose Up
CREATE TABLE posts (
    id         uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    title      text        NOT NULL DEFAULT '',
    body       text        NOT NULL DEFAULT '',
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now()
);

-- +goose Down
DROP TABLE posts;

Every generated column is NOT NULL with a type-appropriate default, so sqlc emits plain Go types rather than pointers. A column that truly needs null is one edit to the migration, by hand.

The queries

db/queries/posts.sql, named statements that sqlc compiles:

-- name: GetPost :one
SELECT * FROM posts WHERE id = $1;

-- name: ListPosts :many
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT $1;

-- name: CreatePost :one
INSERT INTO posts (title, body) VALUES ($1, $2) RETURNING *;

-- name: UpdatePost :one
UPDATE posts SET title = $2, body = $3, updated_at = now() WHERE id = $1 RETURNING *;

-- name: DeletePost :execrows
DELETE FROM posts WHERE id = $1;

A query that names a column which does not exist fails at make sqlc, not in production.

The domain type

app/domain/post.go imports nothing third-party (rule 3). Ids are strings here; the store turns them into uuids:

type Post struct {
	ID        string
	Title     string
	Body      string
	CreatedAt time.Time
	UpdatedAt time.Time
}

// Validate is the model's validations, as Rails would put them. Wrap every
// failure in ErrInvalid so a controller can answer 422 without knowing the
// rule:
//
//	return fmt.Errorf("%w: title is required", ErrInvalid)
func (p Post) Validate() error {
	return nil
}

Validate starts empty; fill it in as Rails's validates would. It runs on both create and update, inside the store, so every caller gets it and none can forget it.

The store

app/models/posts.go converts between the domain type and sqlc's row type, maps "no rows" to domain.ErrNotFound, and runs Validate before writing:

func (s *Store) CreatePost(ctx context.Context, p domain.Post) (domain.Post, error) {
	if err := p.Validate(); err != nil {
		return domain.Post{}, err
	}
	row, err := s.q.CreatePost(ctx, CreatePostParams{Title: p.Title, Body: p.Body})
	if err != nil {
		return domain.Post{}, fmt.Errorf("models: create post: %w", err)
	}
	return postFromRow(row), nil
}

GetPost, ListPosts, UpdatePost and DeletePost follow the same shape. app/models/posts_test.go starts with one test, that an unknown id is ErrNotFound whatever it looks like, and a TODO for the round trip. Store tests run against the real database, so they need it up: make up first.

Attribute types

string text integer bigint boolean datetime uuid jsonb references, each optionally followed by :index or :uniq:

bogie g model comment body:text post:references score:integer

post:references writes the column post_id as uuid NOT NULL REFERENCES posts (id), with its own index. slug:string:uniq adds a unique constraint.

Changing a table

bogie g migration add_slug_to_posts slug:string:uniq
bogie g migration remove_draft_from_posts draft:boolean

Three migration name shapes get their SQL written automatically; any other name gets an empty Up and Down to fill in by hand:

-- +goose Up
ALTER TABLE posts ADD COLUMN slug text NOT NULL DEFAULT '' UNIQUE;

-- +goose Down
ALTER TABLE posts DROP COLUMN slug;

The migration generator runs sqlc immediately too, so app/models/*.sql.go changes before the migration is ever applied, and a renamed column shows up as a compile error right away.

Running them

bogie db:migrate
bogie db:rollback
bogie db:migrate:status
bogie db:reset
bogie db:seed
bogie db:version

All through the app's own binary, never the goose CLI directly, so development runs exactly what a deployed container runs. db:reset drops the database and rebuilds it from the migrations; there is no schema.rb to load instead. A seed is authored the same way a migration is, with its own make target:

make g_seed_add_demo_posts

which writes a timestamped file under db/seeds, versioned in its own table, applied by bogie db:seed.

Taking it out

bogie d model post

Removes the migration, the queries, the domain type and the store, the same way bogie g model wrote them.