2026-09-23·by Sijie Wang#standmeet#architecture#design

domain-facade-and-ddd-layout

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 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 corpusownerconversation (+ marketplace, stats) splits that were "next" are the ones listed done above; connector returns after its drain.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

domain-facade-and-ddd-layout