Рубрики
Без рубрики

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

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

Автор оригинала: baeldung.

1. Обзор

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

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

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

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

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

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

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

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

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

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

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

То Заказ клиента сущность-это агрегатный корень :

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

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

Далее, давайте создадим класс OrderItem :

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

Мы определили сущности, но также нам нужно предоставить некоторые API для других частей приложения. Давайте создадим класс Служба заказов клиентов :

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

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

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

    void setOrderRepository(CustomerOrderRepository orderRepository);
}

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

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

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

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

Теперь давайте определим Контекст доставки. Он также будет простым и будет содержать три объекта: Посылка , Посылка и Отгружаемый заказ .

Давайте начнем с Отгружаемого заказа сущности:

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

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

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

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

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

Наконец, давайте определим Службу доставки посылок :

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

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

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

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

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

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

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

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

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

public interface EventBus {
     void publish(E event);

     void subscribe(String eventType, EventSubscriber subscriber);

     void unsubscribe(String eventType, EventSubscriber subscriber);
}

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

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

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

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

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

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

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

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

4.1. Общий модуль Ядра

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

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

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

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

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

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. Модуль Контекста доставки

Аналогично предыдущему модулю, давайте создадим файл определения модуля Контекста доставки:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

public static void main(String args[]) {
    Map, 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 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");
    }
}

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

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

5.3. Внедрение Зависимостей С Помощью ServiceLoader

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

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

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

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

    return container;
}

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

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

Как следствие, большинство наших сервисов не имеют конструкторов и методов установки зависимостей. Но, как мы уже видели, класс In Memory Order Store реализует два интерфейса: Хранилище заказов клиентов и ShippingOrderRepository .

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

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

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

Мы применили шаблон Singleton для кэширования одного экземпляра класса In Memory Order Store и возврата его из метода provider .

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

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

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

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

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

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

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

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