apistockdocs
Decisions

Resource module template

ADR-0039Accepted

Status: Accepted (2026-09-15) · Amends: ADR-0022, ADR-0023 · Amended by: ADR-0048 (org-scoped resources: --scope org, the default in multi-tenant apps)

Context#

aps gen resource (ADR-0021, ADR-0023, ADR-0027, ADR-0032) creates a one-shot layered module, but nothing yet shows what that module looks like. examples/full-single has auth (too large and specialised to copy), ops (no table) and ping (no repository). Its generated output can't be golden-tested until a hand-written module exists, and aps new --preset=full is generated from the golden app.

Once generated, a resource module belongs to the app and is never upgraded (ADR-0021). Every choice made in the template spreads into every app that runs the generator and can't be fixed later with aps upgrade. The template has to be decided before it is written.

Decision#

examples/full-single gets internal/modules/projects, written by hand with all four layers. It is the reference for resource/single with --scope=user. The generator's golden test regenerates it from a field list and compares the result byte for byte, the same way examples/minimal is checked in v0.1.

Scope#

--scopeOwnershipAccessTemplate
user (default in single-tenant apps)owner_id NOT NULLauth_users.idOnly the owner; no permission from the catalog is neededresource/single, this ADR
globalnonePermissions from the catalog: <module>.<resource>.read / .write, deny by defaultLater: resource/single with ownership removed, permissions added
orgorg_id NOT NULLorgs.RequireMember(permission), four isolation layersresource/org, v0.4 (ADR-0023)

auth_users.id is a stable identity column (ADR-0015), so the foreign key doesn't break the rule that modules never import each other (ADR-0022 rule 4). The key is ON DELETE CASCADE: when auth_cleanup purges an account, its projects go with it. Until then, a soft-deleted owner can't sign in, so their projects can't be reached.

Example fields#

Only fields the generator can express from [fields] (allowlisted types, ADR-0021). The example uses one of each type it will support:

FieldTypeRule
namestring, requiredTrimmed, 1–100 characters; unique per owner, case-insensitive (UNIQUE (owner_id, lower(name))) → ErrProjectNameTaken
descriptiontext0–2000 characters
statusenum(active,archived)Default active; a database CHECK and a domain type
id, owner_id, version, created_at, updated_atfixedAlways present; not in [fields]

IDs are text with a prefix from the resource name (prj_), 128 random bits, the same as auth IDs (ADR-0038).

Layers#

LayerContents
domain/Project with no tags; NewProject and Project.Apply(Changes) validate every field and return a *ValidationError listing each invalid field; Status enum; sentinel errors ErrProjectNotFound, ErrProjectNameTaken, ErrProjectVersionConflict
usecase/ports.go (Store with InTx); Create, Get, List, Update, Delete; each takes the actor from the context, returns ErrUnauthenticated without one, and passes the owner ID to every store call
repository/store.go and one file per operation (insert_project.go, select_project.go, select_projects.go, update_project.go, delete_project.go), scan.go, tests against pgtest (ADR-0032)
delivery/Huma operations with input and output types (ADR-0027); nothing but mapping
module.goNew(pool, deps), Register(api); a nil module registers operations for OpenAPI export, like auth
App wiringOne wiring file, internal/app/module_projects.go, that builds the module from the shared services (database pool, audit recorder, logger) and maps its errors, and one registerProjects(api, mapper, svc), line after //aps:anchor modules inside errors.Join in internal/app/modules.go (ADR-0018, ADR-0021). Pagination errors (invalid_cursor, invalid_sort, invalid_limit) are mapped once in routes.go for every module

Owner isolation#

Isolation for --scope=user mirrors the org isolation layers (ADR-0023):

  1. Code: every repository method takes ownerID; no query reads or writes a row without owner_id = $n.
  2. HTTP: someone else's project returns 404 project_not_found, never 403, so IDs can't be probed.
  3. Database: uniqueness and indexes lead with owner_id.
  4. Tests: generated cross-owner tests check that a second user gets 404 on get, update and delete and never sees the project in the list.

Endpoints#

EndpointResult
POST /v1/projects201 with the project
GET /v1/projects200 page.Result; limit, cursor, sort (created_at, updated_at, name; default -created_at), status filter
GET /v1/projects/{id}200
PATCH /v1/projects/{id}200; body carries the version it read
DELETE /v1/projects/{id}204

Error codes: unauthenticated (401), validation_failed (422 with errors[] of {location, message}, as httpx.FieldError), project_not_found (404), project_name_taken (409), project_version_conflict (409), invalid_cursor, invalid_sort (400).

TopicDecision
PaginationKeyset through the core page package; the cursor encodes the sort key and ID, which is the tiebreaker; one fixed query per allowlisted sort, never ORDER BY built from input (ADR-0032)
UpdatesPATCH with optional fields and a required version; UPDATE … WHERE id AND owner_id AND version increments the version; zero rows after a successful get → ErrProjectVersionConflict
DeletesHard delete. The audit event records who deleted what and when; soft delete stays an app choice. RowsAffected() == 0 → not found
TransactionsUpdate runs select and update in InTx; the other operations are one statement
Auditprojects.project.created, .updated (metadata: changed field names, never values), .deleted, recorded through audit.Recorder after the change, with failures logged. This is the same pattern as auth (ADR-0038)
Rate limitsNone in the template; the app's global limits apply

Fixed versus filled from fields#

Filled from aps gen resource <Name> [fields]Fixed in the template
Names (module, table, ID prefix, routes, errors, audit actions), columns, domain fields and validation, request/response fields, allowlisted sort fields (string and time fields), filters (enum fields), test valuesLayer layout, ownership and isolation, pagination, versioned updates, hard delete, audit pattern, error mapping, test structure

A migration is created with the module (db/migrations/<timestamp>_<resources>.sql), not a separate aps gen migration.

Generator (implemented 2026-09-15)#

text
aps gen resource <Name> <field:type>... [--plural P] [--id-prefix p] [--scope user]
                 [--dry-run] [--json] [--allow-dirty] [--yes] [--no-input] [--plain]
FieldRule
name:string1–100 characters, required, sortable; name:string:unique is unique per owner, ignoring case
notes:textUp to 2000 characters, optional
status:enum(a,b)2–20 snake_case values, the first by default; lists filter by it
TopicDecision
NamesField names are snake_case, up to 20 characters; names every resource has, query parameters and PostgreSQL reserved words are refused; generated Go names are checked for clashes. At least one string field; the first is the title the tests sort by. Up to 20 fields
Derived namesProject → package, table and route projects, ID prefix prj (first letter and next consonants), audit actions projects.project.*; --plural for irregular plurals, --id-prefix to choose the prefix
Migration versionThe current UTC time, or one after the newest migration, so it always runs last
OutputThe 20 files above and one line in modules.go; one-shot, not recorded in apistock.lock (ADR-0021)
ScopeOnly user until organisations (v0.4) and the global template exist
Golden testaps gen resource Project name:string:unique description:text 'status:enum(active,archived)' reproduces examples/full-single's projects module byte for byte; -update regenerates it from the templates for review
Generality testA resource with several unique and enum fields and one with neither are generated into a copy of examples/full-single, which is vetted and runs their tests on PostgreSQL

The example's tests use generic sample values (Example name, Website, Docs) so that the same test code works for any resource.

Not in the template#

Soft delete, search, bulk operations, nested resources, sharing between users, file fields, relations between generated resources (for now, add them by hand).

Why#

  • Developers own and control their business code. Every layer, SQL statement and rule of a resource is in their app, where they can read and change it (for example, what happens when a project is created or how users are inserted), with nothing hidden in the library, just as auth is app-owned (ADR-0038).
  • A real, owned example is easier to read, review and test than a template file. A golden test keeps the two in sync.
  • User scope gives single-tenant apps real isolation now and practises the layers org scope requires in v0.4.
  • Versioned updates and keyset pagination are hard to add after clients depend on an API; putting them in the template makes them the default.
  • A 404 for others' resources avoids leaking which IDs exist.

Trade-offs#

  • Every generated resource needs a signed-in user; public read-only resources need hand edits or --scope=global.
  • Requiring version on PATCH is stricter than many clients expect.
  • Audit after commit can miss an event if the audit write fails; a failure is logged, the same as auth.
  • Hard delete loses data that soft delete would keep; the audit event records only metadata.
  • Moving to organisations (aps add orgs) needs a data migration for user-scoped tables; the skeleton comes from ADR-0023.

Consequences#

  • examples/full-single gains internal/modules/projects, its migration, cross-owner tests and an end-to-end test.
  • aps gen resource is golden-tested against it; ADR-0022's example aliases (projectdomain, projectusecase) become real.
  • Error codes and audit actions above are public API for the example app (ADR-0015); generated apps get their own names.
  • Architecture open item "Example business module with its own repository" is resolved when the module lands.
esc
↑↓ move↵ openesc close