Skip to content
latest
Data and Services / latest

Database

Andurel uses PostgreSQL through pkg/storage. One storage.Connection is created at boot and handed to the domain layer; handlers, services, and workers receive domain receivers, never a pool. There is no ORM and no Bun. SQL is explicit, either as a goose migration or as an annotated query beside its domain.

Connect through storage

config.NewDatabase validates the DB_* environment variables into a storage.Config. internal/runtime creates the pool once and registers its cleanup:

1db, err := storage.NewPostgres(ctx, cfg.Database, storage.WithTelemetry(tel))
2if err != nil {
3    return nil, err
4}
5app.DB = db
6app.closers.Add(func(context.Context) error { return db.Close() })

Only domains/ and the wiring layers (internal/runtime, cmd/, seeds/, migrations/) may name storage.Connection or storage.Transaction. Everything else takes a receiver such as domains.Users. andurel verify enforces the boundary with the db-handle rule.

Entities live in domains

domains/ is one flat package, one file per entity. The entity maps 1:1 to its table: every column it scans is an exported field with an andurel tag. The entity also owns validation.

 1type User struct {
 2    ID               uuid.UUID          `andurel:"id"`
 3    CreatedAt        pgtype.Timestamptz `andurel:"created_at"`
 4    UpdatedAt        pgtype.Timestamptz `andurel:"updated_at"`
 5    Email            string             `andurel:"email"`
 6    EmailValidatedAt pgtype.Timestamptz `andurel:"email_validated_at"`
 7    Password         []byte             `andurel:"password"`
 8    IsAdmin          bool               `andurel:"is_admin"`
 9    SessionVersion   int32              `andurel:"session_version"`
10}
11
12func (u User) Validate() error {
13    b := validation.NewBuilder()
14    b.Required("email", u.Email)
15    b.MaxLen("email", u.Email, 255)
16    b.Email("email", u.Email, "Email must be a valid email address")
17
18    return b.Err()
19}

A receiver exposes New<Plural>(db storage.Connection) and InTx(tx storage.Tx), and is constructed in internal/runtime:

1users := domains.NewUsers(db)
2tokens := domains.NewTokens(db)

Create and Update normalise, validate, and write. Finders and writes call the generated query client and map expected failures to the shared sentinels:

 1func (u Users) Find(ctx context.Context, id uuid.UUID) (User, error) {
 2    return storage.Row(u.queries.GetUser[User](ctx, id))
 3}
 4
 5func (u Users) Create(ctx context.Context, pepper string, data CreateUserData) (User, error) {
 6    // ...build, normalise and validate the entity...
 7    created, err := u.queries.CreateUser[User](ctx, queries.CreateUserParams{
 8        ID:               entity.ID,
 9        CreatedAt:        entity.CreatedAt,
10        UpdatedAt:        entity.UpdatedAt,
11        Email:            entity.Email,
12        EmailValidatedAt: entity.EmailValidatedAt,
13        Password:         entity.Password,
14        IsAdmin:          entity.IsAdmin,
15        SessionVersion:   entity.SessionVersion,
16    })
17
18    return storage.Row(created, storage.Conflict(err, "users_email_key"))
19}

storage.Row turns pgx.ErrNoRows into domains.ErrNotFound; storage.Conflict turns a unique violation of the named constraint into domains.ErrConflict.

domains.ErrNotFound and domains.ErrConflict are aliases of the storage sentinels, so handlers can map them once through MapError.

Schema changes are migrations

The schema is SQL under migrations/, not Go tags. Create a goose file, write both directions, then apply it:

1andurel generate migration create_products_table
2andurel db migrate up
 1-- +goose Up
 2create table products (
 3    id uuid primary key default gen_random_uuid(),
 4    name text not null unique,
 5    price_cents bigint not null default 0,
 6    created_at timestamptz not null default now(),
 7    updated_at timestamptz not null default now()
 8);
 9
10create index products_created_at_idx on products (created_at desc);
11
12-- +goose Down
13drop table if exists products;

The migrations package embeds every *.sql file, so cmd/migrate and the test suites apply exactly the SQL committed to the repository. The development CLI wraps bin/goose against the same files. See Database Commands for the full command set.

Transactions

A receiver holds a storage.Transactor when one of its methods writes more than once, or reads, modifies and writes a row. A service takes the transactor as a constructor argument and wraps multi-domain writes in storage.AtomicOps:

 1var user domains.User
 2
 3err := storage.AtomicOps(ctx, i.transactor, func(ctx context.Context, tx storage.Tx) error {
 4    users, queue := i.users.InTx(tx), i.queue.InTx(tx)
 5
 6    created, err := users.Create(ctx, i.pepper, domains.CreateUserData{
 7        Email: data.Email,
 8        PasswordPair: domains.PasswordPair{
 9            Password:        data.Password,
10            ConfirmPassword: data.ConfirmPassword,
11        },
12    })
13    if err != nil {
14        return fmt.Errorf("create user: %w", err)
15    }
16
17    if _, err := queue.Insert(ctx, jobs.SendVerificationEmailArgs{UserID: created.ID}, nil); err != nil {
18        return fmt.Errorf("queue verification email: %w", err)
19    }
20
21    user = created
22
23    return nil
24})

Returning nil commits; returning an error rolls back; storage.Tx has no Commit or Rollback method. Bind every dependency at the top of the callback and use only the bound locals; andurel verify reports tx-escape otherwise.

A read-modify-write locks the row with FindForUpdate inside the same transaction before saving:

1locked, err := users.FindForUpdate(ctx, id)
2if err != nil {
3    return err
4}
5locked.Password = []byte(hashedPassword)
6entity, err = users.Save(ctx, locked)

Seeds

Named seed sets live in seeds/ and use the exported domain API, not raw SQL and not factories:

1var Registry = map[string]Runner{
2    "development": Development,
3    "test":        Test,
4}
1andurel db seed --list
2andurel db seed development
3andurel db rebuild --seed test

andurel db seed runs the cmd/seeds entrypoint, which loads config, opens PostgreSQL, and calls seeds.Run. Keep seeds idempotent: the scaffold's development seed keeps an existing user instead of creating a duplicate.

Inspect the layer

1andurel inspect domains --json
2andurel patterns show domain
3andurel verify

inspect domains reports each entity's table, fields, validation coverage, columns_ok, and whether its receiver is wired in internal/runtime. Use it before writing a migration, service, or handler.