Every file a new aggregate needs, in the order to write them, with notes as the worked example. Copy the notes files, rename them, and change the fields and rules. Do not invent a second shape.
You need:
- Add your first feature read once. It walks the same steps, in short.
An aggregate is one kind of thing a product keeps: a note, an invoice, a task. A note belongs to one group. Every member reads notes; an editor or above writes them.
- It lives inside a group, the only tenancy boundary. Every row carries a
group_id. - Authorization is in the handlers. The transports only parse and dispatch, so both answer alike.
- Every write reads the role again in its own transaction, with
lockActorRole(libs/server/groups/group-change-log.ts). A write that races a removal is then refused. - Every write moves the group's change sequence and writes an outbox row, in the same transaction. That row becomes a hint on every member's socket: live updates need nothing else.
- Every command carries an idempotency key, and every update and delete names the version it saw. A stale version gets a conflict with the current version.
- Screens are pure: props in, callbacks out ("Three layers of UI" in
AGENTS.md). - Something new stored about a person updates the privacy page
(
apps/mpa/routes/[[lang]]/privacy.tsx) in the same pull request.
| File | What it holds |
|---|---|
+lib.ts |
The Note shape, request schemas, errors, commands, queries, event kinds, the authorization rule and the NoteRepository port |
+lib.test.ts |
Tests of the schemas and the authorization rule |
The request schemas are arktype. Their field names are also the form's field names and the socket
payloads'. The authorization rule is two functions, assertCanReadNotes(role) and
assertCanWriteNotes(role). A person who is not a member gets GROUP_NOT_FOUND, never
"forbidden", so a stranger cannot learn that a group exists.
Its deno.json makes it a workspace member: add it to workspace in the root deno.jsonc, with
an @domain/notes alias, or its tests never run. Add it to the COPY lines of Dockerfile.base.
| File | What it holds |
|---|---|
migrations/2026_10_02_0001_notes.sql |
The notes table |
schema.sql |
The same table appended, so the snapshot matches the migrations |
The table has group_id, the fields, version, change_sequence (the group's sequence at the
row's last write), the author, the last editor and deleted_at. Compose applies migrations on every
start.
Naming a migration.
- Take the newest file name in
libs/server/db/migrations/onmain. - Go one step past it: the same date with the next number, or the next date with
0001. The date is a counter, not the calendar, so never use today's date. - Name the file
YYYY_MM_DD_NNNN_words_in_snake_case.sql. A statement that cannot run in a transaction ends in.no_transaction.sql. - Append the name as the last line of
ON_MAINintests/migration-names.test.ts.
The runner applies files in name order, so a file that sorts too early breaks an empty database.
When another pull request merges a migration first, rename yours past it. Never rename one on
main: databases know it by its name.
| File | What it holds |
|---|---|
postgres-note-repository.ts |
Reads and writes. Each write calls recordGroupChange. It checks no role |
note-list-cursor.ts |
The signed paging cursor, bound to the user and the group |
deno.json |
Workspace member: register it and add an @server/notes/ alias |
recordGroupChange (libs/server/groups/group-change-log.ts) moves the sequence and writes the
outbox row. The version check is the WHERE version = ${expected} of the UPDATE. A create retried
with the same id and content returns the first note. Expose the repository on AppDbBase
(apps/api/services/db-base.ts) as a getter built per access: a cached one escapes the transaction.
| File | What it holds |
|---|---|
features/notes/handlers.ts |
One factory per command and query: read the role, check, call the repository |
cqrs/note-dependencies.ts |
Wires the handlers to db.note and to the membership read |
cqrs/command-handlers/note-*.ts |
One file per command |
cqrs/query-handlers/ |
One file per query |
cqrs/+init.ts |
Registers every handler on its bus |
The session gate and the idempotency middleware need nothing from you: a command with an
idempotencyKey runs once per user and key.
| File | What it holds |
|---|---|
apps/api/features/notes/operations.ts |
Every operation by name: parse the payload, dispatch |
apps/api/features/notes/list.ts |
One page of the list, with its cursor. Both transports call it |
apps/api/features/notes/errors.ts |
REST error codes and statuses (API errors) |
apps/api/routes/notes.ts |
The REST routes under /api/groups/:groupId/notes |
apps/api/services/note-list-cursor.ts |
The cursor codec, keyed from the cookie secret |
The notes operations are the commands note.create, note.update, note.delete, note.restore
and note.move, and the queries note.list, note.get, note.locate and note.search.
note.locate finds a note's group by the note's id alone, and answers only a member.
The socket (/api/ws) and POST /api/call/<name> serve the one list through one dispatcher. The
call route takes the payload as the JSON body and the key in the Idempotency-Key header. So a new
aggregate writes only a list route (GET). The notes routes for single writes are older; do not
copy them. Every route under /api refuses a request whose page names another user.
Wire it up:
- Mount the route in
apps/api/index.ts, before/groups. - Spread the aggregate's operations into
operationsinapps/api/services/realtimeHub.ts. - Map its domain error in
toRequestError(apps/api/services/realtime.ts). The client then getsforbidden,not_foundorconflict, with the domain code and the current version.
The worker needs no change: a note's outbox row names its group.
| File | What it holds |
|---|---|
progressive.tsx |
NOTE_PATHS: /notes, /notes/new and /notes/:id |
notes-screen.tsx |
The list page, NotesScreen. It only reads and links |
note-editor-screen.tsx |
The editor page, NoteEditorScreen, for a new note and a saved one |
notes.test.tsx |
Drives both screens in a browser DOM: typing, submitting, the dialogs |
An aggregate has a list page and an editor page. The list's "New note" and each title are links; a viewer gets no "New note". Everything that writes is on the editor page, which shows Save and Discard only while a field differs. A viewer sees the note as text. The paths carry no group id: the SPA acts on the person's selected group.
"Delete" in the page's menu asks first, with ConfirmDialog. A record that is never edited, such as
an API token, gets a create-only page (api-token-new-screen.tsx). It shows the secret once, and
keeps it out of the address, the history and the device cache.
| File | What it holds |
|---|---|
state/notes.ts |
The store: reads with apiAsUser, writes with callCommand, shows conflicts |
state/notes.test.ts |
The store against fake calls |
views/NotesView.tsx |
Passes the store and the role to NotesScreen |
views/NoteEditorView.tsx |
Passes the store and the role to NoteEditorScreen |
views/spa-paths.ts |
The route table. Every row says whether a signed-out person may open it |
app.tsx |
What each route draws (PAGES), and the re-read on a hint |
libs/ui/frame.tsx |
The rail entry, with its label key in every i18n/app/*.ts |
modules.ts |
The only file that imports the socket module and the offline layer |
offline/notes-collection.ts |
Only for an offline-writable aggregate |
callCommand adds the idempotency key and picks the socket or the call route. The type check fails
for a route with no page. The store never moves the group's cursor on its own writes.
Choosing the offline level
Decide the level before you write the store: 1 online only, 2 offline-readable (the default for a list) or 3 offline-writable. Make an aggregate work offline has the steps.
| File | What it proves |
|---|---|
apps/api/features/notes/transports.test.ts |
Every scenario through the socket and the call route |
apps/api/features/notes/operations.test.ts |
Parsing and dispatch of the operations |
apps/api/routes/notes.test.ts, routes/call.test.ts |
Parsing and dispatch of the REST routes |
tests/integration/notes.integration.test.ts |
Postgres: one sequence step and outbox row per write, conflicts, paging |
e2e/notes.e2e.ts |
Two members: one edits, the other's tab follows without a reload |
A second member is seeded, not invited: an e2e spec calls POST /api/test/add-member
(apps/api/routes/dev.ts). Teach POST /api/test/cleanup-user to delete the new table's rows.
Give the aggregate its operator view: one folder in apps/admin/src/modules/ and one file in
apps/api/routes/admin/. Admin modules has the steps.
A new column on notes, such as a colour, touches these files, in this order. To list the files an
existing field touches, run git grep -c -w tagIds; the group time zone (#785) is a recent
example of a whole field change.
- Migration. A new file in
libs/server/db/migrations/("Naming a migration" above), the same column inlibs/server/db/schema.sql, and the file's name appended toON_MAINintests/migration-names.test.ts. - Domain. The field on
Noteand in the payload schemas (noteCreatePayloadSchema,noteUpdatePayloadSchema) inlibs/domain/notes/+lib.ts. - Repository. The reads and writes in
libs/server/notes/postgres-note-repository.ts, and the in-memory twin the API tests use,apps/api/_testing/memory-notes.ts. - Handlers. The command's fields in
apps/api/features/notes/handlers.ts. The token API (apps/api/routes/token-api.ts) only when it should show the field. - Export, import and move.
libs/server/notes/note-portable.ts,libs/domain/groups/export-import.tsand the person's data export,libs/server/jobs/data-export-sections.ts. - SPA. The store (
apps/spa/src/state/notes.ts) and the device's copy and queue (apps/spa/src/offline/notes-collection.ts,notes-cache.ts,device-copy.ts). - Screens and words.
libs/ui/note-editor-screen.tsxandlibs/ui/notes-screen.tsx; each new message ini18n/app/en.ts(i18n/TRANSLATING.md (on GitHub), "The loop"). - Admin and demo. The admin counterpart when the aggregate has one, and the demo's sample data
(
apps/worker/demo/seed.ts,fake-data.ts; the demo (on GitHub)). - Tests. The tests next to each file above,
tests/integration/notes.integration.test.tsfor the column, and the privacy page when the field is something new about a person ("The rules every aggregate follows").
| Section | Page |
|---|---|
| Choosing the offline level, the changes pull | Make an aggregate work offline |
| Relations between aggregates | Link two aggregates |
| Delete, restore and purge | Delete, move and export |
| Making an aggregate movable, "move all data" | Delete, move and export |
| Making an aggregate exportable | Delete, move and export |
| The person's data export | Delete, move and export |
| Settings, widgets, inbox, first run, demo | Connect a feature to the app |