Each domain: internal DDD layout + a thin outward facade

Updated · View the entry on sijie.xyz ↗

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 is package <domain> so call-sites read security.Verifier, not facade.Verifier (which would collide / need aliases across domains). One targeted revive exemption (package-directory-mismatch for internal/*/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 usecases bag).
  • 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; infra root 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.

Related notes