apistockdocs
v0.4 GitHub apistock.dev
Decisions/Records

Resource module template

ADR-0039Accepted (2026-09-15)

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 NULLauth_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):

  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#

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)#

output
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 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