Do you want your ad here?

Contact us to get your ad seen by thousands of users every day!

[email protected]

Clean and Modular Java: A Hexagonal Architecture Approach

  • June 24, 2025
  • 178 Unique Views
  • 9 min read

One of the discussions that always leaves me with both doubts and excitement is the one about system architecture. Ever since I started diving deeper into programming, I’ve encountered questions like how to separate packages and modules: is it really worth creating so many divisions? I must admit, it’s often complicated to understand or make decisions without being fully sure that I’m doing the right thing.

Right from the start, I want to make it clear that this content will definitely spark a lot of discussion around best practices and whether what I’m doing is correct — but, above all, I guarantee it will be fun!

System Architecture

The main idea behind any software architecture is to ensure that development remains sustainable, making it easier to maintain and evolve the system over time. That’s the mindset we should always have from the very beginning.

The key objective is to make integration easier while decoupling dependencies between the layers, ensuring that the system is easier to maintain and evolve.

For example, imagine a warehouse management system (WMS); If every time there’s a change in how you handle inbound operations (like receiving and stocking goods), you also have to update the way you handle outbound operations (like picking and shipping orders) — because they’re tightly coupled — that
would become unsustainable in the long run, right? Well, exactly.

Hexagonal Architecture

Alistair Cockburn created the hexagonal model precisely to address problems like the ones described in the example above. The idea behind this model is to clearly separate responsibilities: the core, the database, adapters, and so on.

This separation ensures that the core of the application does not directly depend on frameworks or infrastructure implementations, bringing more flexibility and making it easier to evolve the system.

What Are We Going to Build?

In this project, we’re building a modular Java application with Maven, focusing on separating the layers of the system in a clear and maintainable way. While some layers do depend on others — for example, the application layer depends on the domain, and the infrastructure layer depends on both — the goal is to avoid direct, tight coupling and keep responsibilities well-defined.

To achieve this, we’ll split the application into distinct modules, each focused on a specific concern and designed to evolve independently.

This structure shows that:

  • domain is independent and does not access any other module.
  • application accesses domain to orchestrate its rules.
  • infrastructure accesses both domain and application to implement adapters and external integrations.

At the end of the project, we will have something like this:

Maven Multi-Module Project

To start implementing our project, we’ll create a pom.xml file with the following content:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>br.com.ricas</groupId>
<artifactId>my-modular-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>modular application</name>
<description>This repository is the modular parent</description>
<packaging>pom</packaging>

    <modules>
        <module>domain</module>
        <module>infrastructure</module>
        <module>application</module>
    </modules>

    <properties>
        <java.version>21</java.version>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                    <parameters>true</parameters>
                    <release>${java.version}</release>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Notice that this project contains a few modules that we’ll create in the next steps. For that, we’ll create three folders: domain, infrastructure, and application. At the end, we’ll have something like this:

Domain

Let’s start with the domain. As mentioned before, this layer will be 100% isolated and won’t have any dependencies on frameworks, databases, or external resources. Here’s the pom.xmlof domain layer:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>br.com.ricas</groupId>
        <artifactId>my-modular-parent</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath>../pom.xml</relativePath>
    </parent>
    <artifactId>my-modular-domain</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <description>
        This module contains only pure domain logic.
        It has no external dependencies and is completely agnostic to any frameworks or infrastructure concerns
    </description>

    <properties>
        <java.version>21</java.version>
    </properties>


</project>

The parent is the module we created earlier.

Application

The application layer will be responsible for orchestrating the domain layer. I’ve worked on projects where the application layer was also responsible for handling the application’s entry points, like REST calls, for example. However, in this project, we’ll keep this layer isolated and without access to any external resources, leaving it purely for orchestrating the domain layer.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>br.com.ricas</groupId>
        <artifactId>my-modular-parent</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath>../pom.xml</relativePath>
    </parent>
    <artifactId>my-modular-application</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <description>
        This module defines use cases and orchestrates domain logic by leveraging the interfaces (ports) from the domain module.
        - it depends on Domain module
    </description>

    <dependencies>
        <dependency>
            <groupId>br.com.ricas</groupId>
            <artifactId>my-modular-domain</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

    </dependencies>

</project>

Notice that, in addition to referencing the parent module, it also depends on the domain module.

Infrastructure

Inside this layer, we’ll have all the external resources for our application, such as databases, messaging services, HTTP APIs, and others. By isolating the domain layer from the infrastructure layer, if tomorrow we decide to switch frameworks (for example, from Spring to Quarkus), we just need to change this module without impacting the others. Here’s the pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>br.com.ricas</groupId>
        <artifactId>my-modular-parent</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath>../pom.xml</relativePath>
    </parent>
    <artifactId>my-modular-infrastructure</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <description>This repository is the infrastructure modular part</description>


    <dependencies>
       <!-- INFRA DEPENDS ON DOMAIN AND APPLICATION -->
        <dependency>
            <groupId>br.com.ricas</groupId>
            <artifactId>my-modular-domain</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

        <dependency>
            <groupId>br.com.ricas</groupId>
            <artifactId>my-modular-application</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>3.5.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb</artifactId>
            <version>3.5.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>3.5.0</version>
            </plugin>
        </plugins>
    </build>


</project>

In this module, we’ll have dependencies on our application and domain modules, and we’ll also include other external resources.

Now, in the root folder just run:

mvn clean install

Domain Implementation

Let’s start by implementing our domain module. As explained earlier, this module will only have access to domain logic, without any dependencies on frameworks or external systems. We’ll begin by creating a simple Product record. For that, let’s create a package named model:

package br.com.ricas.model;
public record Product(
        String id,
        String name,
        String description,
        double price
) {}

Next, let’s define an output port for accessing this resource:

package br.com.ricas.port;
import br.com.ricas.model.Product;
import java.util.List;
public interface ProductPort {
    Product save(Product product);
    List<Product> findAll(
            int page,
            int sizePerPage,
            String sortField,
            String sortDirection
    );
}

Perfect! That’s all for now. Notice that we only have objects that belong to the Java language itself and nothing else. At the end, we’ll have a structure like this:

Application Implementation

Now let’s move on to the application module. This layer is responsible for orchestrating the domain layer, providing services that coordinate domain logic while keeping itself free from external dependencies.

We have a few key packages here:

  • request: contains classes like ProductRequest, which converts data from incoming requests to domain models.
  • response: contains classes like ProductResponse, used to send structured responses back to the client.
  • service: here’s where the orchestration happens, with services that use ports from the domain layer.
  • usecase: this package would typically hold the core application use cases or application services.
  • exceptions: this package would hold custom exceptions for the application layer.

Let’s start by creating the request folder with its record:

package request;

import br.com.ricas.model.Product;

public record ProductRequest(
        String name,
        String description,
        double price
) {
    public Product toModel() {
        return new Product(null, name, description, price);
    }
}

Now, the response:

package response;

public record ProductResponse(
        String id,
        String name,
        String description,
        double price
) {}

Let’s also go ahead and create another record that we’ll use for our future requests, especially for pagination.

package response;

import java.util.List;

public record PageResponse<T>(
            List<T> content,
            int page,
            int size,
            long totalElements
    ) {}

Moving ahead, let’s create the ProductService class:

package service;

import br.com.ricas.model.Product;
import br.com.ricas.port.ProductPort;
import request.ProductRequest;
import response.ProductResponse;

import java.util.List;

public class ProductService {

    private final ProductPort productPort;

    public ProductService(ProductPort productPort) {
        this.productPort = productPort;
    }

    public ProductResponse create(ProductRequest productRequest) {
        Product product = productPort.save(productRequest.toModel());
        return new ProductResponse(product.id(), product.name(), product.description(), product.price());
    }

    public List<ProductResponse> find(
            int page,
            int sizePerPage,
            String sortField,
            String sortDirection
    ) {
        return productPort.findAll(page, sizePerPage, sortField, sortDirection).stream().map(
                it -> new ProductResponse(it.id(), it.name(), it.description(), it.price())
        ).toList();
    }
}

This ProductService class orchestrates the domain logic using the ProductPort. It provides two methods:

  • create – to save a new product and return a structured response.
  • find – to retrieve a paginated list of products, converting domain models into response objects.

One thing to notice: I’m not using the @Service annotation from Spring here, which is commonly found in service classes. While it’s perfectly okay to use @Service in many Spring-based applications, in this project we’re deliberately keeping the application layer isolated and not introducing any external dependencies (like Spring) in this layer.

This approach ensures the application layer is framework-agnostic and purely focused on orchestrating domain logic, maintaining a clear separation of concerns.

At the end, we’ll have a structure like this:

Infrastructure Implementation

Now let’s talk about the infrastructure layer. This layer will contain everything related to accessing external resources, such as MongoDB, Spring, Kafka, and any other integrations or adapters our application might use.

It’s important to note that in this layer, we’re now working with Spring — you can see that in the use of the @Configuration annotation, which tells Spring to treat this class as a source of bean definitions.

Let’s start by creating a configuration class in the br.com.ricas.config package:

package br.com.ricas.config;

import br.com.ricas.port.ProductPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import service.ProductService;

@Configuration
public class ApplicationServicesConfig {

    @Bean
    public ProductService productService(ProductPort productPort) {
        return new ProductService(productPort);
    }
}

In this class, we’re injecting (or more precisely, we’re wiring up) the ProductService from the application module by providing the required ProductPort dependency. This approach ensures that our ProductService is correctly initialized and can orchestrate domain logic without directly depending on any external framework itself.

By isolating this configuration in the infrastructure layer, we ensure that any changes to frameworks (like moving from Spring to Quarkus, for example) are localized here, without impacting the core application or domain logic.

Accessing MongoDB

Moving forward with our development, we’re going to connect to MongoDB using the spring-boot-starter-data-mongodb dependency that we included in the infrastructure module’s pom.xml. The first thing we’ll do is create an interface to handle basic CRUD operations with MongoDB:

package br.com.ricas.database.mongodb;

import br.com.ricas.entity.ProductDocument;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ProductRepository extends MongoRepository<ProductDocument, String> {
}

Next, we’ll create the implementation for the the ProductPort interface that we defined in the domain module:

package br.com.ricas.database.mongodb;


import br.com.ricas.entity.ProductDocument;
import br.com.ricas.model.Product;
import br.com.ricas.port.ProductPort;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class ProductPortImpl implements ProductPort {

    private final ProductRepository productRepository;

    ProductPortImpl(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Override
    public Product save(Product product) {
        var productDocument = new ProductDocument();

        productDocument.setId(product.id());
        productDocument.setName(product.name());
        productDocument.setDescription(product.description());
        productDocument.setPrice(product.price());

        return productRepository.save(productDocument).toModel();
    }

    @Override
    public List<Product> findAll(int page, int sizePerPage, String sortField, String sortDirection) {
        Pageable pageable = PageRequest.of(page, sizePerPage, Sort.by(sortDirection, sortField));

        return productRepository.findAll(pageable)
                .stream().map(it -> new Product(
                        it.getId(),
                        it.getName(),
                        it.getDescription(),
                        it.getPrice()
                )).toList();
    }

}

This implementation connects our domain logic to the database in a clean and controlled way. Again, this isolation makes the system easier to maintain and evolve, since the domain logic remains untouched by infrastructure changes.

Let’s create the ProductDocument:

@Document(collection = "products")
public class ProductDocument {
    @Id
    private String id;
    private String name;
    private String description;
    private double price;

    public Product toModel() {
        return new Product(id, name, description, price);
    }

  // getters and setters
}

The controller:

package br.com.ricas.http;

import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import request.ProductRequest;
import response.PageResponse;
import response.ProductResponse;
import service.ProductService;

import java.util.List;

@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;

    ProductController(ProductService productService) {
        this.productService = productService;
    }

    @PostMapping
    public ResponseEntity<ProductResponse> addProduct(@RequestBody ProductRequest product) {
        ProductResponse productResponse = productService.create(product);
        return ResponseEntity.ok(productResponse);
    }

    @GetMapping
    public ResponseEntity<PageResponse<ProductResponse>> getProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "100") int sizePerPage,
            @RequestParam(defaultValue = "ID") String sortField,
            @RequestParam(defaultValue = "DESC") Sort.Direction sortDirection
    ) {
        List<ProductResponse> productResponses = productService.find(page, sizePerPage, sortField, sortDirection.name());
        PageResponse<ProductResponse> response = new PageResponse<>(
                productResponses,
                page,
                sizePerPage,
                productResponses.size()
        );

        return ResponseEntity.ok(response);
    }
}

Let’s create an application.yml as well:

spring:
 data:
   mongodb:
     uri: ${MONGODB_URI}
     database: my_database

And finally:

package br.com.ricas;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class InfrastructureApplication {

    public static void main(String[] args) {
        SpringApplication.run(InfrastructureApplication.class, args);
    }
}

By the end, we will have:

Running the Application

Now that we have everything set up, let’s see how to run the application and test our endpoints! To build and start the application, open a terminal at the root of the project and run:

export MONGODB_URI="<YOUR_CONNECTION_STRING>"
cd infrastructure
mvn spring-boot:run

POST — Create a new product:

POST http://localhost:8080/products

Content-Type: application/json
{
 "name": "Product A",
 "description": "This is a description on product A",
 "price": 450.0
}

GET — Fetch a paginated list of products:

GET http://localhost:8080/products?page=0&sizePerPage=10&sortField=id&sortDirection=ASC

Wrapping Things Up

Well, there’s certainly a lot to discuss about this architecture and about patterns and concepts like Clean Architecture and DDD. The main idea here is to show that isolating layers can bring significant benefits, as long as it’s done thoughtfully and correctly.

Remember, there’s no single rule to follow — just concepts and best practices to guide you.

I hope this application gives you a clear starting point if you’re just beginning to explore this topic.

If you liked, follow me here and leave a comment 🙂

You can find the project code [here]

And if you’re looking for a more complete example — with tests, use cases, and services — you can check out this project here.

Do you want your ad here?

Contact us to get your ad seen by thousands of users every day!

[email protected]

Comments (0)

Highlight your code snippets using [code lang="language name"] shortcode. Just insert your code between opening and closing tag: [code lang="java"] code [/code]. Or specify another language.

No comments yet. Be the first.

Subscribe to foojay updates:

https://foojay.io/feed/
Copied to the clipboard