Konteks DDD Terikat dan Modul Java

1. Gambaran keseluruhan

Domain-Driven Design (DDD) adalah sekumpulan prinsip dan alat yang membantu kami merancang seni bina perisian yang berkesan untuk memberikan nilai perniagaan yang lebih tinggi . Bounded Context adalah salah satu corak utama dan penting untuk menyelamatkan seni bina dari Big Ball Of Mud dengan memisahkan keseluruhan domain aplikasi menjadi beberapa bahagian yang semantik-konsisten.

Pada masa yang sama, dengan Java 9 Module System, kita dapat membuat modul yang dikemas dengan kuat.

Dalam tutorial ini, kami akan membuat aplikasi kedai sederhana dan melihat bagaimana memanfaatkan Modul Java 9 sambil menentukan batasan eksplisit untuk konteks yang dibatasi.

2. Konteks DDD Terikat

Pada masa kini, sistem perisian bukanlah aplikasi CRUD yang sederhana. Sebenarnya, sistem perusahaan monolitik khas terdiri daripada beberapa pangkalan data warisan dan ciri yang baru ditambahkan. Namun, menjadi lebih sukar dan sukar untuk mengekalkan sistem sedemikian dengan setiap perubahan yang dilakukan. Akhirnya, ia mungkin tidak dapat dicapai sepenuhnya.

2.1. Konteks Terikat dan Bahasa Di mana-mana

Untuk menyelesaikan masalah yang diatasi, DDD memberikan konsep Konteks Terikat. Konteks Terikat adalah sempadan logik domain di mana syarat dan peraturan tertentu berlaku secara konsisten . Di dalam batasan ini, semua istilah, definisi, dan konsep membentuk Bahasa yang Berwujud.

Khususnya, manfaat utama bahasa di mana-mana adalah mengumpulkan ahli-ahli projek dari pelbagai bidang di sekitar domain perniagaan tertentu.

Selain itu, pelbagai konteks mungkin berfungsi dengan perkara yang sama. Namun, ia mungkin mempunyai makna yang berbeza dalam setiap konteks ini.

2.2. Konteks Pesanan

Mari mulakan aplikasi kita dengan menentukan Konteks Pesanan. Konteks ini mengandungi dua entiti: OrderItem dan CustomerOrder .

The CustomerOrder entiti adalah akar agregat:

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

Seperti yang kita dapat lihat, kelas ini mengandungi calculateTotalPrice kaedah perniagaan. Tetapi, dalam projek dunia nyata, ia mungkin akan menjadi lebih rumit - misalnya, termasuk potongan dan cukai pada harga akhir.

Seterusnya, mari buat kelas OrderItem :

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

Kami telah menentukan entiti, tetapi kami juga perlu mendedahkan beberapa API ke bahagian aplikasi yang lain. Mari buat kelas 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); } }

Di sini, kami mempunyai beberapa perkara penting untuk diketengahkan. The placeOrder kaedah bertanggungjawab untuk memproses pesanan pelanggan. Setelah pesanan diproses, acara tersebut diterbitkan ke EventBus . Kami akan membincangkan komunikasi berdasarkan peristiwa dalam bab seterusnya. Perkhidmatan ini menyediakan pelaksanaan lalai untuk antara muka OrderService :

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

Tambahan pula, perkhidmatan ini memerlukan CustomerOrderRepository untuk meneruskan pesanan:

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

Yang penting ialah antara muka ini tidak dilaksanakan dalam konteks ini tetapi akan disediakan oleh Modul Infrastruktur, seperti yang akan kita lihat kemudian.

2.3. Konteks Penghantaran

Sekarang, mari kita tentukan Konteks Penghantaran. Ini juga akan mudah dan mengandungi tiga entiti: Parcel , PackageItem , dan ShippableOrder .

Mari mulakan dengan entiti ShippableOrder :

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

Dalam kes ini, entiti tidak mengandungi medan Kaedah pembayaran . Ini kerana, dalam Konteks Penghantaran kami, kami tidak peduli kaedah pembayaran mana yang digunakan. Konteks Penghantaran hanya bertanggungjawab untuk memproses penghantaran pesanan.

Juga, entiti Petak khusus untuk Konteks Penghantaran:

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

Seperti yang kita lihat, ini juga mengandungi kaedah perniagaan tertentu dan bertindak sebagai akar agregat.

Akhirnya, mari kita tentukan 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)); } }

Perkhidmatan ini juga menggunakan ShippingOrderRepository untuk mengambil pesanan melalui id. Lebih penting lagi, ia melanggan acara OrderReadyForShipmentEvent , yang diterbitkan oleh konteks lain. Apabila peristiwa ini berlaku, perkhidmatan ini menerapkan beberapa peraturan dan menghantar pesanan. Demi kesederhanaan, kami menyimpan pesanan yang dihantar dalam HashMap .

3. Peta Konteks

Setakat ini, kami menentukan dua konteks. Namun, kami tidak menetapkan hubungan yang jelas antara mereka. Untuk tujuan ini, DDD mempunyai konsep Pemetaan Konteks. Peta Konteks adalah gambaran visual hubungan antara pelbagai konteks sistem . Peta ini menunjukkan bagaimana bahagian yang berbeza wujud bersama untuk membentuk domain.

Terdapat lima jenis hubungan utama antara Konteks Terikat:

  • Perkongsian - hubungan antara dua konteks yang bekerjasama untuk menyelaraskan kedua-dua pasukan dengan tujuan yang bergantung
  • Shared Kernel - sejenis hubungan apabila bahagian umum dari beberapa konteks diekstrak ke konteks / modul lain untuk mengurangkan penduaan kod
  • 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.