DDD · Architecture

When the Docs and the Code
Agree to Be Wrong

An audit that checks whether code matches its own docs cannot catch the case where both are wrong in the same direction, because agreement is exactly what it reports. A user pointed at three violations of a project's root design 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 many earlier audits had caught them. The honest answer was different for each one, and only one of them was a bug.

The three complaints arrived in one sentence. The project was my example project, which implements the same backend design in five languages side by side, and its Java, Go, Kotlin, and FastAPI implementations were violating the root 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 audits 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 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; it was 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 gradually 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 domain-purity rule in its architecture checker (a script that statically checks code against the documented rules) 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 as designed.

The harder question

Java-springboot faced the identical tradeoff and decided the opposite way: full separation, with an AccountJpaEntity/AccountMapper pair doing the translation. Two implementations of the same design, 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 so directly:

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. It also rewired AccountRepositoryImpl to commit pending domain events through the mapper inside the same transaction as the Outbox write. That was the highest-risk change of the whole effort, 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 a re-read of 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 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 up to this one had gone language by language.

Why Earlier Audits 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 check that exists in one language's checker 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. 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. The fixes spanned fourteen issues across five languages, mostly handled by AI agents working in parallel copies of the code. Kotlin's rewrite ran under the highest-stakes model, NestJS's move was 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 was done by hand.

The question behind all three complaints (why hadn't so many audits 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

docs/architecture/tactical-ddd.md (the no-exception ORM rule in full, in my example project that implements the same backend design in five languages) · docs/architecture/domain-service.md (the Technical Service placement principle) · AccountRepositoryImpl.kt (the real domain/JPA split, Outbox transaction included)