Keeping the Domain Pure
No JPA or Spring annotations in the core: mapping between domain models and persistence/DTOs at the boundary, and honestly weighing the mapping cost against the payoff.
On this page
The Dependency Rule has a demanding consequence people love to skip: if the domain depends on nothing outward,
then the domain must contain no framework code - no JPA @Entity, no Spring @Component, no Jackson
annotations. That purity is the whole payoff, but it isn't free: it forces mapping at the boundary between
your clean domain models and the framework's models. This lesson is about doing that mapping well and deciding,
honestly, when the purity is worth its price.
The temptation and the cost of skipping it
The seductive shortcut is to use your JPA entity as your domain model - one class, annotated, doing double duty:
@Entity // 🚩 a framework annotation in your "domain"
@Table(name = "accounts")
class Account {
@Id @GeneratedValue Long id;
@Column BigDecimal balance;
// ...business methods mixed with persistence concerns...
}It works, and for simple apps it's a legitimate pragmatic choice. But it quietly couples your domain to JPA: the
@Entity needs a no-arg constructor (so you can't enforce invariants at construction), lazy-loading proxies leak
into business logic, and your "pure" rules now can't run without a persistence provider on the classpath. The
database's shape starts dictating the domain's shape.
Mapping at the boundary
The pure approach keeps two separate models and maps between them in the adapter:
// DOMAIN - pure, no annotations, enforces its own invariants
class Account {
private final AccountId id;
private Money balance;
public void withdraw(Money amount) { /* invariant enforced */ }
}
// PERSISTENCE - a separate JPA entity, lives in the infrastructure adapter
@Entity @Table(name = "accounts")
class AccountEntity { @Id String id; BigDecimal balance; String currency; }
// The ADAPTER maps between them - this is where the two worlds meet
class JpaAccountRepository implements AccountRepository {
public Optional<Account> findById(AccountId id) {
return jpa.findById(id.value()).map(this::toDomain); // entity → domain
}
public void save(Account a) { jpa.save(toEntity(a)); } // domain → entity
private Account toDomain(AccountEntity e) { ... }
private AccountEntity toEntity(Account a) { ... }
}The domain never sees AccountEntity; the rest of the app never sees JPA. The same discipline applies at the web
edge (map between domain objects and request/response DTOs, so Jackson annotations don't leak inward) and at
message boundaries.
The honest trade-off
Mapping code is real code - boilerplate you write, test, and maintain, and an extra model to keep in sync. Be honest about the ledger:
The purity buys you:
- A domain testable with zero infrastructure (next lesson) - no database, no Spring context.
- Freedom to change persistence, serialization, or framework without touching business rules.
- A domain model shaped by the business, not by table columns or JSON fields.
- Invariants enforceable at construction (no forced no-arg constructor).
It costs you:
- Mapping boilerplate (mitigated by MapStruct or a few helper methods).
- A second set of model classes to maintain.
- More ceremony - overkill for a simple CRUD screen.
The senior call: pay for purity where the domain logic is rich and long-lived, and take the pragmatic single-model shortcut where it's thin CRUD - exactly the DDD "is it worth it?" judgment applied to persistence. Consistency within a codebase matters too; don't map religiously in one module and not another.
Watch for the framework creeping inward
The purity erodes one convenient annotation at a time. Someone adds @JsonProperty to a domain class 'just so it serializes,' then a @Transactional, then a lazy JPA relationship. Each is small; together they re-couple the domain to the framework you worked to exclude. Guard the boundary in code review, or with an ArchUnit test that fails if the domain package imports jakarta.persistence or org.springframework (a fitness function - the Evolutionary Architecture module makes these automatic).
Keeping the domain pure is like an embassy that insists on a professional translator at the border: your diplomats (the domain) speak only their own language and are never asked to learn the foreign one. Every incoming message is translated at the door (adapter mapping), so internal deliberations stay in one clean language. The shortcut - writing memos in a mash-up of both languages so no translator is needed (the annotated dual-purpose entity) - saves effort today but means your diplomats slowly forget how to speak without foreign words mixed in, and you can never switch which foreign country you deal with. The translator costs a salary (mapping boilerplate); whether that's worth it depends on how much serious diplomacy (domain logic) actually happens inside.
A teammate proposes annotating ledger-legacy's rich Account aggregate directly with JPA to avoid 'pointless
mapping code.' The Account has complex invariants (overdraft rules, posting reconciliation) enforced at
construction, and the team plans to possibly move from Postgres to a document store next year. Argue for keeping
a separate persistence model here, and name one case where you'd accept their shortcut instead.
Why does keeping the domain pure require mapping at the boundary?
Key takeaways
- A pure domain contains no framework code (no @Entity, @Component, or JSON annotations), which is what makes it independent and testable.
- Purity forces mapping at the boundary: separate persistence entities and DTOs, converted to/from domain models inside the adapters.
- Using a JPA entity as your domain model is a pragmatic shortcut for simple apps but couples the domain to the framework (no-arg constructors, leaking proxies).
- Purity buys zero-infrastructure testing, framework/DB swap freedom, and business-shaped models; it costs mapping boilerplate and a second model set.
- Pay for purity where domain logic is rich and long-lived; take the single-model shortcut for thin CRUD - and guard the boundary (e.g. an ArchUnit fitness function) so annotations don't creep inward.