«1. Обзор

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

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

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

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

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

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

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

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

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

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

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

Сущность 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.

Начнем с объекта 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 есть концепция отображения контекста. Карта контекста — это визуальное описание взаимосвязей между различными контекстами системы. Эта карта показывает, как разные части сосуществуют вместе, образуя домен.

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

    Партнерство — отношения между двумя контекстами, которые сотрудничают, чтобы согласовать две команды с зависимыми целями. Общее ядро ​​— вид отношений, когда общие части нескольких контексты извлекаются в другой контекст/модуль для уменьшения дублирования кода Клиент-поставщик — соединение между двумя контекстами, где один контекст (восходящий) производит данные, а другой (нисходящий) их потребляет. В этих отношениях обе стороны заинтересованы в установлении наилучшего возможного взаимодействия. Конформизм — в этих отношениях также есть восходящий и нисходящий потоки, однако нисходящий поток всегда соответствует 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 Platform Module System (JPMS) поощряет создание более надежных и сильно инкапсулированных модулей. В результате эти функции могут помочь изолировать наши контексты и установить четкие границы.

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

4.1. Модуль SharedKernel

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

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

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

4.2. Модуль OrderContext

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

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

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

4.3. Модуль ShippingContext

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

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

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

4.4. Модуль инфраструктуры

Теперь пришло время описать модуль инфраструктуры. Этот модуль содержит детали реализации для определенных интерфейсов. Мы начнем с создания простой реализации интерфейса 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.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;
}

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

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

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

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

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

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

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

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

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

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

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

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. Основное приложение

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

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\u003cClass\u003c?\u003e, Object\u003e? Давайте подробнее рассмотрим этот метод.

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

В этом проекте у нас нет зависимостей Spring IoC, поэтому в качестве альтернативы мы будем использовать ServiceLoader API для обнаружения реализаций сервисов. Это не новая функция — сам 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.