Architect
Practices
ReferenceNormative

Database

The application names every record and owns every business rule. The database keeps structure only — keys, references, presence and uniqueness — never an auto-incremented id, a check constraint or an enum.

Verified 2026-10-03 · Stack-independent. QA enforces both rules on every backend project by scanning its migrations.

Two rules, for every database, in every backend.

The application makes every id

An id is a string the application generates when it creates the record, never a number the database hands back. Its shape is a prefix naming the kind of record, an underscore, then a short random part: usr_k3f9q2m7x1b8w, evt_4h8n2c6v0z5ra — the shape Stripe made familiar (cus_…, ch_…).

  • The prefix is two to four lowercase letters, one per kind of record, so an id read in a log, a URL or a support message says what it points to.
  • The random part's length fits the data: long enough that the expected number of records never collides, short enough to read aloud. In any doubt, 13 characters or more after the prefix.
  • One generator behind the ids port makes every id (Go), so a test counts them.
  • Never AUTOINCREMENT, SERIAL, BIGSERIAL, IDENTITY or AUTO_INCREMENT.
  • The record exists, with its id, before it is saved: the core can return it, publish an event about it, or refer to it from another record in the same operation, without a round trip to learn its number.
  • A test counts the ids it was given, so an id is as predictable in a test as a stopped clock.
  • Ids carry no meaning to a reader: they do not reveal how many records exist or the order they were created in to someone guessing the next one.

The only exception is one the user sets explicitly, for one component, in the project's .soffa/project.json ("allow": ["database-ids"]) — for example a table an external system already numbers.

The database keeps structure, the application keeps rules

Constraints are for what is not a constant. The database holds:

  • the primary key;
  • foreign keys between tables;
  • NOT NULL for a value every record has;
  • UNIQUE for a value no two records share.

Never a business rule: no CHECK constraint (a range, a length, a list of allowed values such as gender IN ('MALE', 'FEMALE')), no ENUM type. These live in the core — a typed closed set with its own validation (Go: types), a value that parses once at the edge — where they are tested, named in an error the caller reads, and changed without a migration.

Why the rule is strict. A rule kept in both places drifts: the code accepts a value the database refuses, and the caller gets a 500 instead of the error the core would have named. A rule kept only in the database is invisible to the core's tests, and changing it is a migration and a lock on a live table.

Checked, not asked

QA runs a guard over every .sql file of a backend project and refuses:

  • IDENTITY, SERIAL, BIGSERIAL, SMALLSERIAL, AUTOINCREMENT, AUTO_INCREMENT — unless the component allows database-ids;
  • CHECK (…), CREATE TYPE … AS ENUM and ENUM(…) columns.

On this page