DEV Community

Symplice Boni
Symplice Boni

Posted on

When “Clean Architecture” Isn’t So Clean: Rethinking Validation in the Domain

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");
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Have zero transitive dependencies (single JAR, java.base only)
  2. Throw typed, meaningful exceptions (not IllegalArgumentException)
  3. Support fluent, readable chaining
  4. 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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You now get something like:

EmailFormatInvalidException {
  fieldName = "email",
  invalidValue = "...",
  constraint = "EMAIL_FORMAT"
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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 @NotNull on 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:

  1. Framework validation (Jakarta, Spring)
  • Excellent at the HTTP/binding layer
  • Awful for domain purity
  1. 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)