Show HN: Replacing NotNull and Preconditions with fluent Java assertions
by symplice | 2026-02-05 00:25:29+00:00
Some time ago, I was tasked with maintaining a Java app built using the Clean architecture. In theory, the domain was supposed to remain agnostic, but in reality, every entity was either:

Sprinkled with Jakarta annotations (framework leakage)
Filled with Guava’s Preconditions.checkArgument() calls (intrusive dependencies)
Cluttered with manual null checks like if (x == null) (inconsistent and untested)

The painful part? A constructor like this (anonymized):

`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");
// ...

}`

What I tried first:

I removed Jakarta Bean Validation and Apache Commons Validate because they impose strong framework coupling and generate verbose dependencies or generic exceptions, violating domain independence. Bean Validation 3.0 with records improves the situation but is still annotation-based, making it hard to compose for complex business rules.

Constraints I set for myself:

If I were to introduce a dependency into our domain layer, it had to:
Have no transitive dependencies (single JAR, only java.base)
Throw typed exceptions (not IllegalArgumentException)
Support fluent chaining without hurting readability
Allow custom predicates without falling into lambda hell

What I built

`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();
// ...

}`

Why this matters

The exception hierarchy: Instead of IllegalArgumentException: bad email, you get EmailFormatInvalidException with structured fields (fieldName, invalidValue, constraint). Our monitoring now distinguishes between "user sent garbage" (400) and "developer logic error" (500) automatically.

Composable rules:

`Assert.field("iban", iban)
.notBlank()
.satisfies(this::isValidIBANChecksum, "Checksum failed");`

The trade-offs (being honest)

Not for Hibernate users: If you're all-in on Jakarta and love @NotNull on your JPA entities, this adds nothing. This is for the "ports and adapters" crowd.
Verbosity vs. annotations: Yes, it's more lines than @Email @NotNull. But those lines are explicit, testable, and framework-agnostic.

No AOP magic: You can't just slap this on a method parameter and expect auto-validation. This is imperative, intentional, constructor-level defense.

The bigger picture

I've come to believe that validation libraries fall into two categories:
Framework validation (Jakarta, Spring): Great for binding/HTTP layer, terrible for domain purity.
Utility validation (Guava, Apache): Great for reuse, terrible for expressiveness and exception granularity.
There's a missing middle: Domain-native validation that reads like business rules and fails like typed domain events.

Discussion

For those doing DDD or Clean Architecture in Java - how do you handle input validation without contaminating your entities? Do you accept Jakarta annotations in your domain layer? Write defensive code manually? Or have you found another approach?

Also curious: would typed exceptions (e.g., NumberValueTooLowException vs IllegalArgumentException) actually help your production debugging, or is it over-engineering?

https://github.com/Sympol/pure-assert