Effectively running Java on ServerlessHexagonal Architecture4 min

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.

Hexagonal

  1. Primary actors: Users of the system such as an API or UI call via HTTP, an event message, or a test script.

  2. 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).

  3. Ports: A design pattern for transforming one interface into another interface. They wrap the logic for interacting with a primary or secondary actor.

  4. 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.

  5. Secondary adapters: Used by the application, these adapters are either a Repository (for example, a database) or a Recipient (such as a message queue).

Java example

Let's take a simple example as shown in the perspective guidance for building a hexagonal architecture in Java.

HexagonalJavaExample

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.

HexagonalExtension

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


Created by:

Vipan Kumar
Vipan KumarSr. Solutions Architect