
Loose coupling is a key guiding principle when building complex systems to ensure good quality and agility in development for faster development cycles and easier adaptability to change. Systems should be designed with evolutionary architecture for agility. In the book Building Evolutionary Architectures, this architecture is defined as one that supports guided, incremental change across multiple dimensions.
Hexagonal Architecture is a pattern to build modular systems that allows for various components of the system to evolve independently. This architectural pattern allows for decoupling of domain logic from implementation details, such as infrastructure, protocols, etc.

Primary actors: Users of the system such as an API or UI call via HTTP, an event message, or a test script.
Primary adapters: They are a way for the primary actors (1) to interact with the application, via the domain logic. The domain logic also uses ports for interacting with secondary actors (5).
Ports: A design pattern for transforming one interface into another interface. They wrap the logic for interacting with a primary or secondary actor.
Domain logic: Represents the task that the application should perform, abstracting any interaction with the external world. All the business logic should be encapsulated in this component.
Secondary adapters: Used by the application, these adapters are either a Repository (for example, a database) or a Recipient (such as a message queue).
Let's take a simple example as shown in the perspective guidance for building a hexagonal architecture in Java.

An Amazon API Gateway endpoint acts as the primary actor and invokes the Lambda function as its backend.
The Lambda function acts as the primary adapter that uses a port in order to use the underlying business logic
present in the domain to create a Unicorn.
The underlying domain is responsible for creating the unicorn that is abstracted away from the Lambda function via
port.
In this example, the Lambda function code would look like the following:
Primary Adapter
public class CreateUnicornFunction implements RequestHandler<
APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
private CreateUnicorn port;
@Override
public APIGatewayProxyResponseEvent handleRequest(
APIGatewayProxyRequestEvent event,
Context context
) {
// Step 1: Create newUnicorn object from event payload
// Step 2: Call port with the newUnicorn object
Unicorn unicorn = port.createUnicorn(newUnicorn);
// Step 3: Send response back
}
}
Ports
Now, let's look at the port.
CreateUnicorn is an interface which will be implemented by a service that is part of the underlying domain logic.
public interface CreateUnicorn {
Unicorn createUnicorn(Unicorn unicorn);
}
The domain logic will also need another port to handle persistence.
In this case, to persist the new Unicorn in the database.
public interface UnicornDatabasePort {
Unicorn createUnicorn(Unicorn unicorn);
}
A secondary adapter (DynamoDB adapter in this case) has to implement the logic specifically to persist
the domain (Unicorn) to a DynamoDB database.
It will be clear when we look at the domain logic next.
Domain logic
The core domain logic includes the structure of Unicorn and the UnicornService that holds the business logic.
public class Unicorn {
private String id;
private String name;
private String age;
private String size;
private String type;
}
Now, the service class UnicornService implements CreateUnicorn port and internally uses the
UnicornDatabasePort to create unicorn.
The UnicornDatabasePort totally abstracts the underlying implementation of persistence.
public class UnicornService implements CreateUnicorn {
private UnicornDatabasePort dbPort;
@Overrides
public Unicorn createUnicorn(Unicorn unicorn) {
logger.info("Creating unicorn..");
unicorn.setId(UUID.randomUUID().toString());
unicorn.setName(this.formatName(unicorn.getName()));
return dbPort.createUnicorn(unicorn);
}
// This mimics a business logic
private String formatName(String name) {
return !StringUtils.isBlank(name) ?
name.toUpperCase() : name;
}
}
Secondary Adapter
A secondary adapter implements the logic that is required and has been abstracted away from the core domain. In this case, it is the logic behind persistence.
public class UnicornDynamoDBAdapter implements UnicornDatabasePort {
private DynamoDbAsyncClient dynamoDbClient;
private static final String UNICORN_TABLE = "Unicorn";
public UnicornDynamoDBAdapter() {
AwsCredentialsProvider provider = DefaultCredentialsProvider.create();
SdkAsyncHttpClient sdkAsyncHttpClient = AwsCrtAsyncHttpClient.builder()
.maxConcurrency(20)
.build();
try {
this.dynamoDbClient = DynamoDbAsyncClient.builder()
.credentialsProvider(provider)
.httpClient(sdkAsyncHttpClient)
.build();
this.dynamoDbClient.describeEndpoints();
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
}
public Unicorn createUnicorn(Unicorn unicorn) {
Map<String, AttributeValue> itemAttributes = Map.of(
"id", AttributeValue.builder().s(unicorn.getId()).build(),
"type", AttributeValue.builder().s(unicorn.getType()).build(),
"name", AttributeValue.builder().s(unicorn.getName()).build(),
"age", AttributeValue.builder().s(unicorn.getAge()).build(),
"size", AttributeValue.builder().s(unicorn.getSize()).build()
);
dynamoDbClient.putItem(
PutItemRequest.builder()
.tableName(UNICORN_TABLE)
.item(itemAttributes)
.build()
).get();
return unicorn;
}
}
In the future, if the requirement demands to use Amazon RDS, then a new secondary adapter for
RDS can be added and injected to the domain service via the UnicornDatabasePort port.
This is not limited to just secondary adapters. You can extend the simple example from above to look like below architecture. The main goal is to keep the domain logic abstracted away from different adapters by using necessary ports.

To learn more about Hexagonal Architecture, read this guidance and blog.