Clean Architecture

Part 1 - What is clean architecture, and what are the principles to effectively use it with Spring Boot

This article describes a series of techniques and architectural decisions inspired by next-gen projects that use Spring Boot and clean architecture as a backbone for their backend implementation. This is based on my personal and professional experience and complies with the original Spring Boot authors' recommended and canonical way of using the Spring Boot framework.

Principles

The technical principles that guide this document are:

  • Code maintenance and evolution - We should be able to maintain and evolve a code base over time. All developers should be able to follow sound and widespread standards. The system should be isolated between technical infrastructure decisions (database, external rest services, etc.) and the business logic domain.

  • Cloud-native approach. The backend should be designed to operate on the cloud and take advantage of its surroundings. That topic means different things and keeps evolving. We will drill down what we mean by it in practical terms.

  • Testability and quality. The backend must be testable and provide a good coverage of relevant test cases. The tests should be in line with modern spring boot test approaches.

  • Spring Boot Native. The backend should follow and take advantage of spring boot auto configuration, containerization, tests, security, integrations, and extensions in a canonical way that is compatible with the framework's evolution without workarounds. Specific company extensions should consider how they play with the framework configuration model to extend it without compromising other features and integrations.

  • I suggest following the principle of conventional overconfiguration and flexibility to override and adapt solutions. That means plugins developed by teams to use in Spring Boot can be opted in and out quickly and provide ways to be configured on specific behaviors and change (automatically or not) its configuration on different environments. That aligns with the Spring Boot Native principle and creates reusable components.

💡In the Spring Boot topic, I will explore how to combine AOP, Exception Handling, Meta Annotations, Conditional Beans, and Spring Boot Configuration Model to create powerful, reusable, clean, and sophisticated automation examples to plug-and-play in any Spring Boot application on the company, with minimal effort and flexibility in the hands of the consuming developers. An improvement on top of that is to distribute the configuration across multiple microservices; we will not cover that advanced scenario. However, we should be able to suggest ideas in the microservice architecture style that take advantage of all the code foundations mentioned in this document.

Code Maintenance and Evolution

To organize the code, we suggest the Clean Architecture structure in a single mono project by each microservice.

Clean Architecture is an architectural style created by Robert C. Martin that can be summarized in the following:

The clean architecture creates clear boundaries between business rules (stable and higher-level abstractions) and volatile technical details (lower-level abstractions). The main principle is the dependency rule: Source code dependencies must point only inward toward higher-level policies.

That means infrastructure details (databases, rest services, queues, etc..) are isolated from business logic and can be evolved, dismissed, or added in isolation. In fact, the clean code architecture should also have the following characteristics:

  • Testable

  • Independent UI Layer

  • Independent of frameworks

  • Independent of infrastructure like databases

On a high level, the structure of a spring boot project is as follows:

application
domain
infrastructure
presentation

Let's dive into each layer following a typical user request flow (from presentation to infrastructure)

The Presentation Layer

This layer will program the APIs and controllers. All external requests pass through this layer and are technology-agnostic. The most common way is through REST APIs. Still, it could be a console application, a GraphQL, GRPC, an async queue, or any other communication from the outside world to drive the application's behavior.

Talking about REST, there is an api/v0 folder inside the presentation. Each controller is versioned starting with version v0, which means it is under development and has no obligation to be stable or retrocompatible; each contract object is versioned inside the folder. When a stable version is published, it becomes v1, and another package is created, or the v0 is renamed. The v1 can evolve if it is retro-compatible with itself. If not, a v2 folder is created, and previous versions are still maintained in accordance with the team. There is no need to version at the class level in this approach.

These are the main things inside the presentation layer:

presentation
  api
    v0
      request
        CreatePersonRequest
      response
        CreatePersonResponse
      ExampleController.java
  config
    ObjectMapperConfig.java
    SwaggerConfig.java
  AppExceptionHandler.java

The clean architecture states: "Source code dependencies must point only inward toward higher-level policies." In practice, we can achieve this by isolating layers. The presentation layer depends only inward (depends on the application layer) and is isolated through the use of request and response objects. The convention is to use Js as the name prefix for requests and responses, which is optional and differentiates from domain classes when the names are very similar or equal (this is common while developing). What is not optional is the fact that these kinds of objects need to be created and transformed from/to the inwork layers.

The transformation technique is open to debate; some use mappers, some use static methods, and others use libraries to help, like the map struct library. Each team can use what it is most comfortable with. My only recommendation is to be consistent across the same microservice code base. The mapping is also used in the infrastructure layer; we will discuss it later.

The presentation layer will also have custom validation, pagination, and other API-related things.

Spring has a powerful programming model called Aspect Orienting Programming (AOP), and Spring Boot brings it to another level using the Conditional Beans feature. We could use these features to write clean and maintainable code, cleaning up many cross-cutting aspects of the application like logging, metrics, security, and auditing, and cleaning up significantly the controllers.

Exception Handling

Spring Boot has a canonical way of dealing with exceptions that is quite good.

The principle is to let the exceptions be thrown as specific runtime exceptions with the code and only catch each one in a single place. That is what the AppExceptionHandler does. Catching each one is not an accurate description, as we can catch groups and subclasses. We could also invert the logic of dependency to create a single catch and avoid having to treat each coupled with the AppExceptionHandler, making the exception treat itself (but that is just an idea open to be proved).

The UI will need specific codes to translate the messages and deal with errors. The Problems Details RFC standard could be interesting to study in other to standardize the responses from errors and validations.

The application layer

The presentation layer will get user inputs and delegate the execution to the application layer; the application layer can be considered as all application use cases. The key here is that the application layer will orchestrate the fulfillment of a flow or need of the user (be it a human or machine interacting with the application). The application layer can use many domain services and objects to fulfill its requests in a single transaction.

Speaking of transactions, this layer is a good candidate in most cases for controlling transactions, which means database transactions are initiated here.

When the controllers in the presentation layer access the application layer, they will convert the request to a domain object and pass it to it. Let's give an example:

application
   ExamplePersonUseCase.java
   ExamplePersonAdapterUseCase.java
presentation
  api
    v0
      ExamplePersonSetupController.java
      response
        PersonCreatedResponse.java
      request
        NewPersonRequest.java
@RestController
@RequiredArgsConstruct
@RequestMapping(path = "/api/v0/persons")
public class ExamplePersonSetupController {
    private final ExamplePersonUseCase examplePersonUseCase;

    @PostMapping()
    public PersonCreatedResponse createNewPerson(@Valid JsNewPersonRequest newPersonRequest) {
        return PersonCreatedResponse.of(examplePersonUseCase.createNewPerson(newPersonRequest.toDomain());
    }
}

The controller above is responsible for API-related tasks only (single responsibility principle).

The ExamplePersonUseCase is an interface with the contract between the layers:

public interface ExamplePersonUseCase {
    Person createNewPerson(Person person);
}

The ExamplePersonAdapterUseCase is an implementation. The adapter is a reference to ports and adapters. The interface is a port, and the adapter implements it. It generally has only one adapter, but it could have more, especially when dealing with different databases and persistence.

The pseudo implementation could be something like this:

@Service
@RequiredArgsConstructor
public class ExamplePersonAdaptUseCase implements ExamplePersonUseCase {
   private final ExamplePersonRepositoryService examplePersonRepositoryService;
   private final ExamplePersonRequisitsService examplePersonRequisitsService;


   @Override   
   @Transactional
   public Person createNewPerson(Person person) {
      examplePersonRequisitsService.checkIfCanBeAdded(person.getPassport());
      return examplePersonRepositoryService.createNewPerson(person);
   }
}

The Domain layer

The domain layer represents the business model. It overlaps with the data layer representation because it is common to have a representation as a persistence object (entity or table). But it is not the same. The business layer will represent the problem and can be modeled according to the system and problem. The only hard rule that Robert C. Martin talks about is:

The domain model should not directly depend on technology aspects.

That means if we need to persist or read data from a database, we should depend on an interface, not an implementation. Also we should minimize the use of external libraries in this layer.

Some tips:

  • Avoid over-normalization of the domain layer. It will make it difficult and less performant to retrieve and persist information. It can also complicate data transformation.

  • Create a rich model layer; do not use the domain layer only to carry data. You can reuse logic from the domain objects. For example, custom validation logic related to a single object can be added to the object itself.

  • Prefer immutabibility.

  • Remember: You still have the application layer to orchestrate multiple domain layer logic.

For example:

import lombok.Value;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;

@Value
public class Person {

    private static final Map<String, Integer> LEGAL_AGE_IN_COUNTRIES = Map.of(
            Locale.US.getCountry(),
            21,
            Locale.UK.getCountry(),
            21,
            "BR",
            18
    );

    private String name;
    private LocalDateTime birthDay;

    private Address address;

    private Integer getLegalAgeInCountry(Locale.IsoCountryCode countryCode) {
        return LEGAL_AGE_IN_COUNTRIES.get(countryCode) == null ? 0 : LEGAL_AGE_IN_COUNTRIES.get(countryCode);
    }

    public int ageInYears() {
        return (int) ChronoUnit.YEARS.between(birthDay, LocalDateTime.now());
    }

    public boolean canSignIn(Locale.IsoCountryCode countryCode) {
        return ageInYears() >= getLegalAgeInCountry(countryCode) && Objects.equals(address.getCountryCode(), countryCode.name());
    }
}

This simple domain layer example shows that business logic related to the Person, like verifying whether it can sign in to the system, is performed in the domain object. The object is immutable (The @Value annotation).

If the domain layer exposes or consumes information, for example, loading the person from the database, it will depend on an interface. We call it a RepositoryService. The team can treat the repository service as a generic data access that does not rely on implementation; it could be a service, a relational database, or a nonrelational database. If you want, you could explicitly separate the concept of a data repository from an external REST service; just use a different name convention, likeExternalService.

For example:

import java.util.Optional;

public interface PersonRepositoryService {

    Optional<Person> findById(PersonId personId);
    Person save(Person person);
    List<Person> findAll();
}

Note: The service should consume domain objects and return domain objects. This means there will be a transformation in the infrastructure layer on the implementation side.

The Infrastructure Layer

The infrastructure layer performs every technology-related task, like database operations or external REST API

consumption. This layer typically implements the RepositoryService from the domain layer. We call this an Adapter. This concept is correlated with "Ports and Adapters" from the Alistar Cockburn Hexagonal Architecture reference article.

For example, this is the Spring Data JPA implementation of the PersonRespositoryService

@Repository
@RequiredArgsConstructor
public class PersonRepositoryAdapterImpl implements PersonRepositoryService {
    private final PersonRepository personRepository;

    @Override
    public Optional<Person> findById(PersonId personId) {
        return personRepository.findById(personId.id())
                .map(PersonEntity::toDomain);
    }

    @Override
    public Person save(Person person) {
        return personRepository.save(PersonEntity.fromDomain(person)).toDomain();
    }

    @Override
    public List<Person> findAll() {
        return personRepository.findAll()
                .stream()
                .map(PersonEntity::toDomain)
                .toList();
    }
}

The entity maps its contents from and to the domain layer in this example.

The infrastructure layer is divided into:

database
  entity
    AddressEntity.java
    PersonEntity.java
  repository
    PersonRepository.java
  PersonRespositoryAdapterImpl.java

This is technology-specific. You could have a rest folder in the infrastructure layer to communicate with REST API's

Check out this example repository for a complete code setup