Persistence
How to put a real database behind an Agentix application. The canonical,
runnable reference is examples/pg-notes — a notes
service over PostgreSQL (pg) with unit, behavior, and gated integration
tests. This page names the patterns it uses.
The adapter pattern
Persistence enters the domain only as a port. The domain declares what it may do; one adapter per port implements it; the framework validates every effect input and output crossing that line (Core Concepts).
Two ways to declare a store port:
port.store(id, schema)when generic CRUD is enough: you getget/save/delete/list, a free.memory()adapter, and auto-faking increateTestApplication.- A hand-declared
port(id, { ... })when the SQL carries semantics of its own.pg-notesdoes this:saveis anINSERT ... ON CONFLICT (id) DO UPDATEupsert,archiveis a transactional two-table move, the list operations promise a stableORDER BY. Keep theget/save/delete/listshape so a memory fake stays a page of code — but expect to bind that fake yourself (overrides/ a memory adapter); only trueport.storeports are auto-faked.
Write the adapter against the minimal query surface it actually uses, not
against the driver’s full API (SqlPool/SqlClient in
postgres.ts). Production
binds pg.Pool to that seam; unit tests bind a scripted fake. Every statement
is parameterized — values travel in $1, $2, ... vectors, never in the SQL
text.
Lifecycle: pool create / end
Adapters take { init, dispose } hooks (Port.adapter(impl, hooks)):
NoteStore.adapter(handlers, {
init: async () => {
pool = createPostgresPool(databaseUrl); // constructing a Pool opens nothing
await pool.query(SCHEMA_SQL); // bootstrap; real apps: migrations
},
dispose: async () => { await pool?.end(); },
});
app.start() runs init hooks in registration order — connect before traffic,
fail fast when the database is unreachable. app.close() runs dispose hooks
in reverse order. Neither is automatic: the host calls both. With
serveNode(handler, { gracefulTimeoutMs, closeApplication: true }), close()
first drains in-flight HTTP requests, then closes the app — dispose never runs
while a request is mid-dispatch (HTTP). A SIGTERM handler calling
server.close() completes the story
(server.ts).
Schema bootstrap in init is an example-sized convenience. A production
service should own DDL with a migration tool (node-pg-migrate, Flyway,
Atlas, …) run as a deploy step, keeping init to pool creation and maybe a
version assertion.
Transactions: the unit-of-work recipe
The framework has no transaction abstraction — deliberately. A dispatch is not a transaction; effects are plain async calls. When several statements must be atomic, make them one effect whose adapter owns the transaction:
// Domain: one call, no client, no BEGIN.
const archived = await effects.archive({ id, archivedAt });
// Adapter: the scope lives here.
archive: (input) => withTransaction(pool, async (tx) => {
const removed = await tx.query("DELETE FROM notes WHERE id = $1 RETURNING ...", [input.id]);
if (removed.rows[0] === undefined) return undefined;
await tx.query("INSERT INTO notes_archive ... VALUES ($1, ...)", [...]);
return ...;
}),
withTransaction checks out one client, runs BEGIN → work → COMMIT,
rolls back and rethrows on failure, and always releases the client. An
adapter throw surfaces as an EFFECT_FAILURE fault (HTTP 500) — after the
rollback, so state is consistent even when the response is an error.
Honest limits of the pattern:
- Atomicity spans one effect call. A command calling two write effects is
two transactions; if that’s wrong, redesign it as one effect (as
notes.archivedoes) or accept a compensation step (asexamples/framework-app’scustomers.createdoes withreleaseClaim). - Domain code stays plain because it never sees the transaction. The cost is that transactional composition is an adapter-level decision, not something callers can request per dispatch.
- Cross-port transactions require the ports to share an adapter-level unit-of-work — at that point, merge the ports.
Events and the outbox (sketch)
Operations emit validated events, but the framework neither persists nor
publishes them — a completed dispatch returns them, and in-process
subscribers are read-model conveniences, not durable delivery. To publish
reliably alongside a database write, use a transactional outbox at the
adapter level:
- Give the atomic effect the event data it should make durable
(
archive: { id, archivedAt }already carries it), and INSERT a row into anoutboxtable inside the samewithTransactionas the state change. - A relay (poller or logical decoding) reads
outbox, publishes to the broker, and marks rows done. At-least-once; consumers deduplicate on the event id. - The dispatch’s returned
eventsremain the in-process truth for tests and subscribers; the outbox row is the cross-process truth.
The framework’s stance stays: durable delivery belongs to the caller — the outbox is adapter SQL plus a relay process, no framework hook required.
Testing strategy
Three layers, only the last needing a server
(examples/pg-notes tests):
| Layer | Binds | Asserts |
|---|---|---|
| Adapter unit tests | Fake pg Pool/PoolClient (records query(text, values)) |
Exact parameterized SQL + values; BEGIN/COMMIT/ROLLBACK ordering on success and failure; client release; init/dispose behavior |
| Behavior tests | createTestApplication with the port overridden by memory fakes |
Domain outcomes, declared errors, permissions, emitted events — through the real dispatcher |
| Integration tests | Real PostgreSQL, gated by an env var (describe.skipIf) |
Schema bootstrap, ISO/timestamptz round-trips, real rollback (e.g. occupy a PK so the transaction’s INSERT fails, then observe the DELETE undone) |
Guidelines:
- The fake implements the seam interface (
SqlPool), not all ofpg; scripted responses keyed on the statement text keep tests declarative. - Gate integration on the env var and skip cleanly when absent, so
npm testnever needs infrastructure:describe.skipIf(process.env["PG_NOTES_DATABASE_URL"] === undefined). defineAdapterContract/runAdapterContract(see Testing) can run one behavioral contract against both the memory fake and the SQL adapter, keeping them semantically aligned.
Checklist for a new database adapter
- Declare the port with realistic operation semantics (kinds gate query
purity; outputs model expected alternatives —
undefined, unions). - Implement handlers over a minimal query seam; parameterize everything.
- Wrap multi-statement invariants in
withTransactioninside one effect. - Put pool creation +
end()ininit/dispose; let the host drivestart()/close()(serveNode:closeApplication: true). - Unit-test SQL against a fake pool; behavior-test with memory fakes; integration-test behind an env gate.
- Migrations via a real tool; observer for structured logs/OTel (Core Concepts § Observability).