DDD Bounded Contexts dan Java Modules

1. Ikhtisar

Domain-Driven Design (DDD) adalah sekumpulan prinsip dan alat yang membantu kami merancang arsitektur perangkat lunak yang efektif untuk memberikan nilai bisnis yang lebih tinggi . Bounded Context adalah salah satu pola sentral dan esensial untuk menyelamatkan arsitektur dari Big Ball Of Mud dengan memisahkan seluruh domain aplikasi menjadi beberapa bagian yang konsisten secara semantik.

Pada saat yang sama, dengan Sistem Modul Java 9, kita dapat membuat modul dengan enkapsulasi yang kuat.

Dalam tutorial ini, kita akan membuat aplikasi penyimpanan sederhana dan melihat bagaimana memanfaatkan Modul Java 9 sambil menentukan batasan eksplisit untuk konteks terbatas.

2. Konteks Berbatas DDD

Saat ini, sistem perangkat lunak bukanlah aplikasi CRUD yang sederhana. Sebenarnya, sistem perusahaan monolitik tipikal terdiri dari beberapa basis kode lama dan fitur yang baru ditambahkan. Namun, semakin sulit untuk mempertahankan sistem seperti itu dengan setiap perubahan yang dilakukan. Akhirnya, hal itu mungkin menjadi tidak dapat dipertahankan sama sekali.

2.1. Konteks Terikat dan Bahasa di mana-mana

Untuk mengatasi masalah yang ditangani, DDD menyediakan konsep Konteks Terikat. Konteks yang Terikat adalah batas logis dari sebuah domain tempat istilah dan aturan tertentu diterapkan secara konsisten . Di dalam batasan ini, semua istilah, definisi, dan konsep membentuk Bahasa Ubiquitous.

Secara khusus, manfaat utama dari bahasa yang ada di mana-mana adalah mengelompokkan anggota proyek dari berbagai area di sekitar domain bisnis tertentu.

Selain itu, berbagai konteks dapat bekerja dengan hal yang sama. Namun, ini mungkin memiliki arti yang berbeda di dalam setiap konteks ini.

2.2. Konteks Order

Mari mulai menerapkan aplikasi kita dengan mendefinisikan Konteks Pesanan. Konteks ini berisi dua entitas: OrderItem dan CustomerOrder .

The CustomerOrder entitas 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 lihat, kelas ini berisi calculateTotalPrice metode bisnis. Namun, dalam proyek dunia nyata, ini mungkin akan jauh lebih rumit - misalnya, termasuk diskon dan pajak dalam harga akhir.

Selanjutnya, mari buat kelas OrderItem :

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

Kami telah menentukan entitas, tetapi kami juga perlu mengekspos beberapa API ke bagian lain dari aplikasi. 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 memiliki beberapa poin penting untuk disoroti. The placeOrder Metode bertanggung jawab untuk memproses pesanan pelanggan. Setelah pesanan diproses, acara tersebut dipublikasikan ke EventBus . Kami akan membahas komunikasi yang digerakkan oleh peristiwa di bab-bab berikutnya. Layanan ini menyediakan implementasi default untuk antarmuka OrderService :

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

Lebih lanjut, layanan ini membutuhkan CustomerOrderRepository untuk mempertahankan pesanan:

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

Yang penting adalah bahwa antarmuka ini tidak diterapkan di dalam konteks ini tetapi akan disediakan oleh Modul Infrastruktur, seperti yang akan kita lihat nanti.

2.3. Konteks Pengiriman

Sekarang, mari tentukan Konteks Pengiriman. Ini juga akan langsung dan berisi tiga entitas: Parcel , PackageItem , dan ShippableOrder .

Mari kita mulai dengan entitas ShippableOrder :

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

Dalam kasus ini, entitas tidak berisi bidang paymentMethod . Itu karena, dalam Konteks Pengiriman kami, kami tidak peduli metode pembayaran mana yang digunakan. Konteks Pengiriman hanya bertanggung jawab untuk memproses pengiriman pesanan.

Selain itu, entitas Parcel dikhususkan untuk Konteks Pengiriman:

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 bisa kita lihat, ini juga berisi metode bisnis tertentu dan bertindak sebagai akar agregat.

Terakhir, mari kita definisikan 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)); } }

Layanan ini juga menggunakan ShippingOrderRepository untuk mengambil pesanan berdasarkan id. Lebih penting lagi, itu berlangganan ke acara OrderReadyForShipmentEvent , yang diterbitkan oleh konteks lain. Saat peristiwa ini terjadi, layanan menerapkan beberapa aturan dan mengirimkan pesanan. Demi kesederhanaan, kami menyimpan pesanan yang dikirim di HashMap .

3. Peta Konteks

Sejauh ini, kami mendefinisikan dua konteks. Namun, kami tidak menetapkan hubungan eksplisit apa pun di antara mereka. Untuk tujuan ini, DDD memiliki konsep Pemetaan Konteks. Peta Konteks adalah deskripsi visual tentang hubungan antara konteks sistem yang berbeda . Peta ini menunjukkan bagaimana berbagai bagian hidup berdampingan bersama untuk membentuk domain.

Ada lima jenis hubungan utama antara Konteks Terikat:

  • Kemitraan - hubungan antara dua konteks yang bekerja sama untuk menyelaraskan kedua tim dengan tujuan yang bergantung
  • Kernel Bersama - semacam hubungan ketika bagian umum dari beberapa konteks diekstraksi ke konteks / modul lain untuk mengurangi duplikasi kode
  • 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.