Each domain: internal DDD layout + a thin outward facade
Parent: backend-domain-modules
Problem (2026-07-27, owner)
The domains under internal/<domain>/ have no outward-facing exposure layer. To use a domain
you must read the whole thing to find its methods — there is no single place that says "this is what
this domain offers." Internally the files are also not sorted by DDD role (entity / usecase / service
/ repo / infra are mixed).
Decision
Every internal/<domain>/ gets both:
1. A thin outward facade — the one place that shows the protocol at a glance
Concrete shape (decided 2026-07-27): the facade is its own subpackage internal/<domain>/facade/.
- The
facade/package is the domain's only legal import target for outside code. It is a thin re-export + codedoc shell: value types, constructors, and ports lifted up from the guts, nothing else. - Package name = the domain, not the directory. The dir is always
facade/(so "this is the door" reads at a glance), but the package clause ispackage <domain>so call-sites readsecurity.Verifier, notfacade.Verifier(which would collide / need aliases across domains). One targeted revive exemption (package-directory-mismatchforinternal/*/facade/) covers this. - Thin — nothing but the protocol. No business logic; it delegates to the guts' usecase/service.
- Codedoc is mandatory and load-bearing: the package doc lists the whole protocol; each symbol has
a doc comment stating its contract. Reading only
facade/tells you everything the domain exposes. - One glance at
facade/= the domain's whole API. That is the acceptance test.
Worked example — security (pathfinder, done):
internal/security/
├─ facade/ package security — facade.go (IP-ban) + facade_captcha.go (captcha) + facade_ops.go + doc
├─ ops/ package ops — the domain's fp.Op declarations (ip_bans.go), aggregated by the dispatcher
├─ ban/ package ban — guts: BannedIP entity + repo
├─ captcha/ package captcha — guts: Verifier + Turnstile impl
└─ db/ package db — guts: sqlc DAO (domain owns its repository)
Outside code imports .../internal/security/facade and only that. (ops/ arrived with the dispatcher convergence, 53733b661, 2026-08-01 — see convergence-inbound-and-outbound.)
2. Internal DDD layout — guts as sibling subpackages, sorted by role
The guts are sibling subpackages of facade/ (NOT nested under Go's internal/; the boundary is
enforced by lint, see below). Sort by the standard DDD roles:
- entity — the domain value objects / aggregates (the nouns).
- usecase — application-flow orchestration (the domain's own use cases stay here, not in a
shared
usecasesbag). - service — domain services (logic that isn't one entity's method).
- repository / db — persistence for this domain; the domain owns its own sqlc DAO (
db/) per backend-domain-modules;infraroot keeps only domain-less base.
A small domain collapses roles (security has no usecase/service layer — just entity+repo per capability). Only split a role out when it earns a file.
Standard DDD — nothing exotic. The point is: a reader lands in the right file by role, and the facade is the door.
Why
- Kills "read the whole domain every time" — the facade + codedoc is the contract.
- Makes the module boundary real: outside code depends on the facade, not on scattered internals, so refactoring internals doesn't ripple.
- Sets up facade-parity / owner-facade-from-registry: a clean per-domain facade is what the generated outward facades verify against.
Enforcement (lint)
check-domain-facade-boundary.sh (in make lint): a domain opts in the moment it grows a
facade/ subdir; from then on any package outside that domain importing a non-facade subpackage
of it is a red build. The enforced set grows automatically as each domain is converted — no name-list.
Sits alongside mechanical-guardrails: check-domain-acyclic (no domain cycles),
check-domain-layering (e17e8388f, 2026-07-27 — DDD order inside each faceted domain), check-boundary-thin
(53733b661, 2026-08-01 — "thin" made mechanical: a facade may hold only aliases and re-exports, never a
func body or a self-defined type), check-infra-not-domain, check-routes-not-imported.
Acceptance
For each domain: (a) a thin, codedoc'd facade/ package (package <domain>) whose surface = the
domain's whole public API; (b) guts sorted into sibling entity/usecase/service/repo/db subpackages;
(c) check-domain-facade-boundary green — callers reach the domain only through .../facade. Verify:
reading just facade/ tells you what the domain does, no guts read.
Rollout order (per ordering-depended-upon-first)
By in-degree (most-depended-upon first): access (in=5) → connector (in=3) → capabilities /
marketplace → corpus → owner → conversation. security + stats are clean leaves.
Progress: all seven core domains grew their facade/ on 2026-07-27 — security (2b6aa40ea, pathfinder),
access (b74bdfb21, first real-logic domain), corpus (f13c3f435), owner (9d4e2dbd6), conversation
(19f53f4e5), marketplace (7e0e95485), stats (9e84715b6) — and the boundary lint enforces every one of
them.
connector is still deferred (as of 2026-09-07 there is no internal/connector/facade/; the domain is
flat, with contract/, consumer/, openapi/, db/ as its only subpackages). It still holds #135
externalization-drain leftovers (obsidian.go, the mailer_*.go cluster, the protocol_{smtp,caldav}.go
and openapi_adapter.go protocol adapters). Decision (owner, 2026-07-27): run the drain FIRST, then
facade-split what remains — reshuffling code that is about to leave the kernel is double-work. The
corpus → owner → conversation (+ marketplace, stats) splits that were "next" are the ones
listed done above; connector returns after its drain.