How to Organize the Code in the Ports and Adapters Architecture

In this article, we’ll have a look at how to separate an application into different modules so that it can be developed and tested in isolation of any external dependencies. As a result, we’ll obtain loosely coupled components where things like database and communication protocol can easily be replaced without any changes in the domain logic.

First of all, let’s introduce a user story so that the domain logic of the example will be clear from the start:

User Story

As a client of the bookshop
I want to be able to look up books by title
So that I can choose the one I like
Acceptance Criteria

Given a collection of books
When the user types a title
Then all books matching that title will be returned

Is natural that the first module we create, bookshop-domain, is the one that holds domain DTOs and secondary ports (secondary, because the application itself triggers the calls to other systems).

As per the bookshop example, we’ll need one record to hold all book’s fields:

public record Book(UUID id, String title, List<String> authors) {

}

And another record for search criteria:

public record BookSearchCriteria(String title) {

}

The secondary port BookRepository is an interface that declares a method for obtaining all the shop’s books:

public interface BookRepository {

    List<Book> findAll();

}

Note that all the classes we just defined are not tied in any way to any framework’s specific classes, interfaces, or annotations. For example, Book is annotated neither with javax.persistence nor with Jackson annotations, although our application will persist data and expose REST endpoints.

The second module, bookshop-core, as the previous one, is also part of domain logic. It will hold a service that does exactly what was asked from us in the user story:

@AllArgsConstructor
public class BookService {

    private final BookRepository bookRepository;

    public List<Book> findAll(BookSearchCriteria criteria) {
        return bookRepository.findAll()
                .stream()
                .filter(matches(criteria))
                .collect(toList());
    }

    private Predicate<Book> matches(BookSearchCriteria criteria) {
        return book -> book.title()
                .toLowerCase()
                .contains(criteria.title().toLowerCase());
    }
}

BookService can also be called port in the hexagonal architecture as it is the class that exposes and implements the domain logic. Creating an interface first for the port it’s not always necessary, especially if the interface and implementation reside in the same module.

The next module we want to develop is bookshop-inmem-persistence which will hold the following adapter:

public class InMemBooksRepository implements BookRepository {

    private final List<Book> books;

    public InMemBooksRepository(List<Book> books) {
        this.books = List.copyOf(books);
    }

    @Override
    public List<Book> findAll() {
        return books;
    }
}

For the sake of simplicity, we did an in-memory implementation, but normally this module will hold all entities, JPA repositories or DAOs, and converters from entities to bookshop-domain DTOs.

Finally, the last module, bookshop-rest, exposes the endpoint and invokes the port to our domain logic:

@RestController
@AllArgsConstructor
public class BooksController implements BooksApi {

    private final BookService bookService;
    private final DomainMapper domainMapper;

    @Override
    public ResponseEntity<List<Book>> listBooks(String title) {
        var criteria = new BookSearchCriteria(title);
        var books = bookService.findAll(criteria)
                .stream()
                .map(domainMapper::map)
                .collect(toList());

        return ResponseEntity.ok(books);
    }
}

Notice that we convert the DTO returned by the service to another DTO, generated from an OpenAPI specification.

Conclusion

We have seen examples of ports and adapters in hexagonal architecture, and how to properly organize the code in a decoupled fashion.

Full code can be found over on GitHub.