This article provides some opinionated guidelines for how to organize your Java service code that runs within a microservice. The “opinions” are guided by best practices in software architecture that include:
- Domain Driven Design
- Ports and Adapters (A.K.A Hexagonal Architecture)
Domain Driven Design
The Domain Driven Design patterns provide a good way to organize services into Domain Services and Application Services. The Domain Services are responsible for managing core data structures such as a user account, a customer, a product, etc. The Domain Service typically exposes code that creates, retrieves, updates, and deletes (CRUD operations) the data. Application Services are layered in front of the Domain Services to orchestrate requests that may involve multiple services or transform data received from or returned to the calling component. The distinction is good because it allows the software architect to focus first on the core data, and then define the operations that may be performed on the core data, and then the broader operations from Application Services that may combine or transform data managed by multiple Domain Services. The Domain Driven Design pattern provides guidelines to determine which Domain and Application Services belong within a given microservice (called a “bounded context”).
The following diagram illustrates Application Services (in yellow) and Domain Services (in green) that might be part of a multiplayer game. The dotted lines represent bounded contexts where one surrounds the game server and the others surround microservices. The arrows represent requests issued by Application Services on Domain Services. As a convention you typically do not have Domain Services invoking other Domain Services.
The data maintained by a Domain Service may “reference” data in another Domain Service using some identifier. For example, the Inventory Manager will have an identifier for the Player on the various items that are in the player’s inventory. This allows the Avatar Manager to get the list of items the player has “equipped” using the unique player ID. Each item probably also has identifiers for images and the 3D model maintained in the Content Manager. The Avatar Manager Application Service will aggregate all this data together and return it in a single response to the Game Engine so that the Game Server can create a Game Object representing the player’s avatar in a 3D scene.
All of the Domain Services likely maintain a persistent representation of the data in some kind of repository such as a database. The Domain Service is the single source of truth for the data it maintains. While the Application Services may cache data for efficiency, they likely do not keep a permanent representation of the data.
Ports and Adapters
In order to issue a request from a calling service to a remote service running inside another microservice, one typically uses a remote client component that collects the request arguments, encodes them into a request data packet for a particular communications transport protocol, and issues the remote request using some communications software. The communications software passes the request data over the network to some corresponding software running in the remote microservice. The networking software running in the microservice receives the network request, decodes the request data, and dispatches the request to some registered controller component. The controller component extracts the arguments from the request data, issues a request to the service running inside the microservice, and receives the results from the service call. It encodes the service result into the response data suitable for the transport protocol, and sends the response back to the remote client component that issued the original request. The remote client decodes the result data and returns a result to the calling service.
The Ports and Adapters pattern further decomposes the code used to implement services within a microservice. The Port is essentially the interface exposed by the Remote Service. The Port interface is independent of any communications protocol or framework. The Controller is an Adapter able to adapt the Remote Service Port to support a particular communications protocol. The Remote Client is also an Adapter able to accept requests and prepare them for transmission via the communications protocol and issue the remote request to the corresponding Controller.
The Ports and Adapter pattern insulates the Remote Service from the communications protocol so that it is possible to have different Adapters supporting different communications protocols that issue requests between the Calling Service and the Remote Service. To achieve this ideal, the Remote Client needs to expose an interface (a Port) that is specific to the Remote Service, but independent of the communications protocol. The Port interface exposed by the Remote Client and the Port interface exposed by the Remote Service can be identical, or might be slightly different for example to address the passing of security credentials.
Java Service Code Organization
The first level of code organization follows from the desire to reuse the same remote client adapters in all the various components that need to invoke the service remotely. Therefore, the code used to specify the service, and specify and implement the remote client adapter goes into a separate project from the code that implements the controller adapter and the service implementation. One could put the two projects in separate source control repositories, but I recommend putting them in the same repository named after the microservice.
For example, if there is a microservice that manages secrets (such as cryptographic keys), then the repository might be called, secret-manager. The top-level of this source code repository will have two projects:
- client
- server
The build system for the client project produces and publishes a single versioned Jar file (to a binary repository) that contains the code for all the service specifications, the client adapter specifications and the client adapter implementations. The build system for the server project produces and publishes a versioned Docker image that contains everything required to run the Java process (e.g. a lightweight OS such as Alpine Linux, the Java Runtime Environment, the dependent Jar files, and a Jar file with the class files for the microservice) with the code for the controller adapters and service implementations. The server project build system pulls in the Jar file from the binary repository for the client as a dependency.
The Maven/Gradle project structure promotes a file organization as follows:
- src/
- main/
- java/
- resources/
- test/
- java/
- resources/
- main/
The Jave source code is organized below the java folder for the operational code (below main) and test code (below test). Resource files (such as a logger configuration) are organized below the resources folder. The standard Java convention organizes the Java source files in a folder hierarchy following the Java Package namespace. For example, if the root package namespace is com.worthent (for the developer organization) and the microservice is called secret-manager, then the top-level package namespace might be com.worthent.secret. Then organize the source files below the java folder as follows:
- src/main/java/com/worthent/secret/
- adapter/
- service/
We organize al the adapter source code below the adapter package/namespace and all the service code below the service package/namespace.
Let us say the secret-manager microservice has three services in it:
- Key Manager: creates, retrieves, lists, and deletes cryptographic keys
- Secret Manager: creates, retrieves, updates, lists, and deletes secret files (as binary streams)
- Token Manager: provisions Java Web Tokens (JWTs)
These three services are collocated in the same microservice due to a tight coupling among the services and to avoid circular calling dependencies among microservices. The Secret Manager maintains secrets in a persistent repository (such as the AWS S3 service). The Key Manager uses the Secret Manager to persist public and private keys. The Token Manager provisions and signs JWTs using private keys managed by the Key Manager.
Let us further say that we would like to use the HTTP/REST communication protocol to invoke the services remotely. Given the three services and the communication protocol, our package namespace and file organization is now as follows:
- src/main/java/com/worthent/secret/
- adapter/
- key/
- rest/
- secret/
- rest/
- token/
- rest/
- key/
- service/
- key/
- internal/
- secret/
- internal/
- token/
- internal/
- key/
- adapter/
The above package namespace organization exists in both the client and server projects. The code in the two projects is obviously different.
There are three subfolders below the adapter and service folders, one for each service: key, secret and token. These folders in the client project host the Java files that specify the remote client adapter interfaces and the service interfaces respectively (the Ports). The interface specification folders include Java source files with the Java interface for each remote client adapter and service. The service interface folders also include any enumerated types and exception classes for the services. The service interface folders also include Java interface files that specify the getters for Value Objects (classes that pass service arguments and return results) and build methods for Builders that collect and set the fields for the Value Objects passed as arguments or returned as results.
Note that each of the service-specific adapter folders above contains a “rest” subfolder. These subfolders are named according to the supported communications protocol. Recall that the remote client interfaces are devoid of any specific communication protocol data or logic (they are pure Plan Old Java Objects or POJOs). The remote client adapter implementation contains all the logic and artifacts required to deliver the service arguments over the network to the remote service and process the results. If a given service exposes multiple adapters using different communication protocols (e.g. one REST and another Message Queue protocol), then there will be multiple implementations of the remote client adapter interface, each below a separate folder named according to the communication protocol.
The “rest” folders in the client project include a REST client implementation of the client adapter interface. The folder also includes JSON objects with JsonCreator and JsonProperty annotations used by the Faster Jackson Object Mapper to convert the Java classes to and from JSON. These JSON object classes each correspond to a Value Object class (they actually “extend” them) specified in the service or remote client adapter interface and carry service request argument data and result data (they are Data Transfer Objects or DTOs).
The “rest” folders in the server project include a REST controller implementation with the code that receives the request arguments, validates them, performs authentication and authorization checks, dispatches the request data to the corresponding service method, receives the service response, prepares the result data, and returns the result data to the calling remote client adapter. The controller adapter is constructed with a reference to the corresponding service using the interface specified in the client project (it does not reference the service implementation directly).
Note that each of the service folders above contains an “internal” subfolder. This subfolder in the client project contains the implementation of the Value Object and Builder classes used to carry the service arguments and results. This subfolder in the server project contains the service implementation classes.
There are some important self-imposed restrictions on the implementation code:
- A service implementation or adapter implementation must never directly reference an implementation in another service or adapter. In other words, the code from one implementation must never “import” a class from another.
- A service implementation can only reference another service through its service interface (when in the same microservice) or its remote client interface (when in another microservice)
- A controller adapter should only reference the one service it is designated to adapt. If you think you want to have it access more than one, then create an Application Service that references other services and have the controller instead reference the one Application Service.
Conclusion
The Java source code organization and self-imposed restrictions described here provides a number of benefits:
- It is easy to move a service and its adapters from one microservice to another (just recursively copy the folders from one service project to the other)
- It is easy to write test code for the services and adapters
- It is easy to reuse the remote client adapter code in all the components that call the same remote service
- It is possible to generate the service specification and adapter code from brief descriptions