Skip to content

0009. Commit data, events and jobs in one unit of work

  • Status: accepted; the enqueue through Graphile Worker is superseded by 0019
  • Date: 2026-10-26

The analysis makes one consistency guarantee the base of the sync engine (chapter 4): a change to the data, its domain event, the ari_change for Channex and the job that will send it are committed together or not at all. The tenancy model adds a second requirement (chapter 8): the workspace context (app.workspace_ids) is set with set_config(…, true) on the same connection as the queries of the transaction. The package boundaries alone do not guarantee either: a repository from db and a job runner from jobs, injected separately, could each take their own connection and commit independently (structure review, finding R03).

  • core declares a unit of work port in its application layer. A use case asks it to run a callback for an authorized tenant context; the callback receives transaction-bound capabilities: the repositories it needs, an event and outbox writer, and an enqueue for jobs.
  • db implements the port. It opens one transaction on one client, sets the authorized workspace context locally, and binds every capability to that client. The job enqueue calls Graphile Worker’s public SQL API (graphile_worker.add_job) on the same client, so a job exists only if the transaction commits.
  • Use cases receive the unit of work and other ports by injection; domain calculations stay pure functions called inside the callback.
  • Nothing outlives the transaction on the connection: the tenant context and the statement and idle-in-transaction timeouts are set with set_config(…, true), never as session settings or connection parameters. Runtimes may therefore connect through a transaction pooler such as the pooled URLs of Neon and Supabase, where consecutive transactions on one server connection belong to different clients.
  • No network call happens inside the callback (analysis, chapter 18): work for Channex, email or webhooks is enqueued, then executed after the commit.
  • The unit of work is the only entry point to tenant data. Lookups that must cross tenants (resolving an API key, routing a Channex webhook to its property) use separate, narrowly privileged and named capabilities, never an ordinary repository without context.
  • Failure-injection tests prove both directions: a rollback leaves no orphan job, and a committed change always has its job.
  • A test runs the unit of work through PgBouncer in transaction mode, with every client sharing one server connection, and checks that no context or timeout survives a transaction.
  • A failure during COMMIT that is not a refusal by the database leaves the outcome unknown; the unit of work reports it as such (outcomeUnknown, ADR 0008) instead of asking for a retry.
  • core never sees pg or Drizzle types: the capabilities are interfaces of the domain.
  • Repositories cannot be used outside a unit of work, which also makes it impossible to forget the tenant context.
  • Repositories that each manage their own connection, with an outbox table polled later. Simpler to wire, but the outbox insert and the data change could still be split across transactions by mistake, and the tenant context would be set many times.
  • A job queue outside PostgreSQL. Rejected in the analysis: it cannot join the transaction.