Resource module template
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#
--scope | Ownership | Access | Template |
|---|---|---|---|
user (default in single-tenant apps) | owner_id NOT NULL → auth_users.id | Only the owner; no permission from the catalog is needed | resource/single, this ADR |
global | none | Permissions from the catalog: <module>.<resource>.read / .write, deny by default | Later: resource/single with ownership removed, permissions added |
org | org_id NOT NULL | orgs.RequireMember(permission), four isolation layers | resource/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:
| Field | Type | Rule |
|---|---|---|
name | string, required | Trimmed, 1–100 characters; unique per owner, case-insensitive (UNIQUE (owner_id, lower(name))) → ErrProjectNameTaken |
description | text | 0–2000 characters |
status | enum(active,archived) | Default active; a database CHECK and a domain type |
id, owner_id, version, created_at, updated_at | fixed | Always 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#
| Layer | Contents |
|---|---|
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.go | New(pool, deps), Register(api); a nil module registers operations for OpenAPI export, like auth |
| App wiring | One 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):
- Code: every repository method takes
ownerID; no query reads or writes a row withoutowner_id = $n. - HTTP: someone else's project returns 404
project_not_found, never 403, so IDs can't be probed. - Database: uniqueness and indexes lead with
owner_id. - 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#
| Endpoint | Result |
|---|---|
POST /v1/projects | 201 with the project |
GET /v1/projects | 200 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).
| Topic | Decision |
|---|---|
| Pagination | Keyset 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) |
| Updates | PATCH 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 |
| Deletes | Hard delete. The audit event records who deleted what and when; soft delete stays an app choice. RowsAffected() == 0 → not found |
| Transactions | Update runs select and update in InTx; the other operations are one statement |
| Audit | projects.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 limits | None 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 values | Layer 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)#
aps gen resource <Name> <field:type>... [--plural P] [--id-prefix p] [--scope user]
[--dry-run] [--json] [--allow-dirty] [--yes] [--no-input] [--plain]| Field | Rule |
|---|---|
name:string | 1–100 characters, required, sortable; name:string:unique is unique per owner, ignoring case |
notes:text | Up to 2000 characters, optional |
status:enum(a,b) | 2–20 snake_case values, the first by default; lists filter by it |
| Topic | Decision |
|---|---|
| Names | Field 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 names | Project → 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 version | The current UTC time, or one after the newest migration, so it always runs last |
| Output | The 20 files above and one line in modules.go; one-shot, not recorded in apistock.lock (ADR-0021) |
| Scope | Only user until organisations (v0.4) and the global template exist |
| Golden test | aps 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 test | A 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
authis 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
versiononPATCHis 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-singlegainsinternal/modules/projects, its migration, cross-owner tests and an end-to-end test.aps gen resourceis 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.