Skip to main content

Command Palette

Search for a command to run...

Mastering Domain Invariants: How Pure-Assert Enhances DDD and Clean Architecture

The domain is the heart of your application. Why pollute it with technical frameworks?

Updated
14 min read
Mastering Domain Invariants: How Pure-Assert Enhances DDD and Clean Architecture

Introduction

You practice Domain‑Driven Design or Clean Architecture. You’ve layered your application. You’ve isolated your domain. And yet, your entities look like this:

public class Order {
    @NotNull
    @Size(min = 1)
    private List<@Valid OrderLine> lines;

    @NotNull
    @DecimalMin("0.01")
    private BigDecimal totalAmount;

    @NotNull
    @Pattern(regexp = "^[A-Z]{2}-\\d{6}$")
    private String reference;
}

The question is not whether this is “good” or “bad.” The question is: is this the best choice for your context?

This article defends a nuanced position: a domain can legitimately choose to be technically agnostic when it aims to maximize the stability, expressiveness, and longevity of its business invariants. And I will show you how pure-assert can help you achieve that — while acknowledging its limitations.

Who this article is for (and who it isn’t)

This article is primarily for:

  • Teams working on a complex business core

  • Applications with a long lifespan

  • Contexts where business invariants are critical (finance, healthcare, legal)

  • DDD / hexagonal architectures adopted as a conscious choice

It does NOT claim to be the optimal solution for:

  • Simple CRUD or low-complexity back-offices

  • Projects under very strong time‑to‑market pressure

  • Teams without DDD / Clean Architecture experience

  • Short‑lived, ephemeral microservices

In those contexts, Jakarta Validation or Guava remain perfectly appropriate. Don’t introduce unnecessary complexity.

The problem: a matter of design, not technology

1. Jakarta annotations: a nuanced debate

Annotations like @NotNull, @Size, @Pattern seem innocent. They are declarative, concise, and natively supported by the ecosystem. But do they really deserve to be called “pollution”?

The legitimate counter‑argument

Opposing view: The Jakarta Validation API is not an infrastructure framework — it is a stable specification. It imposes no runtime, no container. The domain depends on an abstraction, not on a technology. According to Clean Architecture, depending on a stable abstraction is acceptable.

This is a reasonable argument. Let’s compare:

DependencyTypeStabilityVerdict
jakarta.validation-apiSpecificationVery stableDebatable
org.hibernate.validatorImplementationVariablePollution
org.springframeworkFrameworkVariablePollution
javax.persistence / jakarta.persistenceSpecificationStableDebatable

It would be intellectually dishonest to equate Jakarta Validation with Spring or Hibernate. The API alone imposes nothing technical.

What’s really questionable

Jakarta Validation is not problematic in itself. What’s questionable is its default mode of usage, which encourages:

  • External validation (an explicit call to a Validator)

  • Deferred validation (the object exists before it is validated)

  • A data‑oriented approach rather than encapsulated invariants

1. The dominant usage pattern

// The object exists in a potentially invalid state
Order order = new Order(null, List.of(), BigDecimal.ZERO);

// Validation happens afterwards
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
Set<ConstraintViolation<Order>> violations = validator.validate(order);

This pattern breaks the DDD “Always Valid” principle. The object can circulate in your system before being validated.

2. Implicit vs explicit invariants

What does @Size(min = 1) on a list of order lines mean? That an order must have at least one line. But this intention is encoded in an annotation, not in the behavior of the object.

Our restated position: We are not attacking Jakarta Validation as a technology. We question the dominant usage that decouples validation from construction. In contexts where business invariants are critical, encapsulating validation in the constructor provides stronger guarantees.

2. Guava Preconditions: an honorable alternative

Let’s be honest: Guava is a mature, battle‑tested, ultra‑stable library. The “3 MB of dependencies” argument is weak — in the container era, it’s negligible. If you already use Guava in your project, Preconditions is a perfectly valid choice.

public Order(String reference, List<OrderLine> lines, BigDecimal totalAmount) {
    Preconditions.checkNotNull(reference, "reference cannot be null");
    Preconditions.checkArgument(reference.matches("^[A-Z]{2}-\\d{6}$"), 
        "Invalid reference format");
    Preconditions.checkNotNull(lines, "lines cannot be null");
    Preconditions.checkArgument(!lines.isEmpty(), "Order must have at least one line");
    Preconditions.checkNotNull(totalAmount, "totalAmount cannot be null");
    Preconditions.checkArgument(totalAmount.compareTo(BigDecimal.ZERO) > 0, 
        "totalAmount must be positive");

    this.reference = reference;
    this.lines = List.copyOf(lines);
    this.totalAmount = totalAmount;
}

What Guava does well:

  • Simple, proven API

  • Immediate fail‑fast

  • Stable for 10+ years

Where pure-assert brings a technical (not rhetorical) difference:

AspectGuava Preconditionspure-assert
Exception typeIllegalArgumentExceptionStringTooShortException, NumberValueTooLowException, etc.
MetadataText message onlyfield(), parameters(), type()
Debugging in production“Validation failed”Structured field, value, constraint
APIStatic methodsFluent, chainable

The real argument: It’s not about size or “pollution.” It’s about semantic richness of errors. A catch (IllegalArgumentException e) tells you neither which field failed nor why. A catch (NumberValueTooLowException e) gives you e.field(), e.parameters().get("min"), and e.type().

The solution: an agnostic domain with pure-assert

Philosophy

pure-assert is built on three core principles :

  1. Zero dependency — Pure Java, no transitive pollution

  2. Always Valid — An object is valid as soon as it’s constructed — or it doesn’t exist

  3. Typed exceptions — Each type of error has its own exception with rich metadata

Transforming an aggregate

Let’s revisit our Order and rebuild it with pure-assert:

public class Order {
    private final OrderReference reference;
    private final List<OrderLine> lines;
    private final Money totalAmount;

    public Order(OrderReference reference, List<OrderLine> lines, Money totalAmount) {
        this.reference = Assert.field("reference", reference).notNull().value();
        this.lines = Assert.field("lines", lines).notEmpty().noNullElement().value();
        this.totalAmount = Assert.field("totalAmount", totalAmount).notNull().value();
    }
}

What changed ? :

BeforeAfter
External annotationsPure Java code
Deferred validationImmediate validation
IllegalArgumentExceptionTyped MissingMandatoryValueException
No contextField name, value, constraint

Value objects with pure-assert

public record OrderReference(String value) {
    private static final Pattern FORMAT = Pattern.compile("^[A-Z]{2}-\\d{6}$");

    public OrderReference {
        Assert.field("value", value)
              .notBlank()
              .matches(FORMAT);
    }
}

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Assert.field("amount", amount)
              .notNull()
              .min(BigDecimal.ZERO);
        Assert.field("currency", currency).notNull();
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new CurrencyMismatchException(this.currency, other.currency);
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

Business intent is in the code, not in annotations.

When to create a Value Object (and when not to) ?

Creating Value Objects is not free: it increases the number of types, complicates mappings, and requires team discipline.

Pragmatic rule:

Create a Value Object when:

  • The concept exists in the domain language (Ubiquitous Language)

  • It carries non-trivial invariants

  • It is reused in multiple places

Avoid them for:

  • Purely technical fields

  • Ephemeral data

  • Attributes without behavior

pure-assert does not impose Value Objects; it simply makes them safer when they are justified.

Intellectual honesty: limits and trade‑offs

When NOT to use pure-assert ?

pure-assert is not a universal solution. Here are cases where Jakarta Validation remains relevant:

ContextRecommendation
REST DTO validationJakarta — error aggregation, i18n, OpenAPI integration
UI formsJakarta — localized messages, automatic binding
Batch / import validationJakarta — collect all errors before rejection
Public APIs with i18n constraintsJakarta — translated messages per locale

Why? Jakarta Validation excels at peripheral validation: where you must collect several errors, localize them, and present them to a human user. pure-assert fails on the first error — that’s intentional for business invariants, but unsuitable for UX.

The hybrid approach: the best of both worlds

In practice, a mature architecture combines both:


┌───────────────────────────────────────────────────────┐
│                        INFRASTRUCTURE                 │
│  ┌───────────────────────────────────────────────┐    │
│  │  REST Controller                              │    │
│  │  @Valid UserDTO dto  ◄── Jakarta Validation   │    │
│  │  (i18n, aggregation, OpenAPI)                 │    │
│  └───────────────────────────────────────────────┘    │
│                              │                        │
│                              ▼                        │
├───────────────────────────────────────────────────────┤
│                        APPLICATION                    │
│  ┌───────────────────────────────────────────────┐    │
│  │  Use Case                                     │    │
│  │  new Email(dto.email())  ◄── pure-assert      │    │
│  │  (business invariants, fail-fast)             │    │
│  └───────────────────────────────────────────────┘    │
│                              │                        │
│                              ▼                        │
├───────────────────────────────────────────────────────┤
│                          DOMAIN                       │
│  ┌───────────────────────────────────────────────┐    │
│  │  Value Objects & Entities                     │    │
│  │  100% pure-assert (zero annotations)          │    │
│  └───────────────────────────────────────────────┘    │
└───────────────────────────────────────────────────────┘

The rule is simple:

  • Infra layer (Controllers, DTOs) → Jakarta Validation for UX

  • Domain layer (Entities, Value Objects) → pure-assert for invariants

Explicit acknowledgement of trade‑offs

Aspectpure-assertJakarta Validation
Fail‑fastYes (by design)No
Error aggregationNoYes
InternationalizationNoYes
OpenAPI integrationNoYes
Zero dependencyYesNo
Pure domainYesNo

The accepted trade‑off: pure-assert intentionally sacrifices aggregation and i18n in favor of domain purity. This isn’t an oversight; it’s a design choice.

The “zero dependency (except pure‑assert)” paradox

Let’s address the elephant in the room: this “except” is architecturally significant.

Legitimate critique: By adopting pure-assert, your domain becomes tightly coupled to this API. Assertions are everywhere. Changing approaches means massive refactoring. Haven’t we simply swapped one dependency for another?

Yes, the risk is real, but it can be evaluated objectively:

pure-assert is still a relatively young library and cannot yet claim the same level of maturity, longevity, or governance as established projects like Guava or Jakarta Validation. However, this youth also means the API is intentionally small, focused, and still open to improvement. If you bring your experience, feedback, or contributions, we can grow the number of maintainers together and collectively shape a library that delivers something genuinely excellent for long-lived, domain-centric systems.

An objective comparison highlights the trade-off: Guava, with over 15 years of experience and backed by Google, and Jakarta Validation, managed for over a decade by the Eclipse Foundation, both present a low risk of dependency. Pure-Assert, on the other hand, is brand new and currently maintained by a single author, which undeniably implies a higher risk of dependency. I cannot claim the same level of guarantees as Guava or Jakarta—and to claim otherwise would be dishonest.

Mitigating factors

1. Minimalist and stable API

// The entire API fits on one line
   Assert.field("name", value).notBlank().maxLength(100);

Small surface = fewer potential breaking changes.

2. Trivial source code to internalize

If pure-assert disappears, you can copy its ~2000 lines into your project. It’s pure Java with no magic.

3. Standard exceptions

The exceptions extend RuntimeException. Your catch blocks work even without the library.

// Guava (possible find/replace refactor)
   checkArgument(age >= 0 && age <= 150, "age must be between 0 and 150");

About pure-assert lock‑in

Yes, using pure-assert creates a dependency. But this dependency is:

  • Fine (reduced API surface)

  • Explicit (assertions are visible in the code)

  • Easily replaceable (conceptual find/replace)

Unlike annotations or implicit mechanisms, invariants expressed with pure-assert remains readable and portable, even if the library were to disappear.

True lock‑in is not the dependency itself, but the opacity of the code.

Our commitment

We take the risks seriously and make the following commitments:

  • Strict Semantic Versioning — no breaking changes without a major version bump

  • Stable 1.x API — the 1.x line is considered frozen, with additions only

  • Fully open source — forkable and easy to internalize if needed

  • Clear migration paths — documentation will be provided whenever incompatible changes occur

Final advice: If long-term stability is your absolute priority, Guava remains the safest and most proven choice while pure-assert continues to mature. If, however, expressive business invariants and rich semantic errors matter more in your day-to-day development and production debugging, pure-assert is a deliberate and reasonable risk to take.

Detailed comparison: before/after pure-assert

Case 1: User entity

Without pure-assert (Jakarta + Guava):

public class User {
    @NotNull
    @Email
    private String email;

    @NotNull
    @Size(min = 2, max = 50)
    private String firstName;

    @NotNull  
    @Size(min = 2, max = 50)
    private String lastName;

    @Min(0)
    @Max(150)
    private int age;

    public User(String email, String firstName, String lastName, int age) {
        // Validation delegated to the framework...
        this.email = email;
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }
}

With pure-assert:

public class User {
    private final Email email;
    private final Name firstName;
    private final Name lastName;
    private final Age age;

    public User(Email email, Name firstName, Name lastName, Age age) {
        this.email = Assert.field("email", email).notNull().value();
        this.firstName = Assert.field("firstName", firstName).notNull().value();
        this.lastName = Assert.field("lastName", lastName).notNull().value();
        this.age = Assert.field("age", age).notNull().value();
    }
}

// Value Objects
public record Email(String value) {
    public Email {
        Assert.field("value", value).notBlank().email();
    }
}

public record Name(String value) {
    public Name {
        Assert.field("value", value).notBlank().minLength(2).maxLength(50);
    }
}

public record Age(int value) {
    public Age {
        Assert.field("value", value).min(0).max(150);
    }
}

Benefits:

  • Domain without any annotations

  • Rich, reusable value objects

  • Strong typing that prevents errors at compile time

  • Immediate validation at construction

Case 2: complete Order aggregate

With pure-assert:

public class Order {
    private final OrderId id;
    private final CustomerId customerId;
    private final List<OrderLine> lines;
    private final OrderStatus status;
    private final Instant createdAt;

    public Order(OrderId id, CustomerId customerId, List<OrderLine> lines) {
        this.id = Assert.field("id", id).notNull().value();
        this.customerId = Assert.field("customerId", customerId).notNull().value();
        this.lines = Assert.field("lines", lines)
                          .notEmpty()
                          .noNullElement()
                          .maxSize(100)
                          .value();
        this.status = OrderStatus.PENDING;
        this.createdAt = Instant.now();
    }

    public Money calculateTotal() {
        return lines.stream()
                    .map(OrderLine::subtotal)
                    .reduce(Money.ZERO, Money::add);
    }

    public void confirm() {
        if (this.status != OrderStatus.PENDING) {
            throw new InvalidOrderStateException(this.id, this.status, OrderStatus.CONFIRMED);
        }
        this.status = OrderStatus.CONFIRMED;
    }
}

The code is self‑documenting. A developer reading this constructor immediately understands the invariants.

Typed exceptions: a production asset

The problem with generic exceptions

In production, you receive this stack trace:

java.lang.IllegalArgumentException: Validation failed
    at com.example.Order.<init>(Order.java:23)
    at com.example.OrderService.createOrder(OrderService.java:45)

Unanswered questions:

  • Which field failed?

  • What was the value?

  • What was the expected constraint?

The pure-assert solution

With pure-assert, you get:

io.github.sympol.pure.asserts.NumberValueTooLowException: 
    Value of field "quantity" is -5 but must be at least 1
    at com.example.OrderLine.<init>(OrderLine.java:12)
    ...

Each exception exposes:

try {
    new OrderLine(productId, quantity, unitPrice);
} catch (NumberValueTooLowException e) {
    log.error("Validation failed: field={}, value={}, min={}", 
              e.field(),           // "quantity"
              e.parameters().get("value"),    // "-5"
              e.parameters().get("min"));     // "1"

    // Prometheus metrics
    validationErrors.labels(e.type().name(), e.field()).inc();
}

Observability and monitoring

Typed exceptions let you build precise dashboards:

// In a global ExceptionHandler
@ExceptionHandler(AssertionException.class)
public ResponseEntity<ProblemDetail> handleAssertion(AssertionException e) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problem.setType(URI.create("urn:problem:" + e.type().name().toLowerCase()));
    problem.setTitle(e.type().name());
    problem.setDetail(e.getMessage());
    problem.setProperty("field", e.field());
    problem.setProperty("parameters", e.parameters());

    return ResponseEntity.badRequest().body(problem);
}

RFC 7807 result:

{
  "type": "urn:problem:number_value_too_low",
  "title": "NUMBER_VALUE_TOO_LOW",
  "status": 400,
  "detail": "Value of field \"quantity\" is -5 but must be at least 1",
  "field": "quantity",
  "parameters": {
    "value": "-5",
    "min": "1"
  }
}

Not over‑engineering, but intentional engineering

Some will say that creating typed exceptions is over‑engineering. I respond:

1. The cost is moved and mutualized — Typed exceptions do have a cost: more classes, a larger API surface, and design discipline. However, this cost is paid once at the library level and then reused everywhere, instead of being paid diffusely in the form of incomplete logs, manual debugging, and implicit conventions.

2. The gain is real — In production, you save hours of debugging.

3. The intention is clear — Your code expresses exactly what can fail.

An IllegalArgumentException says “something failed.” A StringTooShortException says “the field firstName is too short: 1 character instead of the minimum 2.” That’s the difference between a symptom and a diagnosis.

Integration with the ecosystem

Clean Architecture: the domain stays pure

src/
├── domain/           # Zero external dependency (except pure-assert)
│   ├── model/
│   │   ├── Order.java
│   │   └── OrderLine.java
│   └── valueobject/
│       ├── OrderId.java
│       └── Money.java
├── application/      # Use cases
│   └── CreateOrderUseCase.java
└── infrastructure/   # Frameworks (Spring, Jakarta, etc.)
    └── rest/
        └── OrderController.java

Enforcing purity with Maven: guaranteeing domain purity

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-enforcer-plugin</artifactId>
    <executions>
        <execution>
            <id>enforce-domain-purity</id>
            <goals><goal>enforce</goal></goals>
            <configuration>
                <rules>
                    <bannedDependencies>
                        <excludes>
                            <exclude>*</exclude>
                        </excludes>
                        <includes>
                            <include>*:*:*:*:test</include>
                            <include>io.github.sympol:pure-assert</include>
                        </includes>
                    </bannedDependencies>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

Conclusion

DDD and Clean Architecture promise an isolated and expressive domain. But traditional tools — Jakarta annotations, Guava utilities — betray this promise by introducing technical dependencies into the heart of your application.

pure-assert proposes a coherent alternative:

AspectJakarta ValidationGuavapure-assert
DependenciesHeavy framework3 MB transitiveZero
ValidationDeferredImmediateImmediate
ExceptionsConstraintViolationGenericTyped
MetadataYes (complex)NoYes (simple)
Pure DomainNoNoYes

In summary:

  • Reclaim control over your invariants with a fluent, expressive API

  • Keep your domain pure without any technical dependencies

  • Make debugging easier with rich, typed exceptions

  • Improve observability thanks to structured metadata

The domain is the heart of your application. Protect it.

References and further reading

On hexagonal architecture and decoupling the domain

"Business code should never depend on technical code. It is the technical code that adapts to the business."

Julien Topçu, Decoupling your Technical Code from your Business Logic with the Hexagonal Architecture

Julien Topçu argues in his talks and articles that dependencies should always point inward (toward the domain). No technical framework import should appear in the business core. Jakarta Validation annotations violate this fundamental principle by introducing an infrastructure dependency into the heart of the domain.

On the “Always Valid” paradigm

"A Value Object should always be in a valid state; it should not be possible to create one that is invalid."

Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)

The “Always Valid” principle is central to DDD. An object must be valid as soon as it is constructed — or it shouldn’t exist at all. Jakarta Validation’s deferred validation allows invalid objects to exist, which contradicts this principle.

On Clean Architecture

"The center of your application is not the database. Nor is it one or more of the frameworks you may be using. The center of your application is the use cases of your application."

Robert C. Martin, Clean Architecture: A Craftsman’s Guide to Software Structure and Design (2017)

Uncle Bob insists that the domain must be independent of frameworks. A library like pure-assert, without transitive dependencies, respects this principle. Guava and Jakarta, with their heavier dependencies, violate it.

On explicit business exceptions

"Exceptions should be exceptional, but when they occur, they should tell you exactly what went wrong."

Joshua Bloch, Effective Java (3rd Edition, Items 72–77)

Joshua Bloch recommends specific exceptions rather than generic ones. NumberValueTooLowException is more informative than IllegalArgumentException. Debugging in production becomes significantly easier.

Additional resources

Julien Topçu: Hexagonal Architecture Universities

Alistair Cockburn: Hexagonal Architecture (Ports & Adapters)

Martin Fowler: Patterns of Enterprise Application Architecture

Pure-assert

Maven Central: io.github.sympol:pure-assert:1.0.0

GitHub: github.com/sympol/pure-assert