Domain-Driven Design Meets Hexagonal Architecture: A Guide to Future-Proof Applications

Kalpa Senanayake
Level Up Coding
Published in
16 min readSep 9, 2023

--

The beautiful city of Vienna, Austria, at sunset from a rooftop bar by Kalpa Senanayake

A timeless goal of software engineering has been to separate code that changes frequently from stable code — James Copline /Lean Architecture.

Introduction

The inevitable reality of our software applications is that they will change for many reasons over time. There exists a part of the software that represents core real-world business functionalities for our organisation that changes less frequently, and there are parts that frequently change.

A common challenge for product engineering teams is understanding our product’s core domain. Some teams have business-oriented products, and some are enabler products that indirectly help the business products.
In any business, there are domain experts who understand the core business functionalities well. Product engineering teams can leverage these experts and work with them collaboratively to understand the business domain and establish a common language. There are many approaches to establishing that understanding. One of the ways to understand product problem space is to use Domain-Driven-Design and Event Storming.

Another challenge is keeping the core domain away from the other parts of the software. The article explains one approach to do that, however There may be many different ways to do this. This approach is battle-tested with proven results to build quality, maintainable software. It is called Hexagonal Architecture. This article will dive deep into it and show a practical use of Driven Design with Hexagonal Architecture.

This article introduces Domain-Driven-Design and Hexagonal architecture and in-depth practical guidance to combine both patterns to address the above challenges and build maintainable software applications.

Domain-Driven Design

Domain-driven design is an approach to software development that centers on programming a domain model with a rich understanding of the processes and rules of the domain.

Domain-driven design is about aligning software models closely with the business domain to solve real-world problems effectively. It promotes collaboration between technical and domain experts to ensure software meets business needs.

The following are the main building blocks of Domain Driven Design.

The Ubiquitous Language: A common language developed by developers and domain experts to ensure that domain concepts are consistently and unambiguously understood across the team. Ubiquitous language is by far the most crucial aspect of DDD.

Domain experts have their language and terminology to explain business entities and processes. They use these language constructs to communicate business requirements, but software engineers usually ignore these and model the business requirements into interfaces, classes, and functions. During the translation process, ambiguity and uncertainty sneaks into the software. Using common language between engineers and domain experts is the way to solve this problem.

Domain Logic: The collection of specialised knowledge and rules that dictate how a specific business domain operates and evolves.

Domain Model: An abstraction representing the core concepts, logic, and processes within a specific business domain, often expressed as a combination of entities, value objects, aggregates, and services.

Subdomain: A smaller, more focused area within a larger domain dealing with a particular aspect or function of the business. For example, “billing” might be a subdomain within a larger “payment” domain.

Bounded Context: A clear boundary within which a specific domain model is defined and applicable. Within this context, terms and concepts have explicit meanings, preventing ambiguities that can arise in large systems.

Entities: Objects with a distinct identity and a lifecycle within the domain model. Their identity remains consistent even if their attributes change.

Value Objects and Aggregates: Value objects are immutable objects defined only by their attributes and do not possess a distinct identity. Aggregates are clusters of entities and value objects treated as a single unit for data changes, ensuring consistency.

Domain Service: Operations, actions, or logic that don’t naturally belong to an entity or value object but are essential within the domain. These services encapsulate domain logic that doesn’t fit within the context of a single entity or value object.

Repository: A pattern that provides an abstraction layer for accessing and persisting aggregates, making it easier to retrieve or save domain objects without dealing directly with data storage concerns.

Unlock Business Mastery with Domain-Driven Design: Your Gateway to Understanding Business Processes

One tool offered by domain-driven design is Event storm. Event Storm is a collaborative session with domain experts, engineers, product owners, business analysts, etc. This session aims to understand the business process as a holistic entity. It is a rapid, lightweight, and visual domain exploration method.

Imagine that you have time-traveled to 1900, and you and your team are running a retail department store company with a brick-and-mortar store, and all your ledgers are physical files. Computers or information technology will not be invented until another 40–45 years.

This mindset will help you avoid client-server technology, data formats, databases, cloud computing, single-page-web-applications, mobile applications, and everything you know about running a business in 21 century.

The purpose is to understand how this department store works with its various business functions working with one another.

Domain Events: These are the indicators of something that has happened within our department store related to the business process.

  • A customer entered the store.
  • Item added to cart.
  • Checkout initiated.
  • Payment completed.
  • Product returned.

Aggregates: In Domain Driven Design, it’s a cluster of domain objects that can be treated as a single unit.

  • Cart: Contains a list of items, their quantities, and methods to add/remove items. In our case, this is a physical cart.
  • Order: Contains information about a placed order, including payment details and items.

Commands: They represent a request to perform an action or change from a customer or store manager.

  • Add item to cart.
  • Remove the item from cart.
  • Finalize purchase.

Read Models: These are physical books in our 20th-century department store.

  • ProductCatalog: Provides details about available products, their prices, and stocks.
  • OrderHistory: Lists all orders made by a customer.

Bounded Contexts: These define the limits within which a particular model is defined and applicable.

  • Sales: Deals with selling items, processing payments, etc.
  • Inventory Management: Manages stock levels reordering.

Ubiquitous Language: The common language that evolves between business experts and developers. It’s essential that an “Order” in the sales context means the same thing to an engineer and a store manager.

  • Cart: The temporary holding of items a customer wishes to purchase.
  • Stock: The available quantity of a particular item in the store.
  • Checkout: The process of finalising a purchase.

Even though this example is simple, it has enough complexity to convey the idea behind using Event Storm to understand the business process.

Event storm results for department store

Now we understand the business process via event storm and domain-driven design terminology; it is time to dive into other pillars of this bridge.

Hexagonal Architecture

Why do we need another architecture?

Before diving into Hexagonal Architecture, let’s start with the well-known layered architecture and ask why we need another architecture pattern.

Traditional layered architecture (n-tier architecture) of applications

The above diagram shows the traditional layered architecture, which we all know. The idea behind that approach is that the system is built of layers stacked on each other.

Each layer has a distinct responsibility. This separation facilitates modularity, scalability, and maintainability, and the idea is to swap or upgrade layers independently when the changes are required.

While this is getting faster results for product engineering and that business team, there is a ticking time bomb hiding in plain sight.

  • The layers are tightly coupled to each other.
  • Layer logic and dependencies grow over time and become hard to change.

What problems arise from having tightly coupled layers?

  • It is hard to test each layer independently, notably hard to test the domain in isolation. Remember, domain layers hold the core of your business. It is essential to have the ability to try this independently. Hence, when developers ask to change logic in the domain, they fear things may break and do the bare minimum to implement new logic.
  • Reusing components are more complex due to high coupling.
  • Switching protocols, data formats, and databases requires considerable effort, and your business gets frustrated about small changes taking a significant effort.
  • Infrastructure concepts like HTTP, ORM, and Frameworks tend to leak into the domain. Hence, it requires considerable effort in changes and refactoring.
  • It is easy to skip layers and take harmful shortcuts like calling the application layer method from a domain layer.
  • Package by layer, which makes it hard to reveal software intention and domain boundaries.
  • Require lots of engineering discipline from the developers not to violate the dependency direction.

A real-world example

Let’s have a look at a real-world example. We have a microservice with internal architecture constructed using layered architectural principles.
Initially, when the business requirements are simple, and the application itself is relatively simple.

@RestController(value = "/payments")
public class PaymentController {
private final PaymentService paymentService;
private final RequestMapper mapper;

@Autowired
public PaymentController(PaymentService paymentService, RequestMapper mapper) {
this.paymentService = paymentService;
this.mapper = mapper;
}

@PostMapping
ResponseEntity<String> createPayment(@RequestBody PaymentRequest request) {
String paymentId = paymentService.createPayment(mapper.map(request));
return ResponseEntity.ok(paymentId);
}
}

@Service
public class PaymentService {
private final PaymentDao paymentDao;

@Autowired
public PaymentService(PaymentDao paymentDao) {
this.paymentDao = paymentDao;
}

public String createPayment(CreatePaymentCommand command) {
PaymentEntity entity = convertCommandToEntity(command);
return paymentDao.savePayment(entity);
}
}

@Repository
public class PaymentDao {

public String savePayment(PaymentEntity entity) {
return save(entity);
}

}

After a few years, the payment service requires many other services to fulfill its tasks. Along the way, many things have been changed; the team delivered several critical features on top of the original payment application. Now, it’s not the simple, innocent application we used to have.

@Service
public class PaymentService {
private final PaymentValidationService validationService;
private final FeatureFlagsService featureFlagsService;
private final EmailService emailService;
private final LoyalityService loyalityService;
private final WalletService walletService;
private final PaymentDao paymentDao;


@Autowired
public PaymentService(PaymentValidationService validationService,
FeatureFlagsService featureFlagsService,
EmailService emailService,
LoyalityService loyalityService,
WalletService walletService,
PaymentDao paymentDao) {
this.validationService = validationService;
this.featureFlagsService = featureFlagsService;
this.emailService = emailService;
this.loyalityService = loyalityService;
this.walletService = walletService;
this.paymentDao = paymentDao;
}

public String createPayment(CreatePaymentCommand command) {
featureFlagsService.enabled(PAYMENT_SERVICE_002);
boolean valid = validationService.validate(command);
if (valid) {
boolean hasFunds = walletService.check(command);
if (hasFund) {
loyalityService.deteminePoints(command);
// many other logics
PaymentEntity entity = convertCommandToEntity(command);
return paymentDao.savePayment(entity);
} else {
// some error
}
} else {
// invalid payment error
}
}

}

One could argue that this does not have to be like this; basic SOLID principles can significantly improve this code. That is true, and this does not have to look like this. At the same time, one could be surprised at how many real-world application codes are like this.
This code snippet is trying to convey that over time, simple becomes complex, and the layered architecture approach cannot deal with that while protecting the domain logic.

If we look at the dependency graph for the payment service, it will look like below.

Dependecy Graph

Imagine changing to “PaymentService.” The IDE light-up complaints changes from many of these dependencies when we touch the code.

And once we fixed all that, the test cases were unhappy, so we fixed those, pushed the code, and deployed.
But, during the smoke test, we may find that wallets are not updating correctly.
Since there was no mention of the changes related to the wallet, we have not considered changing or testing the wallet.
However, there were scenarios where the wallet service interacted with the payment service inversely. We did not know we had to update the wallet service logic.

Does this sound familiar?

If that sounds familiar, then you have already experienced the problem with layered architecture and its shortcomings in developing software for complex business processes.

The problem is that when the world around the business logic changes, the level of abstraction provided by the layered architecture is not capable enough to protect the critical business logic from the external world.

Over time, essential business logic mixed with concepts like databases, feature flags, validation, etc, makes it hard to change.

This why we need another architecture.

Hexagonal Architecture

Hexagonal architecture can help you to solve the problems packaged with layer architecture. We are going to take a look step by step.

  • Our first goal is to protect the domain logic from the rest of the logic.
  • The “Inside” is the business logic code, which changes less frequently and is most valuable.
  • The “Outside” is the rest of the application, infrastructure logic, which adjusts often and should be replaceable.

Inside Vs Outside

Inside Vs OutSide in Hexagon

The idea of Hexagonal Architecture is to put inputs and outputs at the edges of our design. Business logic should not depend on whether we expose a REST or a GraphQL API, and it should not depend on where we get data from — a database, a microservice API exposed via gRPC or REST, or just a simple CSV file.

The pattern allows us to isolate the core logic of our application from outside concerns. Having our core logic isolated means we can easily change data source details without a significant impact or major code rewrites to the codebase.

— Nexflix Tech Blog

Now that we know what is inside Vs outside, let’s zoom in briefly.

Infrastructure, application, Domain layers

A bit more zoom-in

Source https://netflixtechblog.com/ready-for-changes-with-hexagonal-architecture-b315ec967749

At this level of zoom, we can see that keeping the domain entities protected from the rest of the application is the most crucial aspect of the Hexagonal Architecture, and it shows how to do that using contracts between Domain logic (Ports) and Infrastructure (Adapters). Hence, some refer to this architecture as ports and adapters architecture.

Note the direction of dependency in this Image; the outside can refer to the inside but not vice versa; this is key to protecting the inside.

If the domain logic starts to refer outside, for example, let’s say the domain logic has framework library references or controller references, the moment we change the framework, the blast radius is enormous.

It is time to dive deep into the hexagonal architecture and learn how it works.

Driver and Driven

The application we develop has a driven side (Users, Tests, SPA, Mobile Apps, Queues, Topics) and a driven side (repository, Remote Application, Queue)

Driver and Driven sides

Driver side: The communication is started by the driver

Driven side: The communication is started by the application

Ports and Adapters

Port and Adapters provide flexibility while shielding the domain logic from outside

Port: A Port is an interface that defines a set of operations that one side of the application (typically the core business logic) exposes to the other side. Think of a port as a contract representing a specific use-case’s input and output methods.

Driver Port: Driver Ports are the primary means by which the external actors (like users or external systems) drive the application to perform some action. It’s the interface that the application’s primary use cases implement.

Driven Port: Driven Ports represent the application’s secondary interfaces to communicate with external systems or components (like databases, messaging systems, or third-party services).

Adapters: Adapters bridge the application and the external actors or systems. They match the technology-specific methods and data formats to the use-case-specific Ports.

Driver Adapters: A Driver Adapter interprets and translates specific technology requests into technology-agnostic requests that are fed into the application through a Driver Port.

  • Web REST API Adapter: Allows REST clients to drive the application, often through HTTP requests.
  • CLI (Command-Line Interface) Adapter: Lets users or other systems drive the application through command-line commands.
  • Kafka Consumer Adapter: Enables Kafka message streams to initiate actions or changes in the application.

Driven Adapters: A Driven Adapter takes the application’s technology-agnostic requests, as determined by the Driven Port, and converts them into specific technology interactions or requests.

  • SQL Database Adapter: Allows the system to persist or retrieve data through a Driven Port.
  • SMTP Mail Adapter: Allows the system to send emails through a Driven Port.
  • Cloud Storage Adapter (e.g., AWS S3 Adapter): Lets the system store or retrieve files/blobs through a Driven Port.

How does it is going to help me when we make changes?

Let’s take real-world scenario-based examples.

Scenario 1 : Transitioning from REST to GraphQL

Business wants to provide efficient data retrieval for mobile clients, hence required to redefine data retrieval methods more flexibly, adhering to the new GraphQL standards without altering the business logic contracts defined in the ports.

Current State: Web REST API Adapter

Operations: Handle HTTP methods such as GET, POST, PUT, and DELETE.

Data Exchange: Exchanges data in JSON or XML format.

New State: Web GraphQL Adapter

Operations: Handles queries and mutations, offering more flexibility and efficiency in fetching.

Data Exchange: Allows clients to request the data they need, avoiding over-fetching or under-fetching of data.

We can swap the REST adapter with the GraphQL adapter without changing the core business logic.

Scenario 2 : Switching from Kafka to RabbitMQ

The organisation got a better deal with RabbitMQ through a strategic partnership, and running the current Kafaka cluster is expensive.

Current State: Kafka Consumer Adapter

Message Processing: Processes stream of messages using topics and partitions.

New State: RabbitMQ Consumer Adapter

Message Processing: Utilises queues for message processing and supports various messaging protocols.

Allows an easy swap of messaging systems without affecting the core application.

Scenario 3 : Changing from SQL to NoSQL

The business is scaling at neck-breaking speed; the relational database does not support horizontal scaling. The company wants you to switch to a NoSQL database to scale horizontally, meeting the demands of high-velocity data.

Current State: SQL Database Adapter

Data Storage: Relational data storage using tables, rows, and columns.

New State: NoSQL Database Adapter

Data Storage: Flexible data storage, supports various data models including document, key-value, wide-column, and graph.

Allows for the easy implementation of different data models without affecting the business logic.

Combining DDD with Hexagonal Architecture

The next step is to combine these two approaches. Domain-Driven-Desing to model the business process and hexagonal architecture to protect that business logic from the outside. That is as simple as that.

The domain logic and model comprise all the Domain-Driven-Desing constructs we learn in the domain-driven design segment.

The rules set by Hexagonal Architecture govern the application and infrastructure layer.

Let’s explore this with an example application.

The business requirements for this sample application are simple, and the logic it implements is also simple. Hence, the focus should be on the architecture and how things glue together instead of the business logic.

At the moment the business requirement is as follows.

  • A customer should be able to create an order.
  • The customer should receive an email with the order confirmation.
Real world application strcuture mapping into the Hexagonal Architecture

Driver Ports

This contains only the use cases that act as a domain port.

  • There are two use cases here, CreateOrder and NotifyCustomer.
  • Use cases are mapping to commands from the event storm.
  • They interact with the domain layer through contracts.

Driven ports

  • We need an interface to deal with the domain and save it. OrderPort defines the contract for it.
  • We need an interface to publish the domain event OrderCreated.
@DrivenPort
public interface OrderPort {
Order create(Order order);
Optional<Order> getOrderById(String id);
}

@DrivenPort
public interface OrderEventPublisher {
void publish(OrderCreatedEvent orderCreatedEvent);
}

Driver Adapter

  • The OrderResrAdapter interacts with the UseCase.
  • The OrderResrAdapter does not know how the UseCase gets handled; “How” is completely decoupled from the input adapter via the internal UseCase event bus.
  • The OrderResrAdapter converts the HTTP request entity into a contract set by CreateOrder .And once the order is created, the Order is transformed into an HTTP response entity.
  • Hence, it keeps the infrastructure details and the framework away from the domain logic.
  • The OrderEventListner interacts with UseCases
  • Like the OrderResrAdapter, OrderEventListner also does not know how the domain event OrderCreatedEvent is handled or when it gets handled.
@RestController
@RequestMapping("/v1")
@Adapter
public class OrderRestAdapter extends BaseController {
private final OrderRestMapper orderRestMapper;
public OrderRestAdapter(OrderRestMapper orderRestMapper) {
this.orderRestMapper = orderRestMapper;
}

@PostMapping("/orders")
public ResponseEntity<OrderCreateResponse> createOrder(@RequestBody OrderCreateRequest request) {
var createOrderUseCase = orderRestMapper.toProduct(request);
Order order = publish(Order.class, createOrderUseCase);
return new ResponseEntity<>(orderRestMapper.toProductCreateResponse(order), HttpStatus.CREATED);
}
}

@Component
@Slf4j
class OrderEventListener extends BeanAwareUseCasePublisher {

@EventListener
void handle(OrderCreatedEvent event) {
log.info("Order created with id {}", event.getCustomer());
try {
publish(event.toUseCase());
} catch (OrderApiException e) {
log.error("Failed to send the notification to customer for notification id {}", event.getCustomer(), e);
}
}
}

Driven Adapters

  • The OrderPerisitanceAdapter implements the OrderPort and obeys the contract set by OrderPort
  • Which database we use, how the ORM is handled, and all of these infrastructure details kept out of the domain.
  • The OrderEventPublisherAdapter implements the OrderEventPublisher and obeys the contract set by OrderEventPublisher.
  • The OrderEventPublisherAdapter now knows how this event will be handled.
@Adapter
public class OrderPersistenceAdapter implements OrderPort {
private final OrderRepository orderRepository;
private final OrderPersistenceMapper mapper;

public OrderPersistenceAdapter(OrderRepository orderRepository, OrderPersistenceMapper mapper) {
this.orderRepository = orderRepository;
this.mapper = mapper;
}

@Override
public Order create(Order order) {
var orderEntity = mapper.toOrderEntity(order);
orderRepository.save(orderEntity);
return order;
}

@Override
public Optional<Order> getOrderById(String id) {
return Optional.empty();
}
}

@Adapter
public class OrderEventPublisherAdapter implements OrderEventPublisher {
private final ApplicationEventPublisher applicationEventPublisher;

public OrderEventPublisherAdapter(ApplicationEventPublisher applicationEventPublisher) {
this.applicationEventPublisher = applicationEventPublisher;
}

@Override
public void publish(OrderCreatedEvent orderCreatedEvent) {
applicationEventPublisher.publishEvent(orderCreatedEvent);
}
}

Domain

  • The domain is free from the application layer and consists of ports and an infrastructure layer of adapters.
  • We can change the application and the infrastructure without changing the domain.
  • Domain only gets changed when the business rules get changed.
  • Domain logic can be unit-tested to 100%.
  • Domain objects can not be created without the aggregate roots; this forces the integrity of business logic.
  • All domain objects are immutable, with no “setters,” and no arbitrary mutations are allowed. Hence, the code is easy to reason with.
  • Each layer talks to one another via immutable objects; there is a price to pay when we need mutation; we must create a new thing. Memory is cheap, but the engineering time is expensive.

Example application can be found at https://github.com/KalpaD/ddd_with_hex/tree/main

Conclusion

In the ever-evolving landscape of software development, Domain-Driven Design and Hexagonal Architecture harmonising can be used to build maintainable applications effectively.
This strategy is exemplified in a given project; even though it is written in Java, the concepts and design approach are language and framework-agnostic. Let this be a call to action for product engineering teams to embrace these battle-tested methodologies, embarking on a journey towards building resilient, maintainable software applications.

References

Netflix Technology Blog : https://netflixtechblog.com/ready-for-changes-with-hexagonal-architecture-b315ec967749

What is DDD — Eric Evans — DDD Europe 2019 : https://youtu.be/pMuiVlnGqjk?feature=shared

Event Storming — Alberto Brandolini — DDD Europe 2019 : https://youtu.be/mLXQIYEwK24?feature=shared

--

--