Creating custom validation annotations in Spring Boot applications

Every backend system eventually meets a business rule that off-the-shelf annotations cannot capture. Whether you are processing payroll data for a Sydney-based fintech, handling TFN submissions for a Melbourne accounting platform, or validating AEST timestamps in a Brisbane logistics service, the ability to express domain-specific checks declaratively is invaluable.

Spring Boot builds on Jakarta Bean Validation, giving developers a consistent way to enforce rules across REST controllers, service layers, and persistence boundaries. The framework reads constraint annotations at request-binding time and surfaces violations through MethodArgumentNotValidException or ConstraintViolationException, which can then be mapped to clean error payloads.

While libraries such as Hibernate Validator ship with sensible defaults like @NotBlank, @Size, and @Email, real applications frequently require more nuanced checks. Tax identifiers, region-specific phone formats, and business-level consistency between fields all push engineers toward tailor-made constraints.

This walkthrough demonstrates how to design, implement, and apply a custom annotation, finishing with patterns for localisation, composition, and testing that scale across distributed teams.

Foundations of bean validation in Spring Boot

Jakarta Bean Validation, formerly part of JSR-380, defines a metadata model and an API for declaring constraints on Java beans. Spring Boot autoconfigures Hibernate Validator as the default provider whenever spring-boot-starter-validation sits on the classpath, removing the need for explicit bean declarations. A typical controller signature using @Valid triggers the cascade without additional wiring.

The framework supports two main styles of constraint application: field-level annotations on DTO properties, and class-level annotations used for cross-field validation. Choosing between them depends on whether the rule targets a single attribute or needs access to the entire object graph. For instance, checking that an Australian Business Number string contains exactly eleven digits belongs on a field, whereas comparing two date fields requires the broader context.

Built-in messages can be overridden through resource bundles, which becomes important when systems must speak both developer-facing English and end-user-facing Australian English. The standard ValidationMessages.properties file under src/main/resources accepts locale-specific overrides, and Spring picks them up automatically when configured.

Scenarios that justify a custom constraint in production systems:

Declaring the annotation interface

Custom constraints begin as regular Java annotations decorated with meta-constraints. The @Constraint meta-annotation references a validator class, while @Target and @Retention control where the marker can be placed and when it remains available at runtime. A common pattern is to allow the annotation on fields, methods, and annotation uses, with RUNTIME retention so reflection can find it.

Parameterising an annotation with String message(), Class<?>[] groups(), and Class<? extends Payload>[] payload() mirrors the contract used by built-in constraints. Doing so ensures the new marker integrates smoothly with the validation pipeline, including group sequencing and payload metadata for advanced scenarios such as audit logging.

A practical case is an ABN validator. The Australian Taxation Office issues eleven-digit identifiers with a weighted checksum, and many backend services receiving ABN strings need to reject malformed input early. Declaring @ValidABN with the standard three properties keeps the API familiar and lets existing exception handlers behave consistently.

Implementing the ConstraintValidator

The validator class implements ConstraintValidator<A, T>, where A is the annotation type and T is the value type being checked. The initialize method receives the annotation instance when needed, while isValid performs the actual check, returning true when the value satisfies the rule.

For the ABN example, the validator should first ensure the string is not null and contains the right number of digits, then compute the modulo-89 checksum mandated by the ATO specification. Returning false from isValid causes Hibernate Validator to record a violation, which Spring surfaces through BindingResult or the global exception handler.

Stateless validators are the safest default because Spring Boot may instantiate them once and reuse them across requests. Storing request-specific state in fields leads to concurrency issues under load, particularly in high-throughput APIs that exchange thousands of payloads per minute.

Wiring validation into controllers and services

Applying the new annotation is identical to using any built-in marker. Placing @ValidABN on a DTO field, then annotating the controller parameter with @Valid, triggers validation automatically. When validation fails, Spring throws MethodArgumentNotValidException, which a @ControllerAdvice class can transform into a structured 400 Bad Request response.

Service-layer enforcement matters too. Methods accepting unvalidated entities should call Validator.validate() on the injected jakarta.validation.Validator before persisting data, defending against bypassed controller paths. In multi-module systems, repeating validation at the service boundary prevents internal callers from accidentally introducing dirty objects.

For Australian applications handling phone numbers, postcodes, or TFNs, the same approach applies. Defining distinct annotations such as @ValidAUPhone, @ValidPostcode, or @ValidTFN keeps each rule focused, and grouping them under a class-level constraint lets composite business checks run in a single pass.

Composing constraints and handling localisation

Bean Validation supports constraint composition, where one annotation acts as a wrapper around several others. Marking an annotation with @Constraint(validatedBy = {}) and adding @ReportMinusComponents lets a single marker enforce multiple rules in sequence, producing layered error messages rather than a single combined string.

Localisation is straightforward once the messages live in ValidationMessages.properties and its locale-specific variants such as ValidationMessages_en_AU.properties. This separation allows English-speaking teams to maintain technical defaults while product owners supply polished phrasing for end users.

When business rules evolve, changing the underlying validators rather than the annotation surface keeps DTOs stable. Teams can also expose group sequences through @GroupSequence to enforce a deterministic order, useful when later validations depend on earlier ones succeeding.

Verifying constraints in practice

Testing custom constraints benefits from both unit and integration coverage. Unit tests instantiate the validator directly with sample inputs, asserting that valid ABNs pass and invalid ones trigger ConstraintValidatorContext violations. Mocking the context keeps the test focused on logic rather than framework plumbing.

Integration tests use @WebMvcTest or @SpringBootTest to confirm the annotation works end-to-end. Posting JSON payloads with deliberately malformed fields, then asserting the response status and error structure, exercises the full pipeline from HTTP binding through to the @ControllerAdvice handler.

Test cases worth covering when rolling out a new constraint: