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?

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:
| Dependency | Type | Stability | Verdict |
jakarta.validation-api | Specification | Very stable | Debatable |
org.hibernate.validator | Implementation | Variable | Pollution |
org.springframework | Framework | Variable | Pollution |
javax.persistence / jakarta.persistence | Specification | Stable | Debatable |
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:
| Aspect | Guava Preconditions | pure-assert |
| Exception type | IllegalArgumentException | StringTooShortException, NumberValueTooLowException, etc. |
| Metadata | Text message only | field(), parameters(), type() |
| Debugging in production | “Validation failed” | Structured field, value, constraint |
| API | Static methods | Fluent, 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. Acatch (NumberValueTooLowException e)gives youe.field(),e.parameters().get("min"), ande.type().
The solution: an agnostic domain with pure-assert
Philosophy
pure-assert is built on three core principles :
Zero dependency — Pure Java, no transitive pollution
Always Valid — An object is valid as soon as it’s constructed — or it doesn’t exist
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 ? :
| Before | After |
| External annotations | Pure Java code |
| Deferred validation | Immediate validation |
IllegalArgumentException | Typed MissingMandatoryValueException |
| No context | Field 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:
| Context | Recommendation |
| REST DTO validation | Jakarta — error aggregation, i18n, OpenAPI integration |
| UI forms | Jakarta — localized messages, automatic binding |
| Batch / import validation | Jakarta — collect all errors before rejection |
| Public APIs with i18n constraints | Jakarta — 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
| Aspect | pure-assert | Jakarta Validation |
| Fail‑fast | Yes (by design) | No |
| Error aggregation | No | Yes |
| Internationalization | No | Yes |
| OpenAPI integration | No | Yes |
| Zero dependency | Yes | No |
| Pure domain | Yes | No |
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-assertcontinues to mature. If, however, expressive business invariants and rich semantic errors matter more in your day-to-day development and production debugging,pure-assertis 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:
| Aspect | Jakarta Validation | Guava | pure-assert |
| Dependencies | Heavy framework | 3 MB transitive | Zero |
| Validation | Deferred | Immediate | Immediate |
| Exceptions | ConstraintViolation | Generic | Typed |
| Metadata | Yes (complex) | No | Yes (simple) |
| Pure Domain | No | No | Yes |
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
