Some time ago, I was asked to maintain a Java application supposedly built around Clean Architecture.
On paper, the domain layer was meant to be framework-agnostic and pure.
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
The breaking point
I eventually stumbled upon a constructor like this (anonymized, but painfully real):
public Invoice(String customerEmail, BigDecimal amount, List<Item> items, LocalDate dueDate) {
this.customerEmail = Preconditions.checkNotNull(customerEmail, "email required");
Preconditions.checkArgument(customerEmail.contains("@"), "bad email");
Preconditions.checkArgument(customerEmail.length() < 100, "email too long");
this.amount = Preconditions.checkNotNull(amount);
Preconditions.checkArgument(amount.compareTo(BigDecimal.ZERO) > 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.
But it was noisy, brittle, and actively hostile to readability.
Worse: every failure collapsed into an IllegalArgumentException, making it impossible to distinguish bad user input from developer mistakes once it hit production.
What I tried first (and why it didn’t stick)
I initially experimented with the usual suspects:
- Jakarta Bean Validation
- Apache Commons Validate
But 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.
The constraints I set for myself
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
What I ended up building
The same constructor now looks like this:
public Invoice(String email, BigDecimal amount, List<Item> 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.
Far less noise.
And—most importantly—the intent reads like business logic.
Why this actually matters
1. Typed exceptions, not strings
Instead of this:
IllegalArgumentException: bad email
You now get something like:
EmailFormatInvalidException {
fieldName = "email",
invalidValue = "...",
constraint = "EMAIL_FORMAT"
}
This turned out to be huge.
Our monitoring can now automatically distinguish between:
- User input errors → HTTP 400
- Programming errors → HTTP 500
No fragile string matching. No guessing.
2. Composable, domain-level rules
Custom predicates are explicit and readable:
Assert.field("iban", iban)
.notBlank()
.satisfies(this::isValidIBANChecksum, "Checksum failed");
No annotations.
No reflection.
Just business rules, written where they belong.
The trade-offs (being honest)
Not for Hibernate-first teams
If you’re all-in on Jakarta annotations and happy with@NotNullon JPA entities, this adds little value.More verbose than annotations
Yes, this is more code than@Email @NotNull.
But it’s explicit, testable, debuggable—and doesn’t leak frameworks into your core.No AOP magic
You don’t slap this on method parameters and get auto-validation.
This is intentional, constructor-level defense.
The bigger picture
After this experience, I’ve started to see validation libraries as falling into two camps:
- Framework validation (Jakarta, Spring)
- Excellent at the HTTP/binding layer
- Awful for domain purity
- Utility validation (Guava, Apache Commons)
- Reusable
- But weak on expressiveness and error semantics
There’s a missing middle ground:
domain-native validation that reads like business rules and fails like typed domain events.
That’s the gap I was trying to fill.
Discussion
For those doing DDD or Clean Architecture in Java:
- 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: https://github.com/Sympol/pure-assert
Curious to hear how others approach this.
Top comments (0)