Spring Boot: Clean Architecture vs Vertical Slice Architecture(VSA)

As software development projects become complex, selecting an appropriate architectural paradigm becomes pivotal. Two prominent contenders in this arena are the Clean Architecture and the Vertical Slice Architecture (VSA). While both aim to foster maintainable and scalable codebases, their approaches diverge, catering to distinct project requirements and development philosophies.

Clean Architecture: Layering for Modularity

Conceptualized by Robert C. Martin, Clean Architecture advocates for a layered approach, where the codebase is segregated into concentric circles or layers. This hierarchical structure places the domain logic, embodying the core business rules, at the innermost layer, insulating it from external factors and dependencies.

The philosophy behind this paradigm is to safeguard the domain logic from the volatility of frameworks, user interfaces, and data sources. By encapsulating business rules within the innermost layer, developers can mitigate the ripple effects of changes to external components, thereby fostering a more maintainable and adaptable codebase.

Vertical Slice Architecture: Organizing by Business Capabilities

In contrast, the Vertical Slice Architecture (VSA) takes a different approach, structuring the codebase around business capabilities or use cases rather than technical concerns. This approach advocates organizing components into vertical slices that span multiple layers, encompassing the user interface, business logic, and data access layers.

The rationale behind this paradigm is to enhance cohesion and reduce coupling between unrelated business functionalities. By grouping related components into self-contained slices, developers can more easily reason about and modify specific features without inadvertently impacting unrelated parts of the application.

Coupling and Cohesion: Striking the Right Balance

One of the primary advantages of the Vertical Slice Architecture is its ability to manage coupling and cohesion more effectively. In a traditional layered architecture, components within the same layer may become tightly coupled, as they often share dependencies on services or repositories from other layers.

In contrast, the VSA promotes loose coupling by encapsulating related components within their respective slices. Cross-slice communication is facilitated through well-defined interfaces and application events, minimizing direct dependencies between unrelated components.

Moreover, the VSA fosters higher cohesion by grouping related functionality within the same slice. This approach ensures that changes to a specific business capability are localized, reducing the need to modify multiple files across various layers, as is often the case in layered architectures.

Modeling the Domain: A DDD Perspective

Both architectural paradigms can accommodate Domain-Driven Design (DDD) principles, which emphasize modeling software based on the core business domain and its logic. However, the Vertical Slice Architecture lends itself more naturally to Domain-Driven Design (DDD) concepts, such as bounded contexts and context mapping.

Each slice can represent a distinct, bounded context in a vertically sliced codebase, encapsulating a specific domain model and its associated terminology. This approach aligns technical and business perspectives, fostering a shared understanding between developers and domain experts.

Flexibility and Adaptability

One of the key advantages of the Vertical Slice Architecture is its inherent flexibility. Unlike layered architectures, which often impose strict rules and dependencies across the entire application, VSA allows developers to tailor their approach for each slice.

This flexibility enables developers to leverage various tools, patterns, and paradigms without enforcing a specific coding style or dependencies across the entire codebase. For instance, developers can selectively apply Domain-Driven Design principles or bypass the domain layer for simpler use cases, optimizing the codebase for specific requirements.

Screaming Architecture: Communicating Purpose

Proponents of the Vertical Slice Architecture often cite its ability to “scream” the application’s purpose through its file structure. By organizing components based on business capabilities, the codebase becomes more self-documenting, allowing developers unfamiliar with the project to quickly grasp its functionality and purpose.

This aligns with Robert C. Martin’s “Screaming Architecture” notion, which advocates for a software project’s design to communicate its purpose, much like architectural blueprints reveal a building’s function.

Separating Concerns: Bounded Contexts and Projections

In a vertically sliced codebase, the concept of a user or entity may vary across different bounded contexts, reflecting the specific role they play within that context. For instance, a user might be represented as a “reader,” an “author,” or a “topic follower,” depending on the bounded context.

This approach promotes the separation of concerns and reduces the risk of coupling unrelated components. Additionally, the VSA allows for the use of projections, where each slice can define its view or representation of an entity tailored to its specific requirements, further enhancing decoupling and maintainability.

Bypassing the Domain Layer: Simplifying Use Cases

One of the criticisms of layered architectures is the potential for the “middle man” anti-pattern, where methods pass calls to the next layer without adding value. This can lead to tightly coupled layers and unnecessary complexity.

The Vertical Slice Architecture addresses this issue by allowing developers to bypass the domain layer for simple use cases, directly accessing the persistence layer for data retrieval or manipulation. This approach streamlines the codebase, eliminates unnecessary dependencies, and promotes a more pragmatic approach to software design.

Asynchronous Communication: Loose Coupling with Events

Both architectural paradigms acknowledge the importance of loose coupling between components. However, the Vertical Slice Architecture often leverages application events to facilitate asynchronous and decoupled cross-slice communication.

Slicks can interact without direct dependencies by publishing and subscribing to events, enabling greater flexibility and scalability. This approach aligns with the principles of event-driven architecture, promoting a more reactive and responsive system design.

Monolithic or Microservices: Adapting to Project Needs

One of the key considerations when selecting an architectural paradigm is the project’s decoupling mode: monolithic, modular, or microservices-based. The Clean Architecture and the Vertical Slice Architecture can accommodate various decoupling strategies, allowing developers to adapt their approach based on current and future project requirements.

Components can be organized using package structures or modules in a monolithic application. At the same time, in a microservices architecture, each vertical slice or bounded context can be encapsulated within its service, promoting independent deployment and scalability.

Testability and Maintainability: Striking a Balance

Both architectural paradigms prioritize testability and maintainability, albeit through different approaches. The Clean Architecture emphasizes interfaces and dependency inversion to facilitate unit testing and mock dependencies.

In contrast, the Vertical Slice Architecture promotes higher cohesion and loose coupling, which can lead to more focused and isolated tests. Additionally, application events and asynchronous communication can simplify testing by reducing the need for complex mocking scenarios.

Evolving Requirements: Embracing Change

Software development is an iterative process, and requirements often evolve. Both the Clean Architecture and the Vertical Slice Architecture aim to facilitate change and adaptability, albeit through different means.

Clean Architecture focuses on insulating domain logic from external factors and enabling developers to swap out frameworks, user interfaces, and data sources without impacting the core business rules.

On the other hand, the Vertical Slice Architecture promotes modularity and loose coupling between business capabilities, allowing developers to modify or extend specific features without rippling effects across the entire codebase.

Choosing the Right Approach: Aligning with Project Goals

Ultimately, the choice between Clean Architecture and Vertical Slice Architecture should be driven by the project’s specific requirements, the development team’s preferences, and long-term goals.

Projects that prioritize a clear separation of concerns and strongly emphasize domain modeling may find the Clean Architecture more suitable. In contrast, projects that require rapid feature development, high cohesion, and loose coupling between business capabilities may benefit from the Vertical Slice Architecture.

It’s also worth noting that these architectural paradigms are not mutually exclusive; hybrid approaches that combine elements of both can be employed to leverage their respective strengths and address project-specific challenges.

Putting Theory into Practice: A Hands-on Example

To illustrate the practical implementation of these architectural paradigms, let’s explore a hands-on example of a user registration API built using Spring Boot. We’ll delve into the code structure, design principles, and best practices of each approach.

Clean Architecture Implementation

Let’s begin by examining the Clean Architecture implementation of a user registration API.

The Entity Layer

At the core of the Clean Architecture lies the entity layer, which encapsulates the business rules and domain logic. In our example, we define the User interface and a concrete implementation, CommonUser:

interface User {
    boolean passwordIsValid();
    String getName();
    String getPassword();
}

class CommonUser implements User {
    String name;
    String password;

    @Override
    public boolean passwordIsValid() {
        return password != null && password.length() > 5;
    }

    // Constructor and getters
}

We also introduce an UserFactory interface to facilitate the creation of User instances, adhering to the principle of stable abstractions and isolating the user creation process.

The Use Case Layer

The use case layer, the “Interactors” layer, encapsulates the application rules and orchestrates the interactions between various components. In our example, we define the UserRegisterInteractor and its input and output boundaries:

class UserRegisterInteractor implements UserInputBoundary {
    final UserRegisterDsGateway userDsGateway;
    final UserPresenter userPresenter;
    final UserFactory userFactory;

    // Constructor

    @Override
    public UserResponseModel create(UserRequestModel requestModel) {
        // Use case implementation
    }
}

interface UserInputBoundary {
    UserResponseModel create(UserRequestModel requestModel);
}

interface UserRegisterDsGateway {
    boolean existsByName(String name);
    void save(UserDsRequestModel requestModel);
}

interface UserPresenter {
    UserResponseModel prepareSuccessView(UserResponseModel user);
    UserResponseModel prepareFailView(String error);
}

By defining input and output boundaries, we decouple the use case implementation from external dependencies, such as databases and user interfaces, promoting modularity and testability.

The Interface Adapters Layer

The interface adapters layer bridges the use case layer and external components, such as databases and user interfaces. In our example, we implement the UserRegisterDsGateway using JPA and the UserPresenter to format the response:

class JpaUser implements UserRegisterDsGateway {
    final JpaUserRepository repository;

    // Constructor

    @Override
    public boolean existsByName(String name) {
        return repository.existsById(name);
    }

    @Override
    public void save(UserDsRequestModel requestModel) {
        UserDataMapper accountDataMapper = new UserDataMapper(requestModel.getName(), requestModel.getPassword(), requestModel.getCreationTime());
        repository.save(accountDataMapper);
    }
}

class UserResponseFormatter implements UserPresenter {
    @Override
    public UserResponseModel prepareSuccessView(UserResponseModel response) {
        // Format response
    }

    @Override
    public UserResponseModel prepareFailView(String error) {
        throw new ResponseStatusException(HttpStatus.CONFLICT, error);
    }
}

Separating the interface adapters from the use case layer allows us to easily swap out external components without impacting the core business logic, promoting maintainability and extensibility.

The Frameworks and Drivers Layer

The frameworks and drivers layer represents the lowest level of the Clean Architecture, responsible for connecting to external agents such as databases, web frameworks, and other third-party libraries. In our example, we leverage Spring Boot as the web framework and dependency injection framework:

@SpringBootApplication
public class CleanArchitectureApplication {
    public static void main(String[] args) {
        SpringApplication.run(CleanArchitectureApplication.class);
    }
}

By treating Spring Boot as an external detail, we maintain the separation of concerns and adhere to the principles of Clean Architecture.

The Main Class

The main class serves as the entry point for our application, responsible for wiring up dependencies and instantiating the required components. In our example, we leverage Spring Boot’s dependency injection mechanism to create and inject the necessary instances:

@Bean
BeanFactoryPostProcessor beanFactoryPostProcessor(ApplicationContext beanRegistry) {
    return beanFactory -> {
        genericApplicationContext(
          (BeanDefinitionRegistry) ((AnnotationConfigServletWebServerApplicationContext) beanRegistry)
            .getBeanFactory());
    };
}

void genericApplicationContext(BeanDefinitionRegistry beanRegistry) {
    ClassPathBeanDefinitionScanner beanDefinitionScanner = new ClassPathBeanDefinitionScanner(beanRegistry);
    beanDefinitionScanner.addIncludeFilter(removeModelAndEntitiesFilter());
    beanDefinitionScanner.scan("com.baeldung.pattern.cleanarchitecture");
}

static TypeFilter removeModelAndEntitiesFilter() {
    return (MetadataReader mr, MetadataReaderFactory mrf) -> !mr.getClassMetadata()
      .getClassName()
      .endsWith("Model");
}

Decoupling the main class from the dependency injection framework allows us to switch to alternative mechanisms, further promoting maintainability and extensibility.

Vertical Slice Architecture Implementation

Now, let’s explore the implementation of the same user registration API using the Vertical Slice Architecture.

Organizing by Business Capabilities

The Vertical Slice Architecture organizes components based on business capabilities or use cases rather than technical concerns. In our example, we might structure the codebase into slices, such as authorreader, and recommendation:

- src
  - main
    - java
      - com
        - example
          - author
            - controllers
            - usecases
            - domain
            - data
          - reader
            - controllers
            - usecases
            - domain
            - data
          - recommendation
            - usecases
            - domain
            - data

Each slice encapsulates the user interface, business logic, and data access layers specific to its business capability, promoting high cohesion and loose coupling.

The Author Slice

Within the author slice, we might define components related to the user registration functionality:

// author/controllers/UserRegisterController.java
@RestController
class UserRegisterController {
    final UserInputBoundary userInput;

    // Constructor

    @PostMapping("/user")
    UserResponseModel create(@RequestBody UserRequestModel requestModel) {
        return userInput.create(requestModel);
    }
}

// author/usecases/UserRegisterInteractor.java
class UserRegisterInteractor implements UserInputBoundary {
    final UserRegisterDsGateway userDsGateway;
    final UserPresenter userPresenter;
    final UserFactory userFactory;

    // Constructor

    @Override
    public UserResponseModel create(UserRequestModel requestModel) {
        // Use case implementation
    }
}

// author/domain/User.java
interface User {
    boolean passwordIsValid();
    String getName();
    String getPassword();
}

// author/domain/UserFactory.java
interface UserFactory {
    User create(String name, String password);
}

// author/data/UserRegisterDsGateway.java
interface UserRegisterDsGateway {
    boolean existsByName(String name);
    void save(UserDsRequestModel requestModel);
}

By encapsulating all components related to the user registration functionality within the author slice, we promote high cohesion and loose coupling between unrelated business capabilities.

Cross-Slice Communication with Events

In the Vertical Slice Architecture, cross-slice communication is often facilitated through application events, promoting loose coupling and asynchronous interactions. For example, when a new user is registered, we might publish an event that the recommendation slice can subscribe to:

// author/usecases/UserRegisterInteractor.java
class UserRegisterInteractor implements UserInputBoundary {
    // ...

    @Override
    public UserResponseModel create(UserRequestModel requestModel) {
        // ...
        userDsGateway.save(userDsModel);

        var event = new UserRegisteredEvent(user.getName());
        eventPublisher.publishEvent(event);

        // ...
    }
}

// recommendation/usecases/SendRecommendationUseCase.java
@Component
class SendRecommendationUseCase {
    @EventListener
    void onUserRegistration(UserRegisteredEvent event) {
        String userName = event.getUserName();
        // Logic to send recommendations to the new user
    }
}

By leveraging application events, the author and recommendation Slices can interact without direct dependencies, promoting loose coupling and scalability.

Bounded Contexts and Projections

Within the Vertical Slice Architecture, embracing bounded contexts allows the representation of entities or users to vary across different slices, reflecting their specific roles and responsibilities. For instance, the reader slice might represent a user as a “reader,” while the author slice portrays them as an “author.” This separation of concerns reduces coupling and promotes a more cohesive codebase.

Furthermore, the VSA facilitates projections, enabling each slice to define its tailored view or representation of an entity based on its unique requirements. This approach enhances decoupling and maintainability by eliminating the need for shared data transfer objects (DTOs) or models across the entire application.

Bypassing the Domain Layer

One of the key advantages of the Vertical Slice Architecture is its flexibility, which allows developers to bypass the domain layer for simpler use cases. By directly accessing the persistence layer, developers can streamline the codebase and eliminate unnecessary dependencies. For example, consider a use case that retrieves an article by its slug:

// reader/usecases/ViewArticleUseCase.java
@Component
class ViewArticleUseCase {
    private static final String FIND_BY_SLUG_SQL = """
        SELECT id, name, slug, content, authorid
        FROM articles
        WHERE slug = ?
        """;

    private final JdbcClient jdbcClient;

    // Constructor

    public Optional<ViewArticleProjection> view(String slug) {
        return jdbcClient.sql(FIND_BY_SLUG_SQL)
          .param(slug)
          .query(this::mapArticleProjection)
          .optional();
    }

    record ViewArticleProjection(String name, String slug, String content, Long authorId) {
    }

    private ViewArticleProjection mapArticleProjection(ResultSet rs, int rowNum) throws SQLException {
        // ...
    }
}

In this example, the ViewArticleUseCase directly queries the database using a JdbcClient And defines its projection of an article tailored to the specific requirements of this use case. This approach eliminates unnecessary dependencies and promotes a more pragmatic approach to software design.

Testability and Maintainability

Both architectural paradigms prioritize testability and maintainability, albeit through different approaches. The Clean Architecture emphasizes interfaces and dependency inversion to facilitate unit testing and mock dependencies. In contrast, the Vertical Slice Architecture promotes higher cohesion and loose coupling, which can lead to more focused and isolated tests.

Additionally, application events and asynchronous communication in the VSA can simplify testing by reducing the need for complex mocking scenarios. By decoupling components through events, developers can test individual slices independently without the need to mock collaborators or external dependencies.

Evolving Requirements and Adaptability

Software development is an iterative process, and requirements often evolve. Both architectural paradigms aim to facilitate change and adaptability, but their approaches differ.

Clean Architecture focuses on insulating domain logic from external factors and enabling developers to swap out frameworks, user interfaces, and data sources without impacting the core business rules. This approach promotes long-term maintainability and adaptability, as the core domain logic remains stable even as external dependencies change.

On the other hand, the Vertical Slice Architecture promotes modularity and loose coupling between business capabilities, allowing developers to modify or extend specific features without rippling effects across the entire codebase. This approach facilitates rapid feature development and evolution, as changes are localized within individual slices, minimizing the impact on unrelated components.

Choosing the Right Approach

Ultimately, the choice between Clean Architecture and Vertical Slice Architecture should be driven by the project’s specific requirements, the development team’s preferences, and long-term goals.

Projects prioritizing a clear separation of concerns and a strong emphasis on domain modeling may find the Clean Architecture more suitable, as it provides a structured approach to encapsulating and protecting the core business logic. This paradigm is particularly beneficial for complex domains or applications with high business rule complexity.

Conversely, projects that require rapid feature development, high cohesion, and loose coupling between business capabilities may benefit from the Vertical Slice Architecture. This approach excels in scenarios where business requirements frequently evolve, as it facilitates localized changes and minimizes the impact on unrelated components.

It’s also worth noting that these architectural paradigms are not mutually exclusive; hybrid approaches that combine elements of both can be employed to leverage their respective strengths and address project-specific challenges. For example, a project might adopt Clean Architecture at a high level while embracing the principles of Vertical Slice Architecture within individual bounded contexts or features.

Continuous Learning and Adaptation

Software development is a continuous learning process regardless of the chosen architectural paradigm. Developers must remain open to adapting and refining their approaches as technologies, frameworks, and best practices evolve.

The Clean Architecture and the Vertical Slice Architecture provide valuable guidelines and principles. Still, their successful implementation relies on a deep understanding of the underlying concepts and a willingness to tailor them to the specific needs of each project.

By continuously learning, experimenting, and refining their architectural approaches, developers can create scalable, maintainable, and adaptable software systems that stand the test of time.

Leave a Comment

Your email address will not be published. Required fields are marked *