You open a Spring Boot project and find the User and Article classes full of getters and setters, and the logic spread over three different Service classes: the same email check is written in four places, and when a rule changes you don't know how many you have forgotten. The domain layer is Clean Architecture's answer to this problem: a set of classes that hold the business rules, do not depend on Spring or JPA, and are tested with a plain method call. In this article we build that layer with Java 21 records, all the listings are compiled and run on OpenJDK 21.0.13, and we compare the results with this site's backend, measured in the code. On why it differs from the "anemic entity" there is a passage by Martin Fowler worth reading: Anemic Domain Model argues that a model without behavior pays all the costs of a domain (above all the mapping to the database) without getting its benefits.
What is in the domain layer
The domain contains the business concepts and the rules that bind them. The table says what goes where, and above all what does not belong: no framework annotations, no I/O, no clock read from inside (the instant is passed as a parameter, to make the code testable).
| Element | What it is | Example |
|---|---|---|
| Entity | Has an identity that stays the same over time, even if its attributes change | User, Article |
| Value object | Has no identity: it is defined by its value, is immutable and validates itself at construction | Email, Money, Slug |
| Port | Interface toward the outside, declared in the domain and implemented in infrastructure | UserRepository, EmailSender |
| Domain event | A fact that happened in the domain, which others can react to | ArticlePublished |
The distinction beginners get wrong most often is the one between entity and value object, and it has practical consequences: two emails with the same text are the same email (value object, equality by value), while two users with the same name are not the same user (entity, equality by identity). We will see how to implement it with code, because Java's record, as defined, favors the first case and has to be corrected for the second. We go deeper into ports in JPA and Flyway in Clean Architecture.
Value object: validate at birth
The advantage of a value object is that an invalid object does not exist. If Email checks the format in the compact constructor, every method that receives an Email can take its validity for granted and does not repeat the check. The listing has three value objects, each with a choice to explain. Email normalizes to lowercase after validation, because Test@Example.COM and test@example.com must be the same value. Money sets the scale according to the currency (setScale with HALF_UP): without it, 10 and 10.00 turn out to be different. Slug has a fromTitle factory that strips accents with Normalizer before discarding the characters that are not allowed.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.Normalizer;
import java.util.Currency;
import java.util.Objects;
import java.util.regex.Pattern;
record Email(String value) {
private static final Pattern FORMAT = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
Email {
if (value == null || value.isBlank()) throw new IllegalArgumentException("Empty email");
if (!FORMAT.matcher(value).matches()) throw new IllegalArgumentException("Invalid email: " + value);
value = value.toLowerCase(); // normalization: two spellings, one value
}
}
record Money(BigDecimal amount, Currency currency) {
Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.signum() < 0) throw new IllegalArgumentException("Negative amount");
amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP);
}
Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Different currencies: " + currency + " + " + other.currency);
}
return new Money(amount.add(other.amount), currency);
}
}
record Slug(String value) {
private static final Pattern FORMAT = Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");
Slug {
if (value == null || value.length() > 100 || !FORMAT.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid slug: " + value);
}
}
static Slug fromTitle(String title) {
String senzaAccenti = Normalizer.normalize(title, Normalizer.Form.NFD).replaceAll("\\p{M}+", "");
String pulito = senzaAccenti.toLowerCase()
.replaceAll("[^a-z0-9\\s-]", "")
.trim()
.replaceAll("[\\s-]+", "-");
String troncato = pulito.length() > 100 ? pulito.substring(0, 100) : pulito;
return new Slug(troncato.replaceAll("^-+|-+$", ""));
}
}
Three details of this code come from mistakes that typical examples contain. The first concerns Money: the BigDecimal documentation says that equals requires the same value and the same representation, that is, the same scale, so a record holding a non-normalized BigDecimal inherits this surprise (the comparison is in the program below). The second concerns the format of Slug: the expression admits only groups of letters or digits separated by a single hyphen, which excludes leading or trailing hyphens and double hyphens by construction, without a second check. The third is the validation of Email with a simplified regular expression: it is fine for excluding obviously wrong text, but the only way to verify that an address exists is to send it a message.
Entity: identity is not the set of fields
A record generates equals, hashCode and toString from all its components: the java.lang.Record documentation says so. For a value object this is the right behavior. For an entity it is not, for two practical reasons. First: two instances of the same user with a different lastLoginAt would turn out to be different, and in a HashSet or as a map key they would appear twice. Second: the generated toString also prints the hashed password, which ends up in the logs at the first log.info("user {}", user). The listing compares UserNaive (the record with the defaults) and User (with equals on the id and a selective toString), and defines Article with the publish operation.
import java.time.Instant;
import java.util.List;
import java.util.UUID;
record UserId(UUID value) {}
// "Naive" record: equals/hashCode/toString generated on ALL components.
record UserNaive(UserId id, Email email, String hashedPassword, Instant lastLoginAt) {}
// Entity: identity is the id, not the set of fields.
record User(UserId id, Email email, String hashedPassword, Instant lastLoginAt) {
@Override public boolean equals(Object o) { return o instanceof User other && id.equals(other.id); }
@Override public int hashCode() { return id.hashCode(); }
@Override public String toString() { return "User[id=" + id.value() + ", email=" + email.value() + "]"; }
User withLastLogin(Instant when) { return new User(id, email, hashedPassword, when); }
}
interface DomainEvent {}
record ArticlePublished(String slug, Instant occurredAt) implements DomainEvent {}
enum PublishStatus { DRAFT, PUBLISHED }
record PublishResult(Article article, List<DomainEvent> events) {}
record Article(UUID id, Slug slug, String title, UserId authorId, PublishStatus status, Instant publishedAt) {
static Article draft(String title, UserId author) {
return new Article(UUID.randomUUID(), Slug.fromTitle(title), title, author, PublishStatus.DRAFT, null);
}
// A record cannot accumulate events in a field: the operation's result carries them.
PublishResult publish(Instant now) {
if (status == PublishStatus.PUBLISHED) throw new IllegalStateException("Article already published");
Article published = new Article(id, slug, title, authorId, PublishStatus.PUBLISHED, now);
return new PublishResult(published, List.of(new ArticlePublished(slug.value(), now)));
}
}
Note how Article.publish cannot "change the state": records are immutable, and the method returns a PublishResult that contains the new article and the events produced. A record cannot have instance fields outside its components, so it cannot even accumulate events in an internal list, as one would do in a class; having the operation return the events is the simplest solution. The rule "you don't publish twice" lives in the entity, not in a service, and that is why it is tested without starting anything.
What you observe when you run it
The program exercises everything in a single main: email normalization, the two kinds of user in a Set, the toString, the scale of Money, two ways of building a slug and the publication. The output is identical in two consecutive runs, because the UUID and the instants are fixed and nothing depends on the time of day. The title with accents, "Perché la Clean Architecture è utile? Davvero!" ("Why is Clean Architecture useful? Really!"), is left in Italian on purpose: the example is precisely about how accented letters are handled, so the output slugs are those of the Italian text.
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Currency;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
public class DomainDemo {
// Naive version: strips every character outside [a-z0-9], accents included.
static String slugIngenuo(String title) {
return title.toLowerCase().replaceAll("[^a-z0-9\\s-]", "").replaceAll("\\s+", "-").replaceAll("-+", "-");
}
public static void main(String[] args) {
UserId id = new UserId(UUID.fromString("00000000-0000-0000-0000-000000000001"));
Email email = new Email("Test@Example.COM");
System.out.println("normalized email: " + email.value());
// 1. equals: same id, different last login
Instant t1 = Instant.parse("2026-11-01T10:00:00Z"), t2 = Instant.parse("2026-11-02T10:00:00Z");
Set<UserNaive> naive = new HashSet<>(Set.of(new UserNaive(id, email, "$2a$12$HASHFINTO", t1)));
naive.add(new UserNaive(id, email, "$2a$12$HASHFINTO", t2));
Set<User> entities = new HashSet<>(Set.of(new User(id, email, "$2a$12$HASHFINTO", t1)));
entities.add(new User(id, email, "$2a$12$HASHFINTO", t2));
System.out.println("naive record in the Set: " + naive.size() + " | entity with equals on id: " + entities.size());
// 2. toString and sensitive data
System.out.println(new UserNaive(id, email, "$2a$12$HASHFINTO", t1));
System.out.println(new User(id, email, "$2a$12$HASHFINTO", t1));
// 3. Money: the scale of BigDecimal
Currency eur = Currency.getInstance("EUR");
System.out.println("BigDecimal 10 vs 10.00 equal? " + new BigDecimal("10").equals(new BigDecimal("10.00")));
System.out.println("Money 10 vs 10.00 equal? " + new Money(new BigDecimal("10"), eur).equals(new Money(new BigDecimal("10.00"), eur)));
System.out.println("sum: " + new Money(new BigDecimal("0.1"), eur).add(new Money(new BigDecimal("0.2"), eur)));
try { new Money(BigDecimal.ONE, eur).add(new Money(BigDecimal.ONE, Currency.getInstance("USD"))); }
catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
// 4. Slug from an Italian title
System.out.println(slugIngenuo("Perché la Clean Architecture è utile? Davvero!"));
System.out.println(Slug.fromTitle("Perché la Clean Architecture è utile? Davvero!").value());
try { Slug.fromTitle("???"); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
// 5. Publishing: new instance + events
Article bozza = Article.draft("Pure domain layer", id);
PublishResult r = bozza.publish(t2);
System.out.println(bozza.status() + " -> " + r.article().status() + ", events: " + r.events());
try { r.article().publish(t2); } catch (IllegalStateException e) { System.out.println(e.getMessage()); }
}
}
// normalized email: test@example.com
// naive record in the Set: 2 | entity with equals on id: 1
// UserNaive[id=UserId[value=00000000-0000-0000-0000-000000000001], email=Email[value=test@example.com], hashedPassword=$2a$12$HASHFINTO, lastLoginAt=2026-11-01T10:00:00Z]
// User[id=00000000-0000-0000-0000-000000000001, email=test@example.com]
// BigDecimal 10 vs 10.00 equal? false
// Money 10 vs 10.00 equal? true
// sum: Money[amount=0.30, currency=EUR]
// Different currencies: EUR + USD
// perch-la-clean-architecture-utile-davvero
// perche-la-clean-architecture-e-utile-davvero
// Invalid slug:
// DRAFT -> PUBLISHED, events: [ArticlePublished[slug=pure-domain-layer, occurredAt=2026-11-02T10:00:00Z]]
// Article already published
There are four lines to read. The naive record counts two users in the Set and the entity with equals on the id counts one: it is the effect of the identity choice. The naive record's toString contains the hashedPassword=... line; the entity's does not. The second comparison shows that BigDecimal("10") and BigDecimal("10.00") are different, while the two corresponding Money objects are equal, thanks to the scale imposed by the constructor. And the slug built with the naive version from "Perché la Clean Architecture è utile? Davvero!" loses the accented letters (perch-la-clean-architecture-utile-davvero), while the one with Normalizer keeps perche and e. For an Italian-language site this is not a detail: the slug is a public address.
Where the logic lives: in the domain, not in the service
The rule that motivates the whole layer is that the check lives where the data lives. If Article.publish verifies the state, every path that publishes (a REST endpoint, a job, an import) goes through the same check; if a service verifies it, every new path must remember to do so. The application service (the use case) remains the coordinator: it loads, calls the domain, saves, publishes the events. You can see it in Use Case Pattern in Spring Boot.
A limit worth stating: the domain does not replace database constraints. If someone writes directly in SQL, the rules of Email or Article are not executed. For the invariants that really matter you also need UNIQUE, CHECK and foreign keys in the schema: they are two levels of defense, not one in place of the other.
Aggregates and events: keep them small
An aggregate is a group of objects that must remain consistent within the same transaction, with a root entity as the only access point. The frequent mistake is to include in the aggregate everything that is connected: a user that contains the list of all its articles forces you to load them in order to publish one. Here Article is an aggregate on its own and points to the author by identifier (UserId), not by reference: publishing an article touches a single article. In the domain the criterion is enough: keep together only what must respect an invariant in the same transaction.
Events exist to let other parts react without coupling them. When an article is published, sending a notification is a consequence, not part of the rule. In Spring you listen for the event with @TransactionalEventListener: the Spring Framework documentation specifies that the default phase is AFTER_COMMIT and that, if there is no transaction in progress, the listener is not invoked at all (unless fallbackExecution is set). With a plain @EventListener, instead, the reaction starts during the transaction, before the commit: a later rollback leaves an effect that has already happened.
// Application layer (template to adapt, not run: requires Spring)
@Component
class SendWelcomeEmailOnPublished {
private final EmailSender emailSender; // domain port
SendWelcomeEmailOnPublished(EmailSender emailSender) { this.emailSender = emailSender; }
// AFTER_COMMIT is the default phase: the email goes out only if the transaction succeeded.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(ArticlePublished event) {
emailSender.notifyPublished(event.slug());
}
}
The listener is a template to adapt and was not run: it requires Spring. The cost to know about is that with AFTER_COMMIT the delivery is best effort: if the process dies right after the commit, the event is lost. For strong guarantees you use an outbox table written in the same transaction. This site's backend uses this phase in a real case, the listener that sends the issue-assignment emails, which adds @Async and catches SMTP exceptions without propagating them (IssueAssignedEmailListener, read in the source).
How pure the domain of this site's backend is
The backend's domain package contains 146 Java files, out of 3741 in src/main (measured on 2026-11-12 with find ... -name '*.java' | wc -l). An ArchUnit rule, CleanArchitectureTest.domainDoesNotDependOnFrameworks_frozen, forbids the domain from depending on Spring and JPA. It is frozen: the historical violations are recorded in a store (archunit-store) and do not fail the build, while every new violation does. The store lists six lines today, referring to three classes: TermSheetAnalyzer, annotated with the Spring stereotype @Component, and two repository ports, ProposalRepository and QuoteRepository, which use Spring Data's Page and Pageable in their signatures (the name of the rule, written in July 2026, speaks of four violations: the store counts lines, not classes). For an explanation of the mechanism, see the ArchUnit gate article.
What this says about the principle: "pure domain" is a goal with a declared, measurable debt, not a label. Defining your own pagination type costs code and mapping, and using Spring Data's costs an import in the domain; the project chose the second route in the listed cases and blocked any worsening. Two other details confirm the choices of this article: the domain's User class (domain/model/User.java) is a class, not a record, compares by email and excludes the hash from toString; the ImportoVoce record of the project-economics module declares in its Javadoc the HALF_UP rounding to two decimals.
The cost of double modeling
A pure domain and a separate persistence model mean two classes per concept and a mapper to keep aligned with every new column: a field forgotten in the mapper is a silent bug. The benefit is that the domain does not inherit JPA's constraints (no-argument constructor, mutable fields, lazy proxies). With a small domain and no complex rules, using the JPA entity as the model is a defensible choice; the separation pays off when the rules are many and the persistent model tends to diverge from the business one. The series' infrastructure layer, with JPA and Redis, is in Infrastructure layer: JPA and Redis. To test the domain, see Testing in Clean Architecture.
Frequently asked questions
Should I use records for all entities?
For value objects, yes, almost always. For entities the record works if you override equals, hashCode and toString as seen above; if the entity has a lot of behavior or evolving state, a class with final fields and modification methods that return a copy is a legitimate alternative, and it is the one chosen by the backend's User.
Can I skip value objects in a simple domain?
Yes, when an Email is used in a single place and has no rules, a String is enough. As soon as the rules appear in two places, the value object costs a few lines and removes the risk of a forgotten rule. Starting with the string and promoting it to a value object on the second use is a reasonable strategy.
Who calls Instant.now()?
The use case or an injected Clock interface, not the entity. If the domain reads the clock, every test depends on the time; by passing the instant as a parameter, as in publish(Instant now), the output is always the same.
What you can do now
You can write a value object that is not born invalid and recognize the three surprises of record (equality on all components, a toString that exposes data, the scale of BigDecimal). You can tell entities and value objects apart by the way they are compared, keep aggregates small and have the operation return the events. And you can say how pure a domain is by measuring it, as the backend's ArchUnit rule does, instead of asserting it. The next step is to connect the domain to persistence in JPA and Flyway, and the big picture is in Clean Architecture in Spring Boot 3.4.
Sources
- Martin Fowler - Anemic Domain Model - the cost of a model without behavior; consulted on 2026-11-12
- Java SE 21 API - java.lang.Record -
equals,hashCode,toStringderived from all components; consulted on 2026-11-12 - Java SE 21 API - java.math.BigDecimal -
equalsrequires the same value and the same scale; consulted on 2026-11-12 - Spring Framework Reference - Transaction-bound Events - default
AFTER_COMMITphase and no invocation without a transaction; consulted on 2026-11-12 - Robert C. Martin - The Clean Architecture - the dependency rule pointing toward the domain; consulted on 2026-11-12
- Backend sources:
CleanArchitectureTest.java,archunit-store,domain/model/User.java,ImportoVoce.java,IssueAssignedEmailListener.java- read on 2026-11-12

Commenti
Caricamento commenti...
Sign in to comment