Code structure: the transaction is passed explicitly

Updated · View the entry on sijie.xyz ↗

Status: released in v0.1.76 (2026-09-27) — design and as-built record in docs/design/event-bus-outbox-webhooks.md in the StandMeet repo.

Most of the bus is "infra behind interfaces" (queue-behind-ports). Three things needed a decision: how a transaction reaches the use case, how subscribers register with zero glue, and where the side-effect ports moved.

flowchart TB
  subgraph infra["internal/infra"]
    pg["pgstore<br/>InTx(ctx, Beginner, fn) · Nested · DBTX<br/>Listener · Notify"]
    ev["events<br/>Recorder · declarations · relay loop<br/>retention · reads · webhook signing"]
    jb["jobs<br/>Jobs · Inspector · Runtime · Kind · Periodic"]
    rv["jobs/river<br/>only importer of riverqueue"]
    se["sideeffect/mail · sideeffect/supplier"]
    dt["detach<br/>detach.Go"]
  end
  subgraph dom["internal/&lt;domain&gt;"]
    uc["usecase<br/>InTx → repo.With(tx) · Record · Enqueue"]
    repo["repo<br/>With(tx) · Nested for its own tx"]
    sub["subscriber<br/>subscriptions + job handlers"]
    fac["facade<br/>EventTypes · Subscriptions · JobKinds (data)"]
  end
  root["cmd/server/wire/periodic.go: one line per source, collects every declaration, hands EmbedAdmits to the webhook fan-out"]
  uc --> pg
  uc --> ev
  uc --> jb
  repo --> pg
  sub --> se
  sub --> repo
  sub --> uc
  ev --> pg
  jb --> rv
  ev --> jb
  fac --> sub
  root -.-> fac

How a transaction reaches the use case

Transactions used to be opened inside repos, and most writes were single autocommit statements, so use cases held no transaction and an explicit Record could not join the business write. Trigger-captured row events (two-sources-of-events) are unaffected; semantic events are.

The transaction is an explicit parameter, never a ctx value. ctx already carries 14 WithValue keys. Putting the transaction there too would add a hidden dependency — whether a repo call joins a transaction would depend on what the caller's ctx happens to hold — and that is how ctx becomes a god object.

sequenceDiagram
  participant UC as usecase
  participant P as pgstore
  participant R as repo
  participant E as Recorder
  participant J as Jobs
  UC->>P: InTx(ctx, pool, fn)
  P->>UC: fn(tx), the transaction handed over as a parameter
  UC->>R: requests.With(tx).Create(ctx, …)
  UC->>E: events.With(tx).Record(ctx, ownerID, type, subject, data)
  UC->>J: jobs.With(tx).Enqueue(ctx, kind, args, opts)
  UC-->>P: fn returns nil
  P->>P: COMMIT (business rows + event + job land together)
  Note over UC,J: Who is in the transaction is visible at the call site, a call without With behaves as before
  • pgstore.InTx(ctx, db, fn) begins a transaction on db, a Beginner: the pool, or an open transaction (then it is a savepoint). It commits when fn returns nil and rolls back otherwise; a panic in fn rolls back and re-panics.
  • pgstore.Nested(q, pool) is where a repo bound by With(q) opens its own transaction: a savepoint inside q when q is a transaction, else a transaction of its own on the pool.
  • With(tx) on every repo, Recorder and Jobs returns a copy bound to that transaction. Existing method signatures do not change. The compiler checks who is in the transaction.
  • ctx carries cancellation and deadlines only. This plan adds no ctx key. Record takes the owner and other inputs as explicit arguments, never from ctx.
  • Gate check-tx-only-via-pgstore.sh: .Begin(, BeginTx( and BeginFunc( may appear only in internal/infra/pgstore. The 11 existing Begin sites were converted (the plan had estimated about 20) (no-bypass-by-structure).
  • UTs: an error or panic in fn rolls back business rows, event and job together; a With(tx) copy does not alter the original; calls without With behave as before (events-test-plan).

Subscribers with zero glue

Each domain declares its event types (event-model), subscriptions and job kinds as data, the same shape as dispatcher ops, and exposes them through its facade. The composition root (cmd/server/wire/periodic.go) collects them with one line per source and says nothing about what a job does. The only dedicated wiring is the embed-scope function (EmbedAdmits, from the access domain) handed to the owner domain's webhook fan-out.

Side-effect ports moved

OutboundSender lived in cmd/server/port and the background supplier call in plugin/adapters. The mail port is now internal/infra/sideeffect/mail; the background supplier call is the durable supplier.invoke job in internal/infra/sideeffect/supplier. Their error tables, lint exemptions and test hooks moved with them. Only subscriber packages, infra and cmd/server may import them.