When “Clean Architecture” Isn’t So Clean: Rethinking Validation in the Domain
Source: Dev.to
Some time ago, I was asked to maintain a Java application supposedly built around Clean Architecture. In reality… not so much.
Almost every entity fell into one of these categories:
- Sprinkled with Jakarta annotations → framework leakage straight into the domain
- Stuffed with Guava
Preconditions.checkArgument()→ intrusive utility dependencies - Polluted with ad‑hoc null checks → inconsistent, repetitive, and rarely tested
I eventually stumbled upon a constructor like this (anonymized, but painfully real):
public Invoice(String customerEmail, BigDecimal amount, List items, LocalDate dueDate) {
this.customerEmail = Preconditions.checkNotNull(customerEmail, "email required");
Preconditions.checkArgument(customerEmail.contains("@"), "bad email");
Preconditions.checkArgument(customerEmail.length() 0, "amount positive");
this.items = Preconditions.checkNotNull(items);
Preconditions.checkArgument(!items.isEmpty(), "items empty");
Preconditions.checkArgument(items.stream().allMatch(Objects::nonNull), "null item");
this.dueDate = Preconditions.checkNotNull(dueDate);
Preconditions.checkArgument(dueDate.isAfter(LocalDate.now()), "future date");
}
It worked, sure.
Worse: every failure collapsed into an IllegalArgumentException, making it impossible to distinguish bad user input from developer mistakes once it hit production.
Existing validation attempts
I initially experimented with the usual suspects:
- Jakarta Bean Validation
- Apache Commons Validate
Both came with trade‑offs I couldn’t accept:
- Strong coupling to external frameworks
- Annotation‑heavy APIs that don’t compose well for complex rules
- Generic exceptions that flatten all validation failures into the same bucket
Bean Validation 3.0 + records improves things, but it’s still annotation‑driven—and business rules rarely fit nicely into declarative annotations.
A domain‑native validation approach
If I was going to introduce any dependency into the domain layer, it had to:
- Have zero transitive dependencies (single JAR,
java.baseonly) - Throw typed, meaningful exceptions (not
IllegalArgumentException) - Support fluent, readable chaining
- Allow custom predicates without turning everything into lambda soup
The same constructor now looks like this:
public Invoice(String email, BigDecimal amount, List items, LocalDate dueDate) {
this.email = Assert.field("email", email)
.notBlank()
.email()
.maxLength(100)
.value();
this.amount = Assert.field("amount", amount)
.positive()
.value();
this.items = Assert.field("items", items)
.notEmpty()
.noNullElement()
.value();
this.dueDate = Assert.field("dueDate", dueDate)
.inFuture()
.value();
}
Same rules.
The intent reads like business logic.
Instead of a generic IllegalArgumentException: bad email, you now get something like:
EmailFormatInvalidException {
fieldName = "email",
invalidValue = "...",
constraint = "EMAIL_FORMAT"
}
Benefits
-
Clear monitoring – we can automatically distinguish between
- User input errors → HTTP 400
- Programming errors → HTTP 500
No fragile string matching, no guessing.
-
Explicit custom predicates
Assert.field("iban", iban) .notBlank() .satisfies(this::isValidIBANChecksum, "Checksum failed"); -
No annotations – works for teams that avoid Hibernate‑first patterns (
@NotNullon JPA entities adds little value). -
No AOP magic – intentional, constructor‑level defense.
The bigger picture
After this experience, I see validation libraries falling into two camps:
| Camp | Characteristics |
|---|---|
| Framework validation (Jakarta, Spring) | Excellent at the HTTP/binding layer; awful for domain purity |
| Utility validation (Guava, Apache Commons) | Reusable; weak on expressiveness and error semantics |
There’s a missing middle ground: domain‑native validation that reads like business rules and fails with typed domain events. That’s the gap I was trying to fill.
Open questions for the community
- How do you handle validation without contaminating your domain?
- Do you accept Jakarta annotations in entities?
- Do you write defensive code by hand?
- Or have you found a different approach entirely?
And an honest question: do typed validation exceptions (e.g. NumberValueTooLowException) actually help you in production—or is this just over‑engineering?
Project:
Curious to hear how others approach this.