Contesti limitati DDD e moduli Java

1. Panoramica

Domain-Driven Design (DDD) è un insieme di principi e strumenti che ci aiuta a progettare architetture software efficaci per offrire un valore aziendale più elevato . Il Bounded Context è uno dei modelli centrali ed essenziali per salvare l'architettura dalla Big Ball Of Mud separando l'intero dominio dell'applicazione in più parti semanticamente coerenti.

Allo stesso tempo, con il Java 9 Module System, possiamo creare moduli fortemente incapsulati.

In questo tutorial, creeremo una semplice applicazione per negozio e vedremo come sfruttare i moduli Java 9 definendo confini espliciti per contesti limitati.

2. Contesti delimitati da DDD

Al giorno d'oggi, i sistemi software non sono semplici applicazioni CRUD. In realtà, il tipico sistema aziendale monolitico è costituito da una base di codice legacy e da nuove funzionalità aggiunte. Tuttavia, diventa sempre più difficile mantenere tali sistemi con ogni modifica apportata. Alla fine, potrebbe diventare del tutto impossibile da mantenere.

2.1. Contesto limitato e linguaggio onnipresente

Per risolvere il problema affrontato, DDD fornisce il concetto di contesto delimitato. Un contesto delimitato è un confine logico di un dominio in cui termini e regole particolari si applicano in modo coerente . All'interno di questo confine, tutti i termini, le definizioni e i concetti formano il Linguaggio Ubiquitous.

In particolare, il vantaggio principale del linguaggio onnipresente è il raggruppamento di membri del progetto provenienti da diverse aree attorno a uno specifico dominio aziendale.

Inoltre, più contesti possono funzionare con la stessa cosa. Tuttavia, può avere significati diversi all'interno di ciascuno di questi contesti.

2.2. Contesto dell'ordine

Cominciamo a implementare la nostra applicazione definendo il contesto dell'ordine. Questo contesto contiene due entità: OrderItem e CustomerOrder .

L' entità CustomerOrder è una radice aggregata:

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); } }

Come possiamo vedere, questa classe contiene il calculateTotalPrice formula commerciale. Ma, in un progetto del mondo reale, sarà probabilmente molto più complicato, ad esempio, inclusi sconti e tasse nel prezzo finale.

Successivamente, creiamo la classe OrderItem :

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

Abbiamo definito entità, ma dobbiamo anche esporre alcune API ad altre parti dell'applicazione. Creiamo la classe CustomerOrderService :

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); } }

Qui abbiamo alcuni punti importanti da evidenziare. Il metodo placeOrder è responsabile dell'elaborazione degli ordini dei clienti. Dopo l'elaborazione di un ordine, l'evento viene pubblicato in EventBus . Discuteremo la comunicazione guidata dagli eventi nei prossimi capitoli. Questo servizio fornisce l'implementazione predefinita per l' interfaccia OrderService :

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

Inoltre, questo servizio richiede al CustomerOrderRepository di rendere persistenti gli ordini:

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

L'essenziale è che questa interfaccia non sia implementata all'interno di questo contesto ma sarà fornita dal Modulo Infrastruttura, come vedremo più avanti.

2.3. Contesto di spedizione

Ora definiamo il contesto di spedizione. Sarà anche semplice e contiene tre entità: Parcel , PackageItem e ShippableOrder .

Cominciamo con l' entità ShippableOrder :

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

In questo caso, l'entità non contiene il campo paymentMethod . Questo perché, nel nostro contesto di spedizione, non ci interessa quale metodo di pagamento viene utilizzato. Il contesto di spedizione è solo responsabile dell'elaborazione delle spedizioni degli ordini.

Inoltre, l' entità Pacco è specifica per il contesto di spedizione:

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); } }

Come possiamo vedere, contiene anche metodi aziendali specifici e funge da radice aggregata.

Infine, definiamo il 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)); } }

Questo servizio utilizza in modo simile ShippingOrderRepository per il recupero degli ordini in base all'ID. Ancora più importante, si iscrive all'evento OrderReadyForShipmentEvent , che viene pubblicato da un altro contesto. Quando si verifica questo evento, il servizio applica alcune regole e spedisce l'ordine. Per semplicità, archiviamo gli ordini spediti in una HashMap .

3. Mappe di contesto

Finora abbiamo definito due contesti. Tuttavia, non abbiamo stabilito alcuna relazione esplicita tra loro. A questo scopo, DDD ha il concetto di Context Mapping. Una mappa di contesto è una descrizione visiva delle relazioni tra i diversi contesti del sistema . Questa mappa mostra come le diverse parti coesistono insieme per formare il dominio.

Esistono cinque tipi principali di relazioni tra contesti limitati:

  • Partnership - una relazione tra due contesti che cooperano per allineare le due squadre con obiettivi dipendenti
  • Kernel condiviso : una sorta di relazione quando parti comuni di diversi contesti vengono estratte in un altro contesto / modulo per ridurre la duplicazione del codice
  • 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.