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.