Перейти к основному содержимому

Ограниченные контексты DDD и модули Java

· 13 мин. чтения

1. Обзор

Domain-Driven Design (DDD) — это набор принципов и инструментов, которые помогают нам разрабатывать эффективную архитектуру программного обеспечения для повышения ценности бизнеса . Ограниченный контекст — один из центральных и важных шаблонов для спасения архитектуры от большого кома грязи путем разделения всего домена приложения на несколько семантически согласованных частей.

В то же время с помощью системы модулей Java 9 мы можем создавать сильно инкапсулированные модули.

В этом руководстве мы создадим простое приложение магазина и увидим, как использовать модули Java 9, определяя явные границы для ограниченных контекстов.

2. Контексты, ограниченные DDD

В настоящее время программные системы — это не простые CRUD-приложения . На самом деле типичная монолитная корпоративная система состоит из некоторой устаревшей кодовой базы и недавно добавленных функций. Однако с каждым внесенным изменением поддерживать такие системы становится все труднее и труднее. В конце концов, он может стать полностью непригодным для обслуживания.

2.1. Ограниченный контекст и вездесущий язык

Для решения рассматриваемой проблемы в DDD предусмотрена концепция ограниченного контекста. Ограниченный контекст — это логическая граница домена, в которой последовательно применяются определенные термины и правила . Внутри этой границы все термины, определения и понятия образуют Вездесущий Язык.

В частности, основным преимуществом вездесущего языка является группировка участников проекта из разных областей вокруг конкретной области бизнеса.

Кроме того, несколько контекстов могут работать с одним и тем же объектом. Однако в каждом из этих контекстов он может иметь разные значения.

./78c2fdd7ed04fa8876056370ee84313d.svg

2.2. Контекст заказа

Давайте начнем реализацию нашего приложения, определив контекст заказа. Этот контекст содержит две сущности: OrderItem и CustomerOrder .

./7affbe3403b3c4419a026ab4dd283b08.svg

Сущность CustomerOrder является совокупным корнем :

public class CustomerOrder {
private int orderId;
private String paymentMethod;
private String address;
private List<OrderItem> orderItems;

public float calculateTotalPrice() {
return orderItems.stream().map(OrderItem::getTotalPrice)
.reduce(0F, Float::sum);
}
}

Как мы видим, этот класс содержит бизнес-метод calculateTotalPrice . Но в реальном проекте это, вероятно, будет намного сложнее — например, включение скидок и налогов в конечную цену.

Далее создадим класс OrderItem :

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

Мы определили сущности, но нам также нужно предоставить некоторый API другим частям приложения. Создадим класс 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<String, String> 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);
}
}

Здесь у нас есть несколько важных моментов, на которые стоит обратить внимание. Метод placeOrder отвечает за обработку заказов клиентов. После обработки заказа событие публикуется в EventBus . Мы обсудим управляемую событиями коммуникацию в следующих главах. Этот сервис предоставляет реализацию по умолчанию для интерфейса OrderService :

public interface OrderService extends ApplicationService {
void placeOrder(CustomerOrder order);

void setOrderRepository(CustomerOrderRepository orderRepository);
}

Кроме того, эта служба требует, чтобы CustomerOrderRepository сохранял заказы:

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

Важно то, что этот интерфейс не реализован внутри этого контекста, а будет предоставлен модулем инфраструктуры, как мы увидим позже.

2.3. Контекст доставки

Теперь давайте определим контекст доставки. Он также будет простым и будет содержать три сущности: Parcel , PackageItem и ShippableOrder .

./fb00f41f5951895e086bbe931f43940d.svg

Начнем с объекта ShippableOrder :

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

В этом случае сущность не содержит поля paymentMethod . Это потому, что в нашем контексте доставки нам все равно, какой способ оплаты используется. Контекст доставки отвечает только за обработку отгрузок заказов.

Кроме того, сущность Parcel специфична для контекста доставки:

public class Parcel {
private int orderId;
private String address;
private String trackingId;
private List<PackageItem> 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);
}
}

Как мы видим, он также содержит определенные бизнес-методы и действует как совокупный корень.

Наконец, давайте определим ParcelShippingService :

public class ParcelShippingService implements ShippingService {
public static final String EVENT_ORDER_READY_FOR_SHIPMENT = "OrderReadyForShipmentEvent";
private ShippingOrderRepository orderRepository;
private EventBus eventBus;
private Map<Integer, Parcel> shippedParcels = new HashMap<>();

@Override
public void shipOrder(int orderId) {
Optional<ShippableOrder> 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 <E extends ApplicationEvent> void onEvent(E event) {
shipOrder(Integer.parseInt(event.getPayloadValue("order_id")));
}
});
}

@Override
public Optional<Parcel> getParcelByOrderId(int orderId) {
return Optional.ofNullable(this.shippedParcels.get(orderId));
}
}

Этот сервис аналогичным образом использует ShippingOrderRepository для получения заказов по идентификатору. Что еще более важно, он подписывается на событие OrderReadyForShipmentEvent , которое публикуется в другом контексте. При возникновении этого события служба применяет некоторые правила и отправляет заказ. Для простоты мы храним отправленные заказы в HashMap .

3. Контекстные карты

Пока что мы определили два контекста. Однако мы не устанавливали никаких явных отношений между ними. Для этой цели в DDD есть концепция отображения контекста. Карта контекста — это визуальное описание взаимосвязей между различными контекстами системы . Эта карта показывает, как разные части сосуществуют вместе, образуя домен.

Существует пять основных типов отношений между ограниченными контекстами:

  • Партнерство - отношения между двумя контекстами, которые сотрудничают, чтобы согласовать две команды с зависимыми целями.
  • Shared Kernel — вид отношений, когда общие части нескольких контекстов извлекаются в другой контекст/модуль для уменьшения дублирования кода.
  • Клиент-поставщик — связь между двумя контекстами, где один контекст (upstream) производит данные, а другой (downstream) их потребляет. В этих отношениях обе стороны заинтересованы в установлении наилучшего возможного общения.
  • Конформист - это отношение также имеет восходящий и нисходящий поток, однако нисходящий поток всегда соответствует API-интерфейсам восходящего потока.
  • Уровень защиты от коррупции — этот тип отношений широко используется для устаревших систем, чтобы адаптировать их к новой архитектуре и постепенно мигрировать с устаревшей кодовой базы. Уровень защиты от коррупции действует как адаптер для преобразования данных из восходящего потока и защиты от нежелательных изменений.

В нашем конкретном примере мы будем использовать отношение Shared Kernel. Мы не будем определять его в чистом виде, но в основном он будет выступать посредником событий в системе.

Таким образом, модуль SharedKernel не будет содержать никаких конкретных реализаций, только интерфейсы.

Начнем с интерфейса EventBus :

public interface EventBus {
<E extends ApplicationEvent> void publish(E event);

<E extends ApplicationEvent> void subscribe(String eventType, EventSubscriber subscriber);

<E extends ApplicationEvent> void unsubscribe(String eventType, EventSubscriber subscriber);
}

Этот интерфейс будет реализован позже в нашем модуле инфраструктуры.

Затем мы создаем базовый интерфейс службы с методами по умолчанию для поддержки связи, управляемой событиями:

public interface ApplicationService {

default <E extends ApplicationEvent> void publishEvent(E event) {
EventBus eventBus = getEventBus();
if (eventBus != null) {
eventBus.publish(event);
}
}

default <E extends ApplicationEvent> void subscribe(String eventType, EventSubscriber subscriber) {
EventBus eventBus = getEventBus();
if (eventBus != null) {
eventBus.subscribe(eventType, subscriber);
}
}

default <E extends ApplicationEvent> void unsubscribe(String eventType, EventSubscriber subscriber) {
EventBus eventBus = getEventBus();
if (eventBus != null) {
eventBus.unsubscribe(eventType, subscriber);
}
}

EventBus getEventBus();

void setEventBus(EventBus eventBus);
}

Таким образом, сервисные интерфейсы в ограниченных контекстах расширяют этот интерфейс, чтобы иметь общую функциональность, связанную с событиями.

4. Модульность Java 9

Теперь пришло время изучить, как модульная система Java 9 может поддерживать определенную структуру приложения.

Система модулей платформы Java (JPMS) поощряет создание более надежных и сильно инкапсулированных модулей. В результате эти функции могут помочь изолировать наши контексты и установить четкие границы.

Давайте посмотрим на нашу окончательную диаграмму модуля:

./5817db3e3516fdf09f45f6a98de885a2.svg

4.1. Модуль общего ядра

Начнем с модуля SharedKernel, который не имеет никаких зависимостей от других модулей. Итак, модуль- info.java выглядит так:

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

Мы экспортируем интерфейсы модулей, чтобы они были доступны другим модулям.

4.2. Модуль контекста заказа

Далее, давайте переместим наше внимание на модуль OrderContext. Для этого требуются только интерфейсы, определенные в модуле SharedKernel:

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

Также мы видим, что этот модуль экспортирует реализацию по умолчанию для интерфейса OrderService .

4.3. Контекстный модуль доставки

Аналогично предыдущему модулю создадим файл определения модуля ShippingContext:

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

Точно так же мы экспортируем реализацию по умолчанию для интерфейса ShippingService .

4.4. Инфраструктурный модуль

Теперь пришло время описать модуль Infrastructure. Этот модуль содержит детали реализации для определенных интерфейсов. Мы начнем с создания простой реализации интерфейса EventBus :

public class SimpleEventBus implements EventBus {
private final Map<String, Set<EventSubscriber>> subscribers = new ConcurrentHashMap<>();

@Override
public <E extends ApplicationEvent> void publish(E event) {
if (subscribers.containsKey(event.getType())) {
subscribers.get(event.getType())
.forEach(subscriber -> subscriber.onEvent(event));
}
}

@Override
public <E extends ApplicationEvent> void subscribe(String eventType, EventSubscriber subscriber) {
Set<EventSubscriber> eventSubscribers = subscribers.get(eventType);
if (eventSubscribers == null) {
eventSubscribers = new CopyOnWriteArraySet<>();
subscribers.put(eventType, eventSubscribers);
}
eventSubscribers.add(subscriber);
}

@Override
public <E extends ApplicationEvent> void unsubscribe(String eventType, EventSubscriber subscriber) {
if (subscribers.containsKey(eventType)) {
subscribers.get(eventType).remove(subscriber);
}
}
}

Далее нам нужно реализовать интерфейсы CustomerOrderRepository и ShippingOrderRepository . В большинстве случаев сущность Order будет храниться в той же таблице, но использоваться как другая модель сущности в ограниченных контекстах.

Очень часто можно увидеть одну сущность, содержащую смешанный код из разных областей бизнес-домена или низкоуровневых сопоставлений баз данных. Для нашей реализации мы разделили наши сущности в соответствии с ограниченными контекстами: CustomerOrder и ShippableOrder .

Во-первых, давайте создадим класс, который будет представлять всю персистентную модель:

public static class PersistenceOrder {
public int orderId;
public String paymentMethod;
public String address;
public List<OrderItem> orderItems;

public static class OrderItem {
public int productId;
public float unitPrice;
public float itemWeight;
public int quantity;
}
}

Мы видим, что этот класс содержит все поля из сущностей CustomerOrder и ShippableOrder .

Для простоты давайте смоделируем базу данных в памяти:

public class InMemoryOrderStore implements CustomerOrderRepository, ShippingOrderRepository {
private Map<Integer, PersistenceOrder> 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<ShippableOrder> 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())));
}
}

Здесь мы сохраняем и извлекаем различные типы сущностей, преобразовывая постоянные модели в соответствующий тип или из него.

Наконец, давайте создадим определение модуля:

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

С помощью предложения contains with мы предоставляем реализацию нескольких интерфейсов, которые были определены в других модулях.

Кроме того, этот модуль выступает в роли агрегатора зависимостей, поэтому мы используем ключевое слово required. В результате модуль, которому требуется модуль Infrastructure, транзитивно получит все эти зависимости.

4.5. Основной модуль

В заключение давайте определим модуль, который будет точкой входа в наше приложение:

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

Поскольку мы только что установили транзитивные зависимости для модуля Infrastructure, нам не нужно явно требовать их здесь.

С другой стороны, мы перечисляем эти зависимости с использованием ключевого слова. Предложение uses сообщает ServiceLoader , который мы узнаем в следующей главе, о том, что этот модуль хочет использовать эти интерфейсы. Однако он не требует, чтобы реализации были доступны во время компиляции.

5. Запуск приложения

Наконец, мы почти готовы создать наше приложение. Мы будем использовать Maven для создания нашего проекта. Это значительно упрощает работу с модулями.

5.1. Структура проекта

Наш проект содержит пять модулей и родительский модуль . Давайте посмотрим на структуру нашего проекта:

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

5.2. Основное приложение

К настоящему времени у нас есть все, кроме основного приложения, поэтому давайте определим наш основной метод:

public static void main(String args[]) {
Map<Class<?>, Object> 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<OrderItem> orderItems = new ArrayList<OrderItem>();
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");
}
}

Давайте кратко обсудим наш основной метод. В этом методе мы моделируем простой поток заказов клиентов, используя ранее определенные услуги. Сначала мы создали заказ с тремя позициями и предоставили необходимую информацию о доставке и оплате. Затем мы отправили заказ и, наконец, проверили, был ли он успешно отправлен и обработан.

Но как мы получили все зависимости и почему метод createContainer возвращает Map<Class<?>, Object>? Давайте подробнее рассмотрим этот метод.

5.3. Внедрение зависимостей с помощью ServiceLoader

В этом проекте у нас нет зависимостей Spring IoC , поэтому в качестве альтернативы мы будем использовать API ServiceLoader для обнаружения реализаций сервисов. Это не новая функция — сам API ServiceLoader существует со времен Java 6.

Мы можем получить экземпляр загрузчика, вызвав один из методов статической загрузки класса ServiceLoader . Метод load возвращает тип Iterable , чтобы мы могли перебирать обнаруженные реализации.

Теперь давайте применим загрузчик для разрешения наших зависимостей:

public static Map<Class<?>, Object> 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<Class<?>, Object> container = new HashMap<>();
container.put(OrderService.class, orderService);
container.put(ShippingService.class, shippingService);

return container;
}

Здесь мы вызываем метод статической загрузки для каждого нужного нам интерфейса, который каждый раз создает новый экземпляр загрузчика. В результате он не будет кэшировать уже разрешенные зависимости — вместо этого каждый раз будут создаваться новые экземпляры.

Как правило, экземпляры службы можно создавать одним из двух способов. Либо класс реализации службы должен иметь общедоступный конструктор без аргументов, либо он должен использовать статический метод поставщика .

Как следствие, большинство наших сервисов имеют конструкторы без аргументов и методы установки зависимостей. Но, как мы уже видели, класс InMemoryOrderStore реализует два интерфейса: CustomerOrderRepository и ShippingOrderRepository .

Однако если мы запросим каждый из этих интерфейсов с помощью метода загрузки , мы получим разные экземпляры InMemoryOrderStore . Это нежелательное поведение, поэтому давайте используем технику метода провайдера для кэширования экземпляра:

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

public static InMemoryOrderStore provider() {
return instance;
}
}

Мы применили шаблон Singleton для кэширования одного экземпляра класса InMemoryOrderStore и возврата его из метода поставщика .

Если поставщик службы объявляет метод поставщика , то ServiceLoader вызывает этот метод для получения экземпляра службы. В противном случае он попытается создать экземпляр с помощью конструктора без аргументов через Reflection . В результате мы можем изменить механизм поставщика услуг, не затрагивая наш метод createContainer .

И, наконец, мы предоставляем разрешенные зависимости сервисам через сеттеры и возвращаем настроенные сервисы.

Наконец, мы можем запустить приложение.

6. Заключение

В этой статье мы обсудили некоторые важные концепции DDD: ограниченный контекст, вездесущий язык и сопоставление контекста. Хотя разделение системы на Bounded Contexts имеет много преимуществ, в то же время нет необходимости применять этот подход повсеместно.

Далее мы увидели, как использовать модульную систему Java 9 вместе с ограниченным контекстом для создания сильно инкапсулированных модулей.

Кроме того, мы рассмотрели стандартный механизм ServiceLoader для обнаружения зависимостей.

Полный исходный код проекта доступен на GitHub .