DDD-gebundene Kontexte und Java-Module

1. Übersicht

Domain-Driven Design (DDD) ist eine Reihe von Prinzipien und Tools, mit denen wir effektive Softwarearchitekturen entwerfen können, um einen höheren Geschäftswert zu erzielen . Bounded Context ist eines der zentralen und wesentlichen Muster zur Rettung der Architektur vor dem Big Ball Of Mud, indem die gesamte Anwendungsdomäne in mehrere semantisch konsistente Teile aufgeteilt wird.

Gleichzeitig können wir mit dem Java 9-Modulsystem stark gekapselte Module erstellen.

In diesem Lernprogramm erstellen wir eine einfache Speicheranwendung und erfahren, wie Sie Java 9-Module nutzen und explizite Grenzen für begrenzte Kontexte definieren.

2. DDD-begrenzte Kontexte

Heutzutage sind Softwaresysteme keine einfachen CRUD-Anwendungen. Tatsächlich besteht das typische monolithische Unternehmenssystem aus einer alten Codebasis und neu hinzugefügten Funktionen. Es wird jedoch immer schwieriger, solche Systeme mit jeder vorgenommenen Änderung zu warten. Schließlich kann es völlig unhaltbar werden.

2.1. Begrenzter Kontext und allgegenwärtige Sprache

Um das angesprochene Problem zu lösen, bietet DDD das Konzept des gebundenen Kontexts an. Ein begrenzter Kontext ist eine logische Grenze einer Domäne, in der bestimmte Begriffe und Regeln konsistent gelten . Innerhalb dieser Grenze bilden alle Begriffe, Definitionen und Konzepte die allgegenwärtige Sprache.

Der Hauptvorteil der allgegenwärtigen Sprache besteht insbesondere darin, Projektmitglieder aus verschiedenen Bereichen in einem bestimmten Geschäftsbereich zusammenzufassen.

Darüber hinaus können mehrere Kontexte mit derselben Sache arbeiten. Es kann jedoch in jedem dieser Kontexte unterschiedliche Bedeutungen haben.

2.2. Bestellkontext

Beginnen wir mit der Implementierung unserer Anwendung, indem wir den Auftragskontext definieren. Dieser Kontext enthält zwei Entitäten: OrderItem und CustomerOrder .

Die CustomerOrder- Entität ist ein aggregierter Stamm:

public class CustomerOrder { private int orderId; private String paymentMethod; private String address; private List orderItems; public float calculateTotalPrice() { return orderItems.stream().map(OrderItem::getTotalPrice) .reduce(0F, Float::sum); } }

Wie wir sehen können, enthält diese Klasse die calculateTotalPrice Business - Methode. Aber in einem realen Projekt wird es wahrscheinlich viel komplizierter sein - zum Beispiel einschließlich Rabatten und Steuern im Endpreis.

Als Nächstes erstellen wir die OrderItem- Klasse:

public class OrderItem { private int productId; private int quantity; private float unitPrice; private float unitWeight; }

Wir haben Entitäten definiert, müssen aber auch einige APIs für andere Teile der Anwendung verfügbar machen. Erstellen wir die CustomerOrderService- Klasse:

public class CustomerOrderService implements OrderService { public static final String EVENT_ORDER_READY_FOR_SHIPMENT = "OrderReadyForShipmentEvent"; private CustomerOrderRepository orderRepository; private EventBus eventBus; @Override public void placeOrder(CustomerOrder order) { this.orderRepository.saveCustomerOrder(order); Map payload = new HashMap(); payload.put("order_id", String.valueOf(order.getOrderId())); ApplicationEvent event = new ApplicationEvent(payload) { @Override public String getType() { return EVENT_ORDER_READY_FOR_SHIPMENT; } }; this.eventBus.publish(event); } }

Hier haben wir einige wichtige Punkte hervorzuheben. Die placeOrder- Methode ist für die Verarbeitung von Kundenaufträgen verantwortlich. Nachdem eine Bestellung bearbeitet wurde, wird das Ereignis im EventBus veröffentlicht . Wir werden die ereignisgesteuerte Kommunikation in den nächsten Kapiteln diskutieren. Dieser Service bietet die Standardimplementierung für die OrderService- Schnittstelle:

public interface OrderService extends ApplicationService { void placeOrder(CustomerOrder order); void setOrderRepository(CustomerOrderRepository orderRepository); }

Darüber hinaus muss der CustomerOrderRepository für diesen Service Bestellungen beibehalten:

public interface CustomerOrderRepository { void saveCustomerOrder(CustomerOrder order); }

Wichtig ist, dass diese Schnittstelle nicht in diesem Kontext implementiert ist, sondern vom Infrastrukturmodul bereitgestellt wird, wie wir später sehen werden.

2.3. Versandkontext

Definieren wir nun den Versandkontext. Es ist außerdem unkompliziert und enthält drei Entitäten: Parcel , PackageItem und ShippableOrder .

Beginnen wir mit der Entität ShippableOrder :

public class ShippableOrder { private int orderId; private String address; private List packageItems; }

In diesem Fall enthält die Entität das Feld " Zahlungsmethode" nicht . Das liegt daran, dass es uns in unserem Versandkontext egal ist, welche Zahlungsmethode verwendet wird. Der Versandkontext ist nur für die Bearbeitung von Auftragssendungen verantwortlich.

Die Paketentität ist außerdem spezifisch für den Versandkontext:

public class Parcel { private int orderId; private String address; private String trackingId; private List packageItems; public float calculateTotalWeight() { return packageItems.stream().map(PackageItem::getWeight) .reduce(0F, Float::sum); } public boolean isTaxable() { return calculateEstimatedValue() > 100; } public float calculateEstimatedValue() { return packageItems.stream().map(PackageItem::getWeight) .reduce(0F, Float::sum); } }

Wie wir sehen können, enthält es auch bestimmte Geschäftsmethoden und fungiert als aggregierte Wurzel.

Zum Schluss definieren wir den ParcelShippingService :

public class ParcelShippingService implements ShippingService { public static final String EVENT_ORDER_READY_FOR_SHIPMENT = "OrderReadyForShipmentEvent"; private ShippingOrderRepository orderRepository; private EventBus eventBus; private Map shippedParcels = new HashMap(); @Override public void shipOrder(int orderId) { Optional order = this.orderRepository.findShippableOrder(orderId); order.ifPresent(completedOrder -> { Parcel parcel = new Parcel(completedOrder.getOrderId(), completedOrder.getAddress(), completedOrder.getPackageItems()); if (parcel.isTaxable()) { // Calculate additional taxes } // Ship parcel this.shippedParcels.put(completedOrder.getOrderId(), parcel); }); } @Override public void listenToOrderEvents() { this.eventBus.subscribe(EVENT_ORDER_READY_FOR_SHIPMENT, new EventSubscriber() { @Override public  void onEvent(E event) { shipOrder(Integer.parseInt(event.getPayloadValue("order_id"))); } }); } @Override public Optional getParcelByOrderId(int orderId) { return Optional.ofNullable(this.shippedParcels.get(orderId)); } }

Dieser Service verwendet in ähnlicher Weise das ShippingOrderRepository zum Abrufen von Bestellungen nach ID. Noch wichtiger ist, dass das OrderReadyForShipmentEvent- Ereignis abonniert wird, das in einem anderen Kontext veröffentlicht wird. Wenn dieses Ereignis eintritt, wendet der Service einige Regeln an und versendet die Bestellung. Der Einfachheit halber speichern wir ausgelieferte Bestellungen in einer HashMap .

3. Kontextkarten

Bisher haben wir zwei Kontexte definiert. Wir haben jedoch keine expliziten Beziehungen zwischen ihnen festgelegt. Zu diesem Zweck hat DDD das Konzept der Kontextzuordnung. Eine Kontextkarte ist eine visuelle Beschreibung der Beziehungen zwischen verschiedenen Kontexten des Systems . Diese Karte zeigt, wie verschiedene Teile zusammen existieren, um die Domäne zu bilden.

Es gibt fünf Haupttypen von Beziehungen zwischen begrenzten Kontexten:

  • Partnerschaft - eine Beziehung zwischen zwei Kontexten, die zusammenarbeiten, um die beiden Teams an abhängigen Zielen auszurichten
  • Shared Kernel - eine Art Beziehung, wenn gemeinsame Teile mehrerer Kontexte in einen anderen Kontext / ein anderes Modul extrahiert werden, um die Codeduplizierung zu verringern
  • Customer-supplier – a connection between two contexts, where one context (upstream) produces data, and the other (downstream) consume it. In this relationship, both sides are interested in establishing the best possible communication
  • Conformist – this relationship also has upstream and downstream, however, downstream always conforms to the upstream’s APIs
  • Anticorruption layer – this type of relationship is widely used for legacy systems to adapt them to a new architecture and gradually migrate from the legacy codebase. The Anticorruption layer acts as an adapter to translate data from the upstream and protect from undesired changes

In our particular example, we'll use the Shared Kernel relationship. We won't define it in its pure form, but it will mostly act as a mediator of events in the system.

Thus, the SharedKernel module won’t contain any concrete implementations, only interfaces.

Let’s start with the EventBus interface:

public interface EventBus {  void publish(E event);  void subscribe(String eventType, EventSubscriber subscriber);  void unsubscribe(String eventType, EventSubscriber subscriber); }

This interface will be implemented later in our Infrastructure module.

Next, we create a base service interface with default methods to support event-driven communication:

public interface ApplicationService { default  void publishEvent(E event) { EventBus eventBus = getEventBus(); if (eventBus != null) { eventBus.publish(event); } } default  void subscribe(String eventType, EventSubscriber subscriber) { EventBus eventBus = getEventBus(); if (eventBus != null) { eventBus.subscribe(eventType, subscriber); } } default  void unsubscribe(String eventType, EventSubscriber subscriber) { EventBus eventBus = getEventBus(); if (eventBus != null) { eventBus.unsubscribe(eventType, subscriber); } } EventBus getEventBus(); void setEventBus(EventBus eventBus); }

So, service interfaces in bounded contexts extend this interface to have common event-related functionality.

4. Java 9 Modularity

Now, it’s time to explore how the Java 9 Module System can support the defined application structure.

The Java Platform Module System (JPMS) encourages to build more reliable and strongly encapsulated modules. As a result, these features can help to isolate our contexts and establish clear boundaries.

Let's see our final module diagram:

4.1. SharedKernel Module

Let’s start with the SharedKernel module, which doesn't have any dependencies on other modules. So, the module-info.java looks like:

module com.baeldung.dddmodules.sharedkernel { exports com.baeldung.dddmodules.sharedkernel.events; exports com.baeldung.dddmodules.sharedkernel.service; }

We export module interfaces, so they're available to other modules.

4.2. OrderContext Module

Next, let’s move our focus to the OrderContext module. It only requires interfaces defined in the SharedKernel module:

module com.baeldung.dddmodules.ordercontext { requires com.baeldung.dddmodules.sharedkernel; exports com.baeldung.dddmodules.ordercontext.service; exports com.baeldung.dddmodules.ordercontext.model; exports com.baeldung.dddmodules.ordercontext.repository; provides com.baeldung.dddmodules.ordercontext.service.OrderService with com.baeldung.dddmodules.ordercontext.service.CustomerOrderService; }

Also, we can see that this module exports the default implementation for the OrderService interface.

4.3. ShippingContext Module

Similarly to the previous module, let’s create the ShippingContext module definition file:

module com.baeldung.dddmodules.shippingcontext { requires com.baeldung.dddmodules.sharedkernel; exports com.baeldung.dddmodules.shippingcontext.service; exports com.baeldung.dddmodules.shippingcontext.model; exports com.baeldung.dddmodules.shippingcontext.repository; provides com.baeldung.dddmodules.shippingcontext.service.ShippingService with com.baeldung.dddmodules.shippingcontext.service.ParcelShippingService; }

In the same way, we export the default implementation for the ShippingService interface.

4.4. Infrastructure Module

Now it’s time to describe the Infrastructure module. This module contains the implementation details for the defined interfaces. We’ll start by creating a simple implementation for the EventBus interface:

public class SimpleEventBus implements EventBus { private final Map
    
      subscribers = new ConcurrentHashMap(); @Override public void publish(E event) { if (subscribers.containsKey(event.getType())) { subscribers.get(event.getType()) .forEach(subscriber -> subscriber.onEvent(event)); } } @Override public void subscribe(String eventType, EventSubscriber subscriber) { Set eventSubscribers = subscribers.get(eventType); if (eventSubscribers == null) { eventSubscribers = new CopyOnWriteArraySet(); subscribers.put(eventType, eventSubscribers); } eventSubscribers.add(subscriber); } @Override public void unsubscribe(String eventType, EventSubscriber subscriber) { if (subscribers.containsKey(eventType)) { subscribers.get(eventType).remove(subscriber); } } }
    

Next, we need to implement the CustomerOrderRepository and ShippingOrderRepository interfaces. In most cases, the Order entity will be stored in the same table but used as a different entity model in bounded contexts.

It's very common to see a single entity containing mixed code from different areas of the business domain or low-level database mappings. For our implementation, we've split our entities according to the bounded contexts: CustomerOrder and ShippableOrder.

First, let’s create a class that will represent a whole persistent model:

public static class PersistenceOrder { public int orderId; public String paymentMethod; public String address; public List orderItems; public static class OrderItem { public int productId; public float unitPrice; public float itemWeight; public int quantity; } }

We can see that this class contains all fields from both CustomerOrder and ShippableOrder entities.

To keep things simple, let’s simulate an in-memory database:

public class InMemoryOrderStore implements CustomerOrderRepository, ShippingOrderRepository { private Map ordersDb = new HashMap(); @Override public void saveCustomerOrder(CustomerOrder order) { this.ordersDb.put(order.getOrderId(), new PersistenceOrder(order.getOrderId(), order.getPaymentMethod(), order.getAddress(), order .getOrderItems() .stream() .map(orderItem -> new PersistenceOrder.OrderItem(orderItem.getProductId(), orderItem.getQuantity(), orderItem.getUnitWeight(), orderItem.getUnitPrice())) .collect(Collectors.toList()) )); } @Override public Optional findShippableOrder(int orderId) { if (!this.ordersDb.containsKey(orderId)) return Optional.empty(); PersistenceOrder orderRecord = this.ordersDb.get(orderId); return Optional.of( new ShippableOrder(orderRecord.orderId, orderRecord.orderItems .stream().map(orderItem -> new PackageItem(orderItem.productId, orderItem.itemWeight, orderItem.quantity * orderItem.unitPrice) ).collect(Collectors.toList()))); } }

Here, we persist and retrieve different types of entities by converting persistent models to or from an appropriate type.

Finally, let’s create the module definition:

module com.baeldung.dddmodules.infrastructure { requires transitive com.baeldung.dddmodules.sharedkernel; requires transitive com.baeldung.dddmodules.ordercontext; requires transitive com.baeldung.dddmodules.shippingcontext; provides com.baeldung.dddmodules.sharedkernel.events.EventBus with com.baeldung.dddmodules.infrastructure.events.SimpleEventBus; provides com.baeldung.dddmodules.ordercontext.repository.CustomerOrderRepository with com.baeldung.dddmodules.infrastructure.db.InMemoryOrderStore; provides com.baeldung.dddmodules.shippingcontext.repository.ShippingOrderRepository with com.baeldung.dddmodules.infrastructure.db.InMemoryOrderStore; }

Using the provides with clause, we’re providing the implementation of a few interfaces that were defined in other modules.

Furthermore, this module acts as an aggregator of dependencies, so we use the requires transitive keyword. As a result, a module that requires the Infrastructure module will transitively get all these dependencies.

4.5. Main Module

To conclude, let’s define a module that will be the entry point to our application:

module com.baeldung.dddmodules.mainapp { uses com.baeldung.dddmodules.sharedkernel.events.EventBus; uses com.baeldung.dddmodules.ordercontext.service.OrderService; uses com.baeldung.dddmodules.ordercontext.repository.CustomerOrderRepository; uses com.baeldung.dddmodules.shippingcontext.repository.ShippingOrderRepository; uses com.baeldung.dddmodules.shippingcontext.service.ShippingService; requires transitive com.baeldung.dddmodules.infrastructure; }

As we’ve just set transitive dependencies on the Infrastructure module, we don't need to require them explicitly here.

On the other hand, we list these dependencies with the uses keyword. The uses clause instructs ServiceLoader, which we’ll discover in the next chapter, that this module wants to use these interfaces. However, it doesn’t require implementations to be available during compile-time.

5. Running the Application

Finally, we're almost ready to build our application. We'll leverage Maven for building our project. This makes it much easier to work with modules.

5.1. Project Structure

Our project contains five modules and the parent module. Let's take a look at our project structure:

ddd-modules (the root directory) pom.xml |-- infrastructure |-- src |-- main | -- java module-info.java |-- com.baeldung.dddmodules.infrastructure pom.xml |-- mainapp |-- src |-- main | -- java module-info.java |-- com.baeldung.dddmodules.mainapp pom.xml |-- ordercontext |-- src |-- main | -- java module-info.java |--com.baeldung.dddmodules.ordercontext pom.xml |-- sharedkernel |-- src |-- main | -- java module-info.java |-- com.baeldung.dddmodules.sharedkernel pom.xml |-- shippingcontext |-- src |-- main | -- java module-info.java |-- com.baeldung.dddmodules.shippingcontext pom.xml

5.2. Main Application

By now, we have everything except the main application, so let's define our main method:

public static void main(String args[]) { Map
    
      container = createContainer(); OrderService orderService = (OrderService) container.get(OrderService.class); ShippingService shippingService = (ShippingService) container.get(ShippingService.class); shippingService.listenToOrderEvents(); CustomerOrder customerOrder = new CustomerOrder(); int orderId = 1; customerOrder.setOrderId(orderId); List orderItems = new ArrayList(); orderItems.add(new OrderItem(1, 2, 3, 1)); orderItems.add(new OrderItem(2, 1, 1, 1)); orderItems.add(new OrderItem(3, 4, 11, 21)); customerOrder.setOrderItems(orderItems); customerOrder.setPaymentMethod("PayPal"); customerOrder.setAddress("Full address here"); orderService.placeOrder(customerOrder); if (orderId == shippingService.getParcelByOrderId(orderId).get().getOrderId()) { System.out.println("Order has been processed and shipped successfully"); } }
    

Let's briefly discuss our main method. In this method, we are simulating a simple customer order flow by using previously defined services. At first, we created the order with three items and provided the necessary shipping and payment information. Next, we submitted the order and finally checked whether it was shipped and processed successfully.

But how did we get all dependencies and why does the createContainer method return Map Object>? Let's take a closer look at this method.

5.3. Dependency Injection Using ServiceLoader

In this project, we don't have any Spring IoC dependencies, so alternatively, we'll use the ServiceLoader API for discovering implementations of services. This is not a new feature — the ServiceLoader API itself has been around since Java 6.

We can obtain a loader instance by invoking one of the static load methods of the ServiceLoader class. The load method returns the Iterable type so that we can iterate over discovered implementations.

Now, let's apply the loader to resolve our dependencies:

public static Map
     
       createContainer() { EventBus eventBus = ServiceLoader.load(EventBus.class).findFirst().get(); CustomerOrderRepository customerOrderRepository = ServiceLoader.load(CustomerOrderRepository.class) .findFirst().get(); ShippingOrderRepository shippingOrderRepository = ServiceLoader.load(ShippingOrderRepository.class) .findFirst().get(); ShippingService shippingService = ServiceLoader.load(ShippingService.class).findFirst().get(); shippingService.setEventBus(eventBus); shippingService.setOrderRepository(shippingOrderRepository); OrderService orderService = ServiceLoader.load(OrderService.class).findFirst().get(); orderService.setEventBus(eventBus); orderService.setOrderRepository(customerOrderRepository); HashMap
      
        container = new HashMap(); container.put(OrderService.class, orderService); container.put(ShippingService.class, shippingService); return container; }
      
     

Here, we're calling the static load method for every interface we need, which creates a new loader instance each time. As a result, it won't cache already resolved dependencies — instead, it'll create new instances every time.

Generally, service instances can be created in one of two ways. Either the service implementation class must have a public no-arg constructor, or it must use a static provider method.

As a consequence, most of our services have no-arg constructors and setter methods for dependencies. But, as we've already seen, the InMemoryOrderStore class implements two interfaces: CustomerOrderRepository and ShippingOrderRepository.

However, if we request each of these interfaces using the load method, we'll get different instances of the InMemoryOrderStore. That is not desirable behavior, so let's use the provider method technique to cache the instance:

public class InMemoryOrderStore implements CustomerOrderRepository, ShippingOrderRepository { private volatile static InMemoryOrderStore instance = new InMemoryOrderStore(); public static InMemoryOrderStore provider() { return instance; } }

We've applied the Singleton pattern to cache a single instance of the InMemoryOrderStore class and return it from the provider method.

If the service provider declares a provider method, then the ServiceLoader invokes this method to obtain an instance of a service. Otherwise, it will try to create an instance using the no-arguments constructor via Reflection. As a result, we can change the service provider mechanism without affecting our createContainer method.

And finally, we provide resolved dependencies to services via setters and return the configured services.

Finally, we can run the application.

6. Conclusion

In this article, we've discussed some critical DDD concepts: Bounded Context, Ubiquitous Language, and Context Mapping. While dividing a system into Bounded Contexts has a lot of benefits, at the same time, there is no need to apply this approach everywhere.

Next, we've seen how to use the Java 9 Module System along with Bounded Context to create strongly encapsulated modules.

Furthermore, we've covered the default ServiceLoader mechanism for discovering dependencies.

The full source code of the project is available over on GitHub.