Architecting Validation Logic: A Java/Spring Boot Implementation in Clean Architecture

In the process of developing and implementing a new service/API, sometimes the question arises regarding where to place the validation logic. I will attempt to provide a perspective on its placement and provide an example of its implementation in Clean Architecture using Java/Spring Boot, utilizing Exceptions (`jakarta`) and Non-Exceptions (`vavr`).

Serghei Motpan
15 min readJul 10, 2023

Table of content

  • Validation in context
  • Test Driven Development (Contract-First approach)
  • Organising Code
  • Implementing a Use Case in Spring Boot
  • Conclusions

This article covers various topics, but it will not delve extensively into subjects such as architecture, spring-boot exception handling, optimal library usage, TDD, or Contract First Testing. It assumes that you already have some knowledge about these topics or can refer to the resources provided at the end of the article for further information. The main focus of the article will be on validations, specifically addressing them within the context of Clean Architecture and Hexagonal Architecture.

Validation in context

The concept of “validation” is broad and ambiguous as it carries different interpretations depending on the context in which it is used. However, there is a common misconception that validation only serves a singular purpose. In reality, validation plays a role in various aspects. Similar to Clean Architecture, where responsibilities are divided into layers, each layer possesses its own validation logic.

Within each layer, it is imperative for the system to reject inputs that violate the responsibilities of that particular layer. This rejection process is what we refer to as validation. Hence, the meaning of validation varies depending on the specific context in which it is employed.

It is crucial to note that in Clean Architecture, a given layer should not incorporate validation logic that falls outside the scope of its responsibilities.

Test Driven Development (Contract-First approach)

Before diving into code organization and implementation, the task will be approached within the context of practices such as Test-Driven Development (TDD).

Testing the code is usually crucial for maintaining and ensuring the quality of the developed product. To effectively utilize this technique, you should define the contract between the business and our code. This contract could be your REST API or your application services (UseCase/Ports). Based on this decision, the focus of your test cases will be determined. If you choose the REST API as your contract, it means you need to have tests at this level. On the other hand, if you select the application services as your contract, then your tests will be focused on the application layer. Typically, services are chosen as the contract first because implementing unit tests for them is more cost-effective. You should strive to follow this approach as much as possible.

The hierarchy of traditional tests

According to the testing pyramid, you should focus on lower-level tests and gradually move up with fewer tests.

For more details about Contract-First Testing, you can watch Ian Cooper’s presentation.

More details about testing pyramid can be read on. Google’s SRE Books

Organizing Code

Choosing the right architectural style is not an easy task. It will define the structure of your packages/modules/services and create a “ubiquitous language” for developers to easily recognize the architecture just by looking at the code. The architectural pattern you choose also impacts validations, as it should define the responsibility of each boundary to apply its validations.

Furthermore, the decision you make regarding your contract (as mentioned in the previous paragraph) with business requirements also influences the responsibility of each boundary.

In the following sections, you will explore different ways of organizing the package structure and determining the appropriate location for the validation logic.

Layered

An approach to organizing your code is by layer. You might organize the code like this:

Layered structure

This is the simplest way of organizing packages, with each package defined by responsibility. However, it may not be the best approach for organizing complex projects. Nevertheless, it is commonly used for small projects, and validation should be applied at each layer.

By Domain/Feature

Another approach to organizing your code is by layer. You might organize the code like this:

Modulith structure

It’s a pattern provided by the Spring project, called Modulith. In this structure, the contract is considered a service, so input and business validations should be implemented at the service level.

Hexagon

And, another approach to organizing your code is by layer. You might organize the code like this:

Hexagonal structure

This way of organizing follows an expressive package structure, where each element of the architecture can be directly mapped to one of the packages.

In a hexagonal architecture, you have entities, use cases, incoming and outgoing ports, and incoming and outgoing adapters (also known as ‘driving’ and ‘driven’ adapters) as your main architectural elements. This architectural pattern is typically used for complex projects to define clear and expressive boundaries.

In this code organization approach, within each layer, the system should reject inputs that violate the responsibilities of that layer.

Validation in the Interface Adapter layer

The responsibility of this layer is to convert data from one format to another. For example, it handles the marshaling of the request body JSON into a Java object. If the JSON is malformed (status code 400), the system cannot proceed with the data conversion, so it will throw a failure.

In this layer, validation is performed to ensure data serialization/deserialization protocols are followed.

Validation in the Application layer

In this layer, you must ensure that domain objects can properly receive the input. This involves schema validation as well as business validation. The system should reject any input that the domain object cannot handle.

If a field is mandatory and is absent, the use case should return a failure or throw an exception, depending on the chosen implementation approach. In the implementation of the use case, both approaches will be considered.

Validation in the Domain

In this layer, validation is equivalent to a domain rule.

Mapping between layers/boundaries

Defining the mapping strategy between layers is crucial in order to establish the input model where validation rules should be applied.

The initial strategy involves no mapping whatsoever.

Within the web layer, the web controller invokes the CreateAlbumUseCase interface to execute the relevant use case. This interface requires an Album object as an argument. Consequently, both the web and application layers require access to the Album class, indicating that they both utilize the same model.

No Mapping Strategy

On the other hand, the mapping strategy of having a “full” connection between layers involves employing distinct input and output models for each operation. Instead of relying on the domain model for communication between layers, it is used operation-specific models such as the CreateAlbumCommand. In the provided diagram, this command serves as an input model for the CreaAlbumUseCase port. These operation-specific models can be referred to as “commands,” “requests,” or similar terms.

Full Mapping Strategy

There are various mapping strategies available, each with its own advantages and disadvantages. However, for this specific use case, it‘ll be used the ‘full’ mapping strategy.

Implementing a Use Case in Spring Boot

REST API Blueprints

You will build an API that provides access to a store selling vintage vinyl recordings. Therefore, you need to create endpoints that allow clients to retrieve and add albums for users.

When developing an API, it is customary to start by designing the endpoints. Making the endpoints easy to comprehend will enhance the success of your API’s users.

Below is the endpoint you will create in this demo:

/albums

  • POST – Add a new album from request data sent as JSON (Content-Type).

Request JSON:

{
"title": "Sarah Vaughan and Clifford Brown",
"artist": "Sarah Vaughan",
"price": 39.99
}

Also, following REST API conventions, the responses for posting a new album will be:

Success — 201 Created + Location header

HTTP/1.1 201 
Location: http://localhost:8080/albums/87552bbd-77ed-45be-86aa-2bdda8cab3a5

Failure

  • 400 Bad Request (Malformated JSON request, Type mismatch request)
{
"timestamp": "2023-07-09T16:22:06.925563Z",
"status": 400,
"errorCode": "BAD_REQUEST",
"message": "Malformed JSON request"
}
  • 422 Unprocessable Entity (Request Schema validation failure)
{
"timestamp": "2023-07-09T16:22:06.925563Z",
"status": 422,
"errorCode": "UNPROCESSABLE_ENTITY",
"message": "Schema validation failure.",
"errors": [
{
"field": "title",
"message": "title cannot be blank."
},
{
"field": "artist",
"message": "artist cannot be blank."
},
{
"field": "price",
"message": "price must be greater than 0."
}
]
}
  • 500 Internal Server Error (Unexpected errors)
{
"timestamp": "2023-07-09T16:22:06.925563Z",
"status": 500,
"errorCode": "INTERNAL_SERVER_ERROR",
"message": "An unexpected error occurred"
}

For the full specification implementation, please refer to the source code.

Framework setup

Spring Boot provides significant assistance by eliminating a considerable amount of boilerplate code and facilitating automatic configuration of multiple components. It’s assumed that you have a solid understanding of the fundamental concepts of API development using these technologies.

To be able to have as a response the format defined in the previous paragraph you need to override the default message that spring-boot is building, it looks:

{
"timestamp": 1688920909,
"status": 400,
"error": "Bad Request",
"exception": "org.springframework.http.converter.HttpMessageNotReadableException",
"message": "JSON parse error: Unrecognized token ....",
"path": "/albums"
}

You can customize the default exception handling in Spring Boot by extending the ResponseEntityExceptionHandlerclass and implementing a global ExceptionHandler.

@RestControllerAdvice
@Slf4j
class ApiExceptionHandler extends ResponseEntityExceptionHandler {

@Override
protected ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
return badRequest("Malformed JSON request");
}

@Override
protected ResponseEntity handleTypeMismatch(TypeMismatchException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
return badRequest("Type mismatch request");
}

@ExceptionHandler(Throwable.class)
ResponseEntity<ApiErrorResponse> handleThrowable(Throwable throwable) {
log.error("Request handling failed", throwable);
return internalServerError("An unexpected error occurred");
}
// other handlers
}

the class ApiErrorResponse

@ToString
@Getter
public final class ApiErrorResponse {

private final Instant timestamp;
private final int status;
private final HttpStatus errorCode;
private final String message;
private final Collection<ApiError> errors;

@Builder
private ApiErrorResponse(HttpStatus httpStatus, String message, Collection<ApiError> errors) {
this.timestamp = Instant.now();
this.status = httpStatus.value();
this.errorCode = httpStatus;
this.message = message;
this.errors = isNull(errors) ? emptyList() : errors;
}

// other methods
}

also, add some additional configurations in theapplication.yml

spring:
autoconfigure:
exclude: org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration
mvc:
throw-exception-if-no-handler-found: true
jackson:
default-property-inclusion: non_empty
web:
resources:
add-mappings: false

Adapter Level validations — Exception-based

As mentioned in the paragraph about validation in context, the web adapter is responsible for performing serialization and deserialization. Failures such as malformed JSON requests and type mismatch requests are handled by the framework (Spring Boot). This layer is responsible for invoking the application layer and use cases, handling the response (either success or failure), and mapping the result.

The exception-based approach means that use cases throw an exception in case of any validation failures, and the adapter layer is responsible for catching and handling the exception.

The Controller for handling AlbumResource looks:

@RequiredArgsConstructor
@RequestMapping(value = "/albums")
final class AlbumController {

private final CreateAlbumUseCase createAlbumUseCase;

@PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
ResponseEntity<?> create(@RequestBody AlbumResource resource) {
var command = CreateAlbumCommand.builder()
.title(resource.title())
.artist(resource.artist())
.price(resource.price())
.build(); // validations are applied on build

var albumId = createAlbumUseCase.add(command);

return created(fromCurrentRequest().path("/{id}").build(albumId)).build();
}
}

As you can see, in the case of a successful response, the controller receives the albumId and returns it as a location header. However, in the event of a validation failure, you need to handle it differently. One approach is to define a @RestControllerAdvice, either at the controller level (locally) or at the global level, to handle these exceptions.

Local:

@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/albums")
final class AlbumController {

private final CreateAlbumUseCase createAlbumUseCase;

@PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
ResponseEntity<?> create(@RequestBody AlbumResource resource) {
var command = CreateAlbumCommand.builder()
.title(resource.title())
.artist(resource.artist())
.price(resource.price())
.build(); // validations are applied on build

var albumId = createAlbumUseCase.add(command);

return created(fromCurrentRequest().path("/{id}").build(albumId)).build();
}

@ExceptionHandler(value = ConstraintViolationException.class)
ResponseEntity<?> handle(ConstraintViolationException ex) {
var apiErrors = ex.getConstraintViolations().stream()
.map(c -> fieldApiError(c.getPropertyPath().toString(), c.getMessage(), c.getInvalidValue()))
.toList();
return unprocessableEntity(apiErrors, "Schema validation failure.");
}
}

Globally, you should define a separate class that will be responsible for handling it:

@RestControllerAdvice
class AlbumControllerAdvice {

@ExceptionHandler(value = ConstraintViolationException.class)
ResponseEntity<?> handle(ConstraintViolationException ex) {
var apiErrors = ex.getConstraintViolations().stream()
.map(c -> fieldApiError(c.getPropertyPath().toString(), c.getMessage(), c.getInvalidValue()))
.toList();
return unprocessableEntity(apiErrors, "Schema validation failure.");
}
}

As it’s selected the use case as a contract, and as part of integration tests, it will cover both a happy path and an unhappy path:

@Tag("integration")
@SpringBootTest(webEnvironment = RANDOM_PORT)
class AlbumControllerTest {

@Autowired
TestRestTemplate restTemplate;

@Test
@DisplayName("Should create a new album successfully")
void createNewAlbum() {
// given
var request = newValidRequest();

// when
var resp = postNewAlbum(request);

// then
assertThat(resp.getStatusCode())
.isEqualTo(CREATED);

// and
assertThat(resp.getHeaders().getLocation())
.isNotNull();
}

@Test
@DisplayName("Should fail album creation when request has validation failures")
void failCreationWhenRequestHasValidationFailures() throws JSONException {
// given
var request = newInvalidRequest();

// when
var resp = postNewAlbum(request);

// then
assertThat(resp.getStatusCode())
.isEqualTo(UNPROCESSABLE_ENTITY);

// and
JSONAssert.assertEquals(expectedValidationFailure(), resp.getBody(), LENIENT);
}
// other methods
}

Adapter Level validations — Non-Exception-based

As described earlier, the responsibility of the web adapter is to use a non-exception-based approach. Therefore, it’s assumed that when invoking a use case, it will either return a successful result or a failure. The same controller is used to handle the AlbumResource, as follows:

@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/albums")
class AlbumController {

private final CreateAlbumUseCase createAlbumUseCase;
private final ApiFailureHandler apiFailureHandler;

@PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
ResponseEntity<?> create(@RequestBody AlbumResource resource) {
var command = CreateAlbumCommand.builder()
.title(resource.title())
.artist(resource.artist())
.price(resource.price())
.build();

return createAlbumUseCase.create(command)
.fold(apiFailureHandler::handle, albumId -> created(fromCurrentRequest().path("/{id}").build(albumId)).build());
}
}

As you can see, the controller in the use case invocation handles a response that includes a reference to either a successful or failed response. In the case of success, the header location is added. However, in the case of failure, it is managed by the ApiFailureHandler class.

@Component
class ApiFailureHandler {

private final ValidationFailureToApiMapper apiMapper = new ValidationFailureToApiMapper();

ResponseEntity<?> handle(Failure failure) {
return switch (failure) {
case NotFoundFailure notFoundFailure -> notFound(notFoundFailure.message());
case ConflictFailure conflictFailure -> conflict(conflictFailure.message());
case ValidationFailure validFailure -> unprocessableEntity(apiMapper.map(failure.fieldViolations()), validFailure.message());
default -> throw new IllegalArgumentException("Unsupported failure type");
};
}

private static class ValidationFailureToApiMapper {

private Collection<ApiError> map(Collection<FieldViolation> violations) {
return violations.stream()
.map(violation -> fieldApiError(violation.field(), violation.message(), violation.rejValue()))
.toList();
}
}
}

And integration test is the same as shown in the previous approach.

Application Level validations — Exception-based

In Java, it is a widely adopted practice to handle validation failures using Exceptions. This approach is the default method employed by Spring Boot, offering numerous built-in mechanisms. Whenever something deviates from the expected rules, a custom exception is thrown and handled to ensure proper request handling.

To meet the validation requirements, the jakarta validation framework will be employed. To create a reusable component, an abstract class is defined, which encapsulates the validator’s logic.

public abstract class SelfValidating<T> {

private final Validator validator;

public SelfValidating() {
var factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}

protected void validateSelf() {
Set<ConstraintViolation<T>> violations = validator.validate((T) this);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}

and the command looks:

public interface CreateAlbumUseCase {

UUID add(CreateAlbumCommand command) throws ResourceDuplicateException;

@Getter
class CreateAlbumCommand extends SelfValidating<CreateAlbumCommand> {

@NotBlank(message = "title cannot be blank.")
private final String title;

@NotBlank(message = "artist cannot be blank.")
private final String artist;

@NotNull(message = "price cannot be null.")
@Positive(message = "price must be greater than 0.")
private final BigDecimal price;

@Builder
public CreateAlbumCommand(String title, String artist, BigDecimal price) {
this.title = title;
this.artist = artist;
this.price = price;
this.validateSelf();
}
}
}

And the use case implementation looks:

@Service
class CreateAlbumService implements CreateAlbumUseCase {

@Override
public UUID create(CreateAlbumCommand command)
throws ResourceDuplicateException {
// TODO: validate business rules
// TODO: manipulate domain state
// TODO: return output
}
}

As you can see the command is validated on creation, so the service gets a valid command, the complete implementation looks:

@Service
class CreateAlbumService implements CreateAlbumUseCase {

@Override
public UUID add(CreateAlbumCommand command) {
// TODO: validate business rules
// requireUniqueTitle(command.title());

// TODO: manipulate model state
var album = Album.builder()
.id(UUID.randomUUID())
.title(command.getTitle())
.artist(command.getArtist())
.price(command.getPrice())
.build();


// TODO: return output
return album.id();
}
}

And test class looks:

@Tag("unit")
class CreateAlbumServiceTest {

CreateAlbumService service;

@BeforeEach
void setUp() {
service = new CreateAlbumService();
}

@Test
@DisplayName("Should create a new album successfully")
void createNewAlbum() {
// when
var albumId = service.add(validAlbum().build());

// then
assertThat(albumId).isNotNull();
assertThat(albumId).isInstanceOf(UUID.class);
}

@ParameterizedTest
@ValueSource(strings = {"", " "})
@NullSource
@DisplayName("Should fail album creation when 'title' is blank")
void failWhenTitleIsBlank(String title) {
// when
var thrown = catchThrowable(() -> validAlbum().title(title).build());

// then
assertThat(thrown)
.isInstanceOf(ConstraintViolationException.class)
.hasMessageContaining("title: title cannot be blank.");
}
// other tests and methods
}

You may notice that most of the input validations are applied during command creation. However, in the next section, you will see a different approach.

Application Level validations — Non-Exception-based

Another approach to handling validation failures, is to ask “What is an exception?”, in languages like Golang an exception is something it cannot be controlled, it means the use case should be implemented in such a way that it should return output with (ok, nok), since Java cannot return multiple values as return, you need to wrap them in one reference. To handle it properly we need a library that offers such features, vavr is a library with some interesting features that allow us to handle it in a proper way.

The same use case definition looks:

public interface CreateAlbumUseCase {

Either<Failure, UUID> create(CreateAlbumCommand command);
}

Either represents a value of two possible types. An Either is either a Either.Left or a Either.Right. The use case will contain, in case of successful operation right value, failure left value.

Command representation will be the same as previous, but without jakarta annotations, the validation is going to be done as part of UseCase implementation. The complete definition of the use case looks:

public interface CreateAlbumUseCase {

Either<Failure, UUID> create(CreateAlbumCommand command);

@Builder
record CreateAlbumCommand(String title,
String artist,
BigDecimal price) {

public static final String FIELD_TITLE = "title";
public static final String FIELD_ARTIST = "artist";
public static final String FIELD_PRICE = "price";
}
}

The CreateAlbumUseCase looks this way, and it should implement the same behavior as in the exception-based implementation

@Service
class CreateAlbumService implements CreateAlbumUseCase {

@Override
public Either<Failure, UUID> create(CreateAlbumCommand command) {
// TODO: validate input schema
// TODO: validate business rules
// TODO: manipulate domain state
// TODO: return output
}
}

The representation of a Failure class looks:

public interface Failure {

String message();

Collection<FieldViolation> fieldViolations();

record ValidationFailure(String message, Collection<FieldViolation> fieldViolations) implements Failure {}

record NotFoundFailure(String message) implements Failure {}

record ConflictFailure(String message) implements Failure {}

record FieldViolation(String field, String message, Object rejValue) {}
}

as you can see it is an interface that has a few implementations, ValidationFailure, NotFoundFailure, and ConflictFailure, they are pretty descriptive and they should be used accordingly.

As a first step, the implementation of the input validation schema is required. To accomplish that, some vavr features for validation are going to be used, which provide Validation<E, V> — an interface that allows validations to be applied on different fields and combine them.

The command validator looks:

class CreateAlbumCommandValidator {

Validation<Seq<FieldViolation>, CreateAlbumCommand> validate(CreateAlbumCommand command) {
return Validation.combine(
validateTitle(command.title()),
validateArtist(command.artist()),
validatePrice(command.price())
).ap((title, artist, price) -> command);
}

private Validation<FieldViolation, String> validateTitle(String title) {
if (StringUtils.isBlank(title))
return Invalid(FieldViolation.builder()
.field(CreateAlbumCommand.FIELD_TITLE)
.message("title cannot be blank.")
.rejValue(title)
.build());

return Valid(title);
}
// other validate methods
}

As you can see the Command validator implementations validate each field and collect all failures in a Validation object.

The complete implementation looks:

@Service
class CreateAlbumService implements CreateAlbumUseCase {

static final String VALIDATION_FAILURE_MESSAGE = "Schema validation failure.";

private final CreateAlbumCommandValidator validator = new CreateAlbumCommandValidator();

@Override
public Either<Failure, UUID> create(CreateAlbumCommand command) {
// TODO: validate input schema
var validation = validator.validate(command);
if (validation.isInvalid())
return Either.left(validatonFailure(VALIDATION_FAILURE_MESSAGE, validation.getError().toJavaList()));

// TODO: validate business rules
// if (!uniqueTitle(command.title()))
// return Either.left(conflictFailure("title '%s' already exists.".formatted(command.title())));

// TODO: manipulate domain state
var album = Album.builder().build();

// TODO: return output
return Either.right(album.id());
}


private static final class CreateAlbumCommandValidator {
// validator implementation
}
}

The validator is defined as an inner class and is initialized inside the use case service, following the simple principle of composition, which means is part of the use case, and it should be tested as part of the use case as well.

And test class looks:

@Tag("unit")
class CreateAlbumServiceTest {

CreateAlbumService service;

@BeforeEach
void setUp() {
service = new CreateAlbumService();
}

@Test
@DisplayName("Should create a new album successfully")
void createNewAlbum() {
// given
var command = validAlbum().build();

// when
var either = service.create(command);

// then
VavrAssertions.assertThat(either)
.isRight()
.containsRightInstanceOf(UUID.class);
}

@ParameterizedTest
@ValueSource(strings = {"", " "})
@NullSource
@DisplayName("Should fail album creation when 'title' is blank")
void failWhenTitleIsBlank(String title) {
// given
var command = validAlbum().title(title).build();

// when
var either = service.create(command);

// then
VavrAssertions.assertThat(either)
.isLeft()
.containsOnLeft(failure(new FieldViolation(FIELD_TITLE, "title cannot be blank.", title)));
}
// other tests and methods
}

for asserting eithervalues there isassertj-vavr implementation.

Conclusions

In conclusion, validation logic is naturally derived within each layer’s responsibility. As a common best practice in software design, we should follow a basic principle known as Single Responsibility Principle (SRP). As part of the provided sample, we approached the use case implementation using both an exception-based and non-exception-based approach, each with its own pros and cons.

In the exception-based approach, one advantage is its common usage and wide adoption in Java-based applications. Additionally, Spring Boot provides existing mechanisms to handle exceptions with out-of-the-box solutions. However, a drawback is that it doesn’t fully adhere to the SRP principle since failures are handled outside of the use case.

On the other hand, the non-exception-based approach has the advantage of fully following the SRP principle. It allows for complete implementations without relying on global handlers, which can create issues during testing and system evolution. Nevertheless, a disadvantage is that it requires more custom implementations, which may not always be the optimal approach.

See full source code on Github.

Resources

--

--