DDD · Architecture

When the Docs and the Code
Agree to Be Wrong

A user pointed at three places where the implementations violated the repository's own guide — a Query injected with a write Repository, a domain class carrying JPA annotations, a notification module sitting in the wrong layer — and asked why none of the dozens of audit rounds before this one had caught them. The honest answer was different for each one, and only one of them was actually a bug.

The three complaints arrived in one sentence, right after a round of Card-domain and healthcheck work had just wrapped up: the Java, Go, Kotlin, and FastAPI implementations were violating the repository's own guide in ways that felt too basic to still be sitting there. A Query Handler was reading through a Repository that could also write. A domain class was carrying ORM annotations. A notification module lived somewhere it apparently shouldn't. The follow-up question mattered more than the complaint itself: how many previous audit rounds had walked past all three, and why?

The One That Was Real

FastAPI's GetTransactionsHandler depended on AccountRepository — the same interface CreateAccountService used to call save_account(). Nothing in the type signature stopped a query from mutating state, and nothing forced a reviewer to notice either, because FastAPI's own cqrs-pattern.md documented that exact shape as the correct example. The doc and the code weren't out of sync. They agreed, and they were both wrong.

The fix split the interface — a read-only AccountQuery that every write-capable AccountRepository extends, so a Query Handler physically cannot reach save_account():

class AccountQuery(ABC):
    """A read-only interface — for the Query Handler only. Never exposes a write method
    such as save() (see cqrs-pattern.md). Shares its method signatures with
    AccountRepository (the write model) but is a separate contract — a Query Handler
    must always depend only on this type.
    """

    @abstractmethod
    async def find_accounts(self, page: int, take: int, ...) -> tuple[list[Account], int]: ...


class AccountRepository(AccountQuery, ABC):
    @abstractmethod
    async def save_account(self, account: Account) -> None: ...

Java-springboot turned out to be a partial version of the same bug: GetAccountService had already been split correctly, but GetTransactionsService hadn't — a known gap, already written down in the project's own CLAUDE.md, just never finished. Kotlin and Go had already separated the two interfaces correctly; their only issue was a name — XxxQueryRepository instead of the convention's XxxQuery — cosmetic, but the kind of drift that makes root and per-language docs quietly stop meaning the same thing.

The One That Wasn't a Miss

Kotlin's domain classes carried @Entity, @Column, and the rest of JPA directly. That looked like the same category of violation as the FastAPI bug — until it turned out Kotlin's own directory-structure.md documented it as a deliberate, sanctioned exception, and the harness's domain-purity rule had been written to skip JPA annotations specifically so it wouldn't fail on code the docs already approved of. The audit hadn't missed anything here. It had worked exactly as designed.

The harder question

Java-springboot faced the identical tradeoff and decided the opposite way — full separation, an AccountJpaEntity/AccountMapper pair doing the translation. Two implementations of the same repository, two opposite calls, both locally consistent with their own docs. Keeping Kotlin's exception meant every future language got to make this decision for itself again. The alternative was harder and less negotiable: no framework gets an exception in the domain, ever, no matter how idiomatic it feels in that ecosystem.

The root tactical-ddd.md now says exactly that:

Never use a framework decorator — ORM annotations (@Entity, @Column, etc.) are forbidden too, with no exception. No implementation gets an exception just because "it's the convention in this ecosystem."

Kotlin's migration split Account.kt into a pure domain class and an infrastructure-side AccountJpaEntity + AccountMapper + MoneyEmbeddable, mirroring the pattern Java-springboot already had, and rewired AccountRepositoryImpl to commit pending domain events through the mapper inside the same transaction as the Outbox write — the highest-risk change of the round, run under a model picked specifically for it.

The One Nobody Could Have Caught Alone

The third complaint was placement: FastAPI and Go kept their notification code inside the Account domain; NestJS, Java, and Kotlin had each split it out to a shared top-level module. Neither side was obviously wrong — until re-reading the root's own domain-service.md, whose Technical Service example uses "sending an email or SMS" as the textbook case for staying inside the domain that needs it:

Only consider promoting it to a top-level shared module once multiple domains actually end up sharing the same implementation (YAGNI) — don't split it out to the top level in advance just because "other domains might use it someday."

FastAPI and Go were the two that had actually followed the doc. NestJS, Java, and Kotlin had drifted from it, independently, in the same direction. No single-language audit was ever going to surface that — the violation only exists when five implementations of the same concept get lined up side by side, and every audit round up to this one had gone language by language.

Why the Previous Rounds Missed All Three

Three separate structural reasons, one per complaint. An audit that checks whether the code matches its own docs is blind exactly when the docs are wrong in the same direction as the code — which is what happened in FastAPI. A harness rule that exists in one language's implementation isn't a rule the other four are held to — the Repository-name check that would have caught the naming drift existed only in NestJS's harness. And a per-language audit, run one implementation at a time, structurally cannot see a disagreement that only shows up in the comparison — which is the only place the notification split was ever visible.

What Got Written Down

Fixing the code was the easy part. Fixing the process meant writing both decisions into the root docs in language explicit enough that the next implementation doesn't get to make its own local call: no ORM exception in the domain, ever; a Technical Service defaults to living inside the domain until more than one domain is actually sharing it. Fourteen issues, five languages, mostly parallel worktree agents — Kotlin's rewrite run under the highest-stakes model, NestJS's move re-verified after it collided with a Card-domain change landing the same day, and one small pass of root-doc codification at the end, done by hand.

The question behind all three complaints — why hadn't dozens of rounds caught this — had three different honest answers, and the uncomfortable one is that two of the three violations were invisible by design: one because the doc that would have caught it was the doc that endorsed it, the other because no audit had ever looked at the five languages next to each other instead of one at a time.

Further reading in the repo

docs/architecture/tactical-ddd.md — the no-exception ORM rule in full · docs/architecture/domain-service.md — the Technical Service placement principle · AccountRepositoryImpl.kt — the real domain/JPA split, Outbox transaction included