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

Published: (February 5, 2026 at 10:12 AM EST)
3 min read
Source: Dev.to

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.base only)
  • 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 (@NotNull on 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:

CampCharacteristics
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.

Back to Blog

Related posts

Read more »