The key question now is how to validate the invariants while constructing new Car instances. This is where Bean Validation’s API for method validation comes in: it allows to validate pre- and post-conditions that should be satisfied when a Java method or constructor gets invoked. Pre-conditions are expressed by applying constraints to method and constructor parameters, whereas post-conditions are expressed by putting constraints to a method or constructor itself.

This can be leveraged for enforcing record invariants: as it turns out, any annotations on the components of a record type are also copied to the corresponding parameters of the generated constructor. I.e. the Car record implicitly has a constructor which looks like this:

1 2 3 4 5 6 7 8 9 public Car ( @NotBlank String manufacturer , @NotNull @Size ( min = 2 , max = 14 ) String licensePlate , @Min ( 2 ) int seatCount ) { this . manufacturer = manufacturer ; this . licensePlate = licensePlate ; this . seatCount = seatCount ; }

That’s exactly what we need: by validating these parameter constraints upon instantiation of the Car class, we can make sure that only valid objects can ever be created, ensuring that the record type’s invariants are always guaranteed.

What’s missing is a way for automatically validating them upon constructor invocation. The idea for that is to enhance the byte code of the implicit Car constructor so that it passes the incoming parameter values to Bean Validation’s ExecutableValidator#validateConstructorParameters() method and raises a constraint violation exception in case of any invalid parameter values.

We’re going to use the excellent ByteBuddy library for this job. Here’s a slightly simplified implementation for invoking the executable validator (you can find the complete source code of this example in this GitHub repository):

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 public class ValidationInterceptor { private static final Validator validator = Validation (1) . buildDefaultValidatorFactory () . getValidator (); public static < T > void validate ( @Origin Constructor < T > constructor , @AllArguments Object [] args ) { (2) Set < ConstraintViolation < T >> violations = validator (3) . forExecutables () . validateConstructorParameters ( constructor , args ); if (! violations . isEmpty ()) { String message = violations . stream () (4) . sorted ( ValidationInterceptor: : compare ) . map ( cv -> getParameterName ( cv ) + " - " + cv . getMessage ()) . collect ( Collectors . joining ( System . lineSeparator ())); throw new ConstraintViolationException ( (5) "Invalid instantiation of record type " + constructor . getDeclaringClass (). getSimpleName () + System . lineSeparator () + message , violations ); } } private static int compare ( ConstraintViolation <?> o1 , ConstraintViolation <?> o2 ) { return Integer . compare ( getParameterIndex ( o1 ), getParameterIndex ( o2 )); } private static String getParameterName ( ConstraintViolation <?> cv ) { // traverse property path to extract parameter name } private static int getParameterIndex ( ConstraintViolation <?> cv ) { // traverse property path to extract parameter index } }

1 Obtain a Bean Validation Validator instance 2 The @Origin and @AllArguments annotations are the hint to ByteBuddy that the invoked constructor and parameter values should be passed to this method from within the enhanced constructor 3 Validate the passed constructor arguments using Bean Validation 4 If there’s at least one violated constraint, create a message comprising all constraint violation messages, ordered by parameter index 5 Raise a ConstraintViolationException , containing the message created before as well as all the constraint violations

Having implemented the validation interceptor, the code of the record constructor must be enhanced by ByteBuddy, so that it invokes the inceptor. ByteBuddy provides different ways for doing so, e.g. at application start-up using a Java agent. For this example, we’re going to employ build-time enhancement via the ByteBuddy Maven plug-in. The enhancement logic itself is implemented in a custom net.bytebuddy.build.Plugin :

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 public class ValidationWeavingPlugin implements Plugin { @Override public boolean matches ( TypeDescription target ) { (1) return target . getDeclaredMethods () . stream () . anyMatch ( m -> m . isConstructor () && hasConstrainedParameter ( m )); } @Override public Builder <?> apply ( Builder <?> builder , TypeDescription typeDescription , ClassFileLocator classFileLocator ) { return builder . constructor ( this :: hasConstrainedParameter ) (2) . intercept ( SuperMethodCall . INSTANCE . andThen ( MethodDelegation . to ( ValidationInterceptor . class ))); } private boolean hasConstrainedParameter ( MethodDescription method ) { return method . getParameters () (3) . asDefined () . stream () . anyMatch ( p -> isConstrained ( p )); } private boolean isConstrained ( ParameterDescription . InDefinedShape parameter ) { (4) return ! parameter . getDeclaredAnnotations () . asTypeList () . filter ( hasAnnotation ( annotationType ( Constraint . class ))) . isEmpty (); } @Override public void close () throws IOException { } }

1 Determines whether a type should be enhanced or not; this is the case if there’s at least one constructor that has one more more constrained parameters 2 Applies the actual enhancement: into each constrained constructor the call to ValidationInterceptor gets injected 3 Determines whether a method or constructor has at least one constrained parameter 4 Determines whether a parameter has at least one constraint annotation (an annotation meta-annotated with @Constraint ; for the sake of simplicity the case of constraint inheritance is ignored here)

The next step is to configure the ByteBuddy Maven plug-in in the pom.xml of the project:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <plugin> <groupId> net.bytebuddy </groupId> <artifactId> byte-buddy-maven-plugin </artifactId> <version> ${version.bytebuddy} </version> <executions> <execution> <goals> <goal> transform </goal> </goals> </execution> </executions> <configuration> <transformations> <transformation> <plugin> dev.morling.demos.recordvalidation.implementation.ValidationWeavingPlugin </plugin> </transformation> </transformations> </configuration> </plugin>

This plug-in runs in the process-classes phase by default, so it can access and enhance the class files generated during compilation. If you were to build the project now, you could use the javap tool to examine the byte code of the Car class,and you’d see that the implicit constructor of that class contains an invocation of the ValidationInterceptor#validate() method.

As an example, let’s consider the following attempt to instantiate a Car object, which violates the invariants of that record type:

1 Car invalid = new Car ( "" , "HH-AB-123" , 1 );

A constraint violation like this will be thrown immediately:

1 2 3 4 5 javax.validation.ConstraintViolationException: Invalid instantiation of record type Car manufacturer - must not be blank seatCount - must be greater than or equal to 2 at dev.morling.demos.recordvalidation.RecordValidationTest.canValidate ( RecordValidationTest.java:20 )