Layers, Hexagons, Ports and Adapters

March 30, 2022

Software can be structured in various ways. The most common architecture, in my experience, is a layered architecture. One could argue, this is mainly because of its simplicity. Additionally, the rules behind this architecture, are often broken in practice or simply not enough without additional constraints.

In this post, we are going to scratch the surface of an alternative approach, named hexagonal architecture, also known as ports and adapters architecture, and how it can be implemented with Java and Quarkus.

Fundamental parts of the hexagonal architecture are application hexagon, ports, and adapters. You can find multiple variants of implementation, but what all should have in common are ports exposed by the application and adapters implementing those ports.

Our simplified example relies on the fundamentals of onion architecture, which is known for multiple layers, where dependencies between the layers should always point inwards. Therefore we have implemented the following layers:

The purpose of the domain layer is to implement business rules. In this layer, domain-driven design (DDD) would be a perfect fit. To mimic this, we have implemented one business rule, which validates the minimum length of a comment's content. As this layer does not depend on anything else, it should be fairly simple to test. Furthermore, as long as there are no changes in business requirements, this layer should never change.

    public record Comment(
        Long id,
        String content,
        Long postId) {

    public Comment {
        if (postId == null) {
            throw new DomainException("Post id must be provided");
        }
        if (StringUtils.length(content) < 10) {
            throw new DomainException("Content must contain at least 10 characters");
        }
    }
}

The next layer, depending only on the domain, is the application layer. Here we have defined use cases interface for the creation of a new post, commenting on a post, etc. Another important component of the application layer is input and output ports. As part of this, we have a service as a technology-agnostic implementation of specific use cases. It could be confusing to have a port named use case, but it will make sense to have a reference to the use case instead of a port later on when we get to the last layer. For now, just consider the use cases interface as an actual input port from the hexagonal point of view.

    public interface PostManagementUseCase {
    Post create(String title, String content);

    Post retrieve(Long id);

    Comment commentOnPost(Long postId, String content);

    Collection listPostComments(Long id);
}
    public class PostManagementService implements PostManagementUseCase {

    private final PostManagementOutputPort outputPort;

    public PostManagementService(PostManagementOutputPort outputPort) {
        this.outputPort = outputPort;
    }

    @Override
    public Post create(String title, String content) {
        Post post = new Post(null, title, content, Collections.emptySet());
        return outputPort.persist(post);
    }

    @Override
    public Comment commentOnPost(Long postId, String content) {
        return outputPort.addComment(new Comment(null, content, postId));
    }

    ...
}
    public interface PostManagementOutputPort {
    Post persist(Post post);

    Post retrieve(Long id);

    Comment addComment(Comment comment);

    Collection listComments(Long id);
}

Finally, there is the framework layer. In this layer, we have to make decisions about technologies, that could be a good fit for our application. This is another benefit of such an approach. We don't have to make many technological decisions at the beginning. Instead, the focus is on domain and business value, not the other way around. Ideally, the last layer is added when the other two layers are already implemented and covered with tests.

    @Path("/posts")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PostManagementRestAdapter {

    private final PostManagementUseCase postManagementUseCase;

    public PostManagementRestAdapter(PostManagementOutputPort postManagementOutputPort) {
        this.postManagementUseCase = new PostManagementService(postManagementOutputPort);
    }

    @POST
    public PostDto create(PostDto post) {
        Post created = postManagementUseCase.create(post.title(), post.content());
        return new PostDto(created.id(), created.title(), created.content());
    }

    ...
}

You may have noticed, we are creating a new instance of PostManagementService and injecting it into the rest adapter. In an actual application, the input port could be registered as a managed bean with the CDI (Contexts and Dependency Injection).

    @ApplicationScoped
public class PostManagementPostgresAdapter implements PostManagementOutputPort {

    @Override
    @Transactional
    public Post persist(Post post) {
        PostEntity entity = new PostEntity();
        entity.title = post.title();
        entity.content = post.content();
        entity.persist();
        return new Post(entity.id, entity.title, entity.content, Collections.emptySet());
    }

    @Override
    @Transactional
    public Comment addComment(Comment comment) {
        PostEntity post = (PostEntity) PostEntity.findByIdOptional(comment.postId()).orElseThrow(() ->
                new EntityNotFoundException("Post not found, id: " + comment.postId()));
        CommentEntity entity = new CommentEntity();
        entity.content = comment.content();
        entity.post = post;
        entity.persist();
        return new Comment(entity.id, entity.content, entity.post.id);
    }

    ...
}

Our example is implemented using Quarkus, but it could be replaced by any other framework or technology, without affecting the inner two layers. Input adapter exposes the application via REST endpoint. On the other side, the output adapter enables us to persist data in the Postgres database.

The code is extremely simple, which should make the whole idea behind hexagonal architecture very clear. So far the focus was only on benefits, although you fur sure have recognized some code overhead with this approach. You would also have to think good about the use case boundaries and some other details, which are trivial solutions without such separation. In the end, the decision is yours and should, as always, be aligned with every project's requirements. In practice, you can always tweak the architectural decisions, take shortcuts or introduce even more complexity. I believe every decision can be the right one, as long as you are aware of the consequences, both positive and negative ones.

If you would like to understand this whole idea in more depth, I would recommend reading the book Designing Hexagonal Architecture with Java by Davi Vieira, which was an inspiration for this blog post.

Check out the GitHub project for full implementation.