Ditch Hibernate Validator: Enforce Domain Invariants with Java 21 Record Patterns and Guard Clauses
If you are still pulling in Hibernate Validator or reflection-heavy rule engines like Drools just to validate complex domain invariants, you are burning CPU cycles for no good reason. Native Java 21 pattern matching gives you sub-nanosecond, compiler-enforced invariant checks without a single annotation or reflection call.
Why Most Developers Get This Wrong
-
Framework addiction: Relying on
@AssertTrueor custom JSR-380 annotations that force runtime reflection and scatter domain logic across field-level metadata. - Over-engineering: Building custom, stateful rule engine pipelines that obscure core business logic behind dynamic invocation wrappers.
-
Ignoring compile-time safety: Skipping exhaustiveness checks, which leaves your code vulnerable to unhandled edge cases or runtime
ClassCastExceptionfailures in production.
The Right Way
Model domain states as sealed hierarchies and record trees, then evaluate cross-field invariants instantly using guarded switch expressions.
- Use Nested Record Patterns to deconstruct deep payload trees in a single, readable line.
- Leverage Guard Clauses (
when) to evaluate strict boolean invariants directly on deconstructed values before executing logic. - Rely on Sealed Interfaces so the Java compiler forces exhaustive handling of every domain state without relying on unsafe
defaultfallbacks. - Keep business rules purely functional, deterministic, and entirely free from framework magic.
Shameless plug: javalld.com has full LLD implementations with step-by-step execution traces — free to use while prepping.
Show Me The Code (or Example)
public static ValidationResult validatePlacement(OrderPlacement placement) {
return switch (placement) {
case OrderPlacement(Customer(var status, _), Order(var total, _))
when status == AccountStatus.FROZEN -> ValidationResult.reject("Account frozen");
case OrderPlacement(Customer(_, var limit), Order(var total, PaymentMethod.CREDIT))
when total > limit -> ValidationResult.reject("Credit limit exceeded");
case OrderPlacement(_, Order(var total, _))
when total <= 0 -> ValidationResult.reject("Invalid order total");
case OrderPlacement -> ValidationResult.approve();
};
}
Key Takeaways
- Zero Reflection Overhead: Native switch pattern matching executes at sub-nanosecond speeds compared to JSR-380 introspection.
- Compile-Time Exhaustiveness: Combining sealed types with record patterns ensures every edge case is handled at compile time.
- Lean Architecture: Eliminating external validation frameworks drastically reduces your memory footprint and startup times.
Top comments (0)