Docs / Decisions
ADR 0002: One module, one binary, Postgres only, a fixed layout
- Status: accepted; built
- Date: 2026-09-28
Context
Araldo must be easy to self-host ("one binary and a Postgres") and easy for
contributors to find their way around. The author's other Go services
(open-b00ks among them) settled on conventions worth keeping: an
internal/ tree, hand-written SQL, golang-migrate, model and storage kept
apart, and a Taskfile for every command.
Decision
-
One Go module (
github.com/spectrum-labs-tech/araldo) and one binary,araldo, with subcommands:- operators:
server,worker,migrate,backup,keys,users; - developers:
posts,templates,listen,login(talking to an Araldo API with a key).
A deployment runs the same image as
araldo serverandaraldo worker.cmd/araldo/main.goonly callsinternal/cli. - operators:
-
PostgreSQL only. We use
pgx/v5directly with hand-written SQL, no ORM or query builder. Being Postgres-only lets us rely onFOR UPDATE SKIP LOCKED,LISTEN/NOTIFY,jsonb, and composite foreign keys (ADR 0004). -
Migrations use golang-migrate, embedded in the binary (
araldo migrate up). A shipped migration is never edited. Each index on an existing table is its own migration builtCONCURRENTLY, so upgrades never block writes for long. -
Layout:
cmd/araldo/ main: wires the CLI and nothing else internal/ app/ composition root shared by subcommands cli/ subcommands model/ domain types; no storage tags, no SQL store/ all SQL (pgx), sentinel errors, migrations core/ every use case: sign-in, tenancy, keys, channels, templates, posts, publishing, events, webhooks platform/ adapter interface, rules, registry sandbox/ bluesky/ mastodon/ discord/ telegram/ ... tmpl/ template compiling and rendering keyring/ authn/ envelope encryption, sign-in primitives opsched/ periodic background tasks under leases api/ HTTP API handlers web/ dashboard (ADR 0015) apperr/ config/ id/ buildinfo/ api/openapi.yaml the API contract deploy/ compose.yaml, helm/araldo docs/ architecture, ADRs, roadmap, operations -
Dependency rules:
modelimports nothing internal exceptplatform(for provider names);storeis the only package with SQL. It is concrete (Postgres only), with no interface layer in front of it;coretests run against a real database;- handlers (
api,web,cli) never call the store; they callcore, which applies tenancy (ADR 0004).
-
Background work is an
opschedtask (ininternal/, to move togo-toolkitonce its API settles), or one of the two lease-based queues incore: publishing targets and webhook deliveries. -
Code rules:
- every function that does I/O takes
ctx context.Contextfirst; - logging is
log/slogonly; - every Go file starts with
// SPDX-License-Identifier: AGPL-3.0-or-later; - standard library first, and any new module is justified in its pull request.
- every function that does I/O takes
Alternatives considered
- Separate server and worker binaries. Clearer process boundaries, but more to download and package. One binary with subcommands, run as two Deployments, gives the same isolation at runtime.
- Supporting SQLite as well, for a zero-dependency demo mode. Database portability has a real cost; Araldo's queue relies on Postgres features. Revisit if "try it in 30 seconds" becomes a goal.
- sqlx or sqlc. sqlx adds little on top of pgx; sqlc's generated code would sit awkwardly with the separation between model and store.
Consequences
- Self-hosting is one image plus one Postgres (and S3-compatible storage once media is supported).
- Contributors need only Go and Task for the unit suite; integration tests need Docker for Postgres.
- Postgres-specific SQL is allowed everywhere in
pgstore.