Using Ports & Adapters to organize a Java project

Introduction

In this post we’ll take a look how to organize code in a Java project using Ports & Adapters (also know as Hexagonal architecture) taking Domain Driven Design principles into account.

The goal is to have a quick reference when working on a project and trying to figure out where to implement what. As such this post will probably be extended/change over time to take new things I learned or problems I encountered into account. There is a changelog at the end of this post in which I will document future changes.

I assume that you are familiar with the theoretical concepts of both, Ports & Adapters and Domain Driven Design. The base of my understanding stems from the following sources.

  • Get Your Hands Dirty on Clean Architecture and the accompanying example repository.
    I highly recommend reading this book. It was tremendously helpful in getting a grasp on this topic.
  • Implementing Domain-Driven Design and the accompanying example repository.
    This book takes you through an example from start to finish on how to implement an application using Domain-Driven Design. As such it is a great read, not only to get to know Domain-Driven Design, but also guidance on how to implement it.
  • Domain-Driven Design Series’ Articles
    A fantastic series of articles giving an introduction to Domain Driven Design and than moving on to concrete examples. This is probably the fastest way to get a general understanding of both concepts.
  • My job in which I apply what I learn.
    In my job I develop software for insurances. As such I’m faced with “enterprise challenges” on a regular base. The challenges I face there and the solutions I came with influenced this post.

As an example we will be building the package structure for a personal finance application. It allows users to enter transactions (i.e. grocery shopping) manually through a web app or import transactions from csv files.

This post only looks at the package structure. (I’m planning on writting future posts in which we will build the actual application.)

Quick reference

  • io.betweendata.moneymanager
    • [BOUNDED_CONTEXT]
      • adapter
        • in
          • [TYPE_OF_ADAPTER]
            • model
            • mapper
        • out
          • [TYPE_OF_ADAPTER]
            • model
            • mapper
      • application
        • service
        • port
          • in
          • out
      • domain
        • model
        • service
    • shared
      • domain
        • model

Package Structure with Explanations

  • io.betweendata.moneymanager
    The root package.

    • [BOUNDED_CONTEXT]
      The name of the bounded context (i.e. accounts or import).

      • adapter
        Adapters surround the application core (containing the business logic). They translate the representation of data between the domain model and any system outside of it.

        Outside of the application core is anything that is not part of the domain model (of the bounded context it belongs to). For example the web app a user uses to enter a new transaction. It also includes the database of the application and any other bounded context.

        Adapters fall in one of two categories: incoming or outgoing. What category an adapter falls into is answered from the point of view of the application core.

        An adapter is incoming, when something triggers the application core to do something. For example any request coming from the web app is incoming. It doesn’t matter if the request loads data or saves data. The important part is that something from outside triggers the application core to do something.

        An adapter is outgoing, when the application core itself triggers something that involves anything from outside the application core. For example saving data in a database. Or reaching out to another system via an API to load data. Even when saving a file on the local harddrive, the harddrive is considered outside the application core and therefore requires an outgoing adapter.

        Whenever data enters or leaves the application core an adapter must map between the different representations of the data.

        Implementation:
        Adapters are classes.
        Adapters map between the representation of data inside and outside of the application core.

        • in
          Incoming adapters receive requests from outside the application core and trigger it to do something.

          They do this by receiving a request, mapping the received data into the format the application expects it to be and then delegate the request to the appropriate application service.

          Examples:
          A web app uses a REST API to load/save data.

          • [TYPE_OF_ADAPTER]
            The package containing the adapter implementation (i.e. rest or soap). (Each type should have its own package.)

            Implementation:
            Incoming adapters use the interfaces defined in application/port/in to trigger the desired use case. (The implementation of these interfaces can be found in application/service and are provided via dependency injection.)
            The name of the adapter class must end with the type of adapter (i.e. Rest for a REST API) followed by InAdapter. (i.e. TransactionRestInAdapter) The name itself should indicated what kind of information is beeing processed by the adapter.

            • model
              The classes/records defining how the data received by the adapter looks like (DTOs).
              This model might be generated and therefore located in a separate source folder for generated source (i.e. when generating the API definitions from an OpenAPI spec).

            • mapper
              The implementation of the mappers to map between the external representation of data (DTOs) and the internal representation defined in the application core (domain model).

              Implementation:
              The class name must end with Mapper.
              The class name must start with the name of the adapter it maps data for (i.e. TransactionRestInMapper).

        • out
          Outgoing adapters are triggered from the application core to reach out to anything outside of it. This includes APIs, messaging queues and so on. It also includes interacting with other bounded contexts in the same application.

          Examples:
          Loading data from a database.
          Saving a file to the harddrive.
          Triggering an action in another bounded context by using one of that contexts incoming adapters.
          Calling a REST API of another application.

          • [TYPE_OF_ADAPTER]
            The package containing the adapter implementation (i.e. rest, jpa, harddrive). (Each type should have its own package.)

            Implementation:
            Outgoing adapter implement one or more of the interfaces defined in application/port/out.
            They encapsulate whatever technology is used to implement the interfaces methods (i.e. use a JpaRepository to connect to a database).
            The class name must end with the type of adapter followed by OutAdapter (i.e. TransactionsImportHarddriveOutAdapter). (If you wonder: Yes I’m aware how Java this class name plays into the stereotype that Java class names are cumbersome. In doubt come up with your own naming scheme.)

            • model
              The classes defining how the data send out by the adapter looks like (i.e. the JPA entities to store data in the database).

              This model might be generated and therefore located in a separate source folder for generated source (i.e. when generating the API definitions from an OpenAPI spec).

            • mapper
              The implementation of the mappers to map between the external representation of data (DTOs) and the internal representation defined in the application core (domain model).

              Implementation:
              The class name must end with Mapper.
              The class name must start with the name of the adapter it maps data for (i.e. TransactionJpaOutMapper). (Yeah, Java class names!)

      • application
        Contains the application services. This package together with the domain package make up the application core.

        • service
          A service implements incoming port interfaces and calls outgoing port interfaces (through dependency injection).

          Implementing an incoming port is a use case to execute. This could be coming from an UI, through an API, a message queue or any other way another system can talk to our application.

          Implementing an outgoing port is a way to load data, store/update state (i.e. in a database) or publish events (i.e. to a message queue).

          Each method (use-case) in a service usually also represents the transaction boundary.

          Implementation:
          Implement the interfaces defined in application.port.in.
          Call the interfaced defined in application.port.out (provided via dependency injection).

        • port
          Here we define the interfaces representing what use-cases or queries the application core can handle (incoming). And how the application core can reach out to things outside of it (outgoing).

          • in
            An incoming port exposes functionality of the application core to the outside world.

            There are two types of ports to distinguish. Use-cases and queries. A use-case triggers the application core to do something and typically results in data changing. A query asks the application core to simply collect and return data.

            To pass data to a use-case use a command. A command is a class or record that defines the input data for a use-case. The command should do input validation in its constructor to ensure a basic level of validity. The goal is to ensure that the content of a command is generally valid (i.e. that the amount field only contains numbers). Any validation more than that (i.e. that the account the user tries to enter a transaction for actually exists) must be implemented in the application service, since the application service can load additional data to perform more extensive validation.

            To pass data to a query we use a filter. A filter is a simple class or record containing details about what data to query. This can include search teams but also pagination, filtering and sorting. This data is technically not part of the domain but the outgoing adapter (loading the data) will need it later and therefore we’ll have to pass it through.

            Implementation:
            Use-cases and queries are interfaces. Their implementation can be found in application.service.
            The name of the interface must either end with UseCase or Query.
            The classes/records defining the input data for a use-case have a name that ends with Command.
            The classes/records defining the input data for a query have a name that ends with Filter.
            During creation, commands and filters perform input validation.
            The implementation of the interfaces defined in this package can be found in application.service.

          • out
            An outgoing port allows the application core to reach out to anything outside of it.

            The outside could be another bounded context, a database or an API provided by a 3rd party.

            Implementation:
            Outgoing ports are interfaces whose name end with Port.
            Methods should only accept parameters whose classes are defined in the application core (the domain model in domain.model). (There are exceptions from this rule. For example when you have to pass the filter/sorting parameters received through a query to an outgoing adapter.)
            The interfaces are implemented in adapter.out.

      • domain
        This is the application core that contains our domain model and services. Whenever possible domain logic should be part of the aggregates or entities. Domain services contain the logic that does not fit into a single entity or aggregate.

        • model
          Contains the classes making up the domain model (value objects, entities, aggregates).

          These classes contain any domain (business) logic that can be attributed to a single domain object. For any domain logic that cannot be matched clearly to a domain object, implement a domain service in domain.service..

        • service
          Contains the domain services. In domain services the domain specific logic that involves more than one domain object is implemented.

          Implementation:
          Domain service should be stateless. The transaction boundary is the responsibility of the application service. Therefore make sure not to introduce any side-effects here.

    • shared
      As the name suggest this package holds anything that is shared between more than one bounded context. Use it sparsely and only when you are sure it is actually needed by more than one context.

      • domain

        • model
          Here the same applies as in the domain package. Just add the sub-packages as required.

          Examples:
          Having a Money class is probably something that all bounded contexts in a personal finance app will need.

Changelog

14.01.2024

  • Publish the initial post.

1887 Words

2024-01-14