1. Обзор
В этом руководстве мы реализуем приложение Spring с использованием DDD. Кроме того, мы организуем слои с помощью гексагональной архитектуры.
При таком подходе мы можем легко обмениваться разными слоями приложения.
2. Шестиугольная архитектура
Шестиугольная архитектура — это модель разработки программных приложений на основе доменной логики , чтобы изолировать ее от внешних факторов.
Логика предметной области указана в бизнес-ядре, которое мы назовем внутренней частью, а остальные — внешними частями. Доступ к логике домена извне возможен через порты и адаптеры .
3. Принципы
Во-первых, мы должны определить принципы разделения нашего кода. Как уже кратко объяснялось, шестиугольная архитектура определяет внутреннюю и внешнюю части .
Вместо этого мы разделим наше приложение на три слоя; приложение (снаружи), домен (внутри) и инфраструктура (снаружи):
Через прикладной уровень пользователь или любая другая программа взаимодействует с приложением. Эта область должна содержать такие вещи, как пользовательские интерфейсы, контроллеры RESTful и библиотеки сериализации JSON. Он включает в себя все , что открывает доступ к нашему приложению и управляет выполнением логики предметной области.
На уровне предметной области мы храним код, который затрагивает и реализует бизнес-логику . Это ядро нашего приложения. Кроме того, этот уровень должен быть изолирован как от части приложения, так и от части инфраструктуры. Кроме того, он также должен содержать интерфейсы, определяющие API для связи с внешними частями, такими как база данных, с которой взаимодействует домен.
Наконец, уровень инфраструктуры — это часть, которая содержит все, что необходимо приложению для работы , например, конфигурацию базы данных или конфигурацию Spring. Кроме того, он также реализует интерфейсы, зависящие от инфраструктуры, на уровне предметной области.
4. Уровень домена
Давайте начнем с реализации нашего основного уровня, который является уровнем предметной области.
Во-первых, мы должны создать класс Order :
public class Order {
private UUID id;
private OrderStatus status;
private List<OrderItem> orderItems;
private BigDecimal price;
public Order(UUID id, Product product) {
this.id = id;
this.orderItems = new ArrayList<>(Arrays.astList(new OrderItem(product)));
this.status = OrderStatus.CREATED;
this.price = product.getPrice();
}
public void complete() {
validateState();
this.status = OrderStatus.COMPLETED;
}
public void addOrder(Product product) {
validateState();
validateProduct(product);
orderItems.add(new OrderItem(product));
price = price.add(product.getPrice());
}
public void removeOrder(UUID id) {
validateState();
final OrderItem orderItem = getOrderItem(id);
orderItems.remove(orderItem);
price = price.subtract(orderItem.getPrice());
}
// getters
}
Это наш совокупный корень . Все, что связано с нашей бизнес-логикой, будет проходить через этот класс. Кроме того, Order
отвечает за поддержание себя в правильном состоянии:
- Заказ может быть создан только с заданным идентификатором и на базе одного
Товара —
конструктор также сам создает заказ со статусомСОЗДАН .
- Как только заказ выполнен, изменение
OrderItem
невозможно . - Невозможно изменить
порядок
из-за пределов объекта домена, например, с помощью сеттера
Кроме того, класс Order
также отвечает за создание своего OrderItem
.
Давайте тогда создадим класс OrderItem
:
public class OrderItem {
private UUID productId;
private BigDecimal price;
public OrderItem(Product product) {
this.productId = product.getId();
this.price = product.getPrice();
}
// getters
}
Как мы видим, OrderItem
создается на основе Product
. Он хранит ссылку на него и хранит текущую цену Продукта
.
Далее мы создадим интерфейс репозитория ( порт
в Hexagonal Architecture). Реализация интерфейса будет на уровне инфраструктуры:
public interface OrderRepository {
Optional<Order> findById(UUID id);
void save(Order order);
}
Наконец, мы должны убедиться, что Орден
всегда будет сохраняться после каждого действия. Для этого мы определим доменную службу, которая обычно содержит логику, которая не может быть частью нашего корня :
public class DomainOrderService implements OrderService {
private final OrderRepository orderRepository;
public DomainOrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Override
public UUID createOrder(Product product) {
Order order = new Order(UUID.randomUUID(), product);
orderRepository.save(order);
return order.getId();
}
@Override
public void addProduct(UUID id, Product product) {
Order order = getOrder(id);
order.addOrder(product);
orderRepository.save(order);
}
@Override
public void completeOrder(UUID id) {
Order order = getOrder(id);
order.complete();
orderRepository.save(order);
}
@Override
public void deleteProduct(UUID id, UUID productId) {
Order order = getOrder(id);
order.removeOrder(productId);
orderRepository.save(order);
}
private Order getOrder(UUID id) {
return orderRepository
.findById(id)
.orElseThrow(RuntimeException::new);
}
}
В шестиугольной архитектуре эта служба представляет собой адаптер, реализующий порт. Кроме того, мы не будем регистрировать его как компонент Spring , потому что с точки зрения предметной области он находится внутри, а конфигурация Spring — снаружи. Чуть позже мы вручную подключим его к Spring на уровне инфраструктуры.
Поскольку уровень домена полностью отделен от уровней приложений и инфраструктуры, мы также можем тестировать его независимо :
class DomainOrderServiceUnitTest {
private OrderRepository orderRepository;
private DomainOrderService tested;
@BeforeEach
void setUp() {
orderRepository = mock(OrderRepository.class);
tested = new DomainOrderService(orderRepository);
}
@Test
void shouldCreateOrder_thenSaveIt() {
final Product product = new Product(UUID.randomUUID(), BigDecimal.TEN, "productName");
final UUID id = tested.createOrder(product);
verify(orderRepository).save(any(Order.class));
assertNotNull(id);
}
}
5. Прикладной уровень
В этом разделе мы реализуем прикладной уровень. Мы позволим пользователю общаться с нашим приложением через RESTful API.
Поэтому давайте создадим OrderController:
@RestController
@RequestMapping("/orders")
public class OrderController {
private OrderService orderService;
@Autowired
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
CreateOrderResponse createOrder(@RequestBody CreateOrderRequest request) {
UUID id = orderService.createOrder(request.getProduct());
return new CreateOrderResponse(id);
}
@PostMapping(value = "/{id}/products")
void addProduct(@PathVariable UUID id, @RequestBody AddProductRequest request) {
orderService.addProduct(id, request.getProduct());
}
@DeleteMapping(value = "/{id}/products")
void deleteProduct(@PathVariable UUID id, @RequestParam UUID productId) {
orderService.deleteProduct(id, productId);
}
@PostMapping("/{id}/complete")
void completeOrder(@PathVariable UUID id) {
orderService.completeOrder(id);
}
}
Этот простой контроллер Spring Rest отвечает за организацию выполнения логики домена .
Этот контроллер адаптирует внешний интерфейс RESTful к нашему домену. Он делает это, вызывая соответствующие методы из OrderService
(порт).
6. Уровень инфраструктуры
Уровень инфраструктуры содержит логику, необходимую для запуска приложения.
Поэтому начнем с создания классов конфигурации. Во-первых, давайте реализуем класс, который зарегистрирует наш OrderService
как компонент Spring:
@Configuration
public class BeanConfiguration {
@Bean
OrderService orderService(OrderRepository orderRepository) {
return new DomainOrderService(orderRepository);
}
}
Затем давайте создадим конфигурацию, отвечающую за включение репозиториев Spring Data , которые мы будем использовать:
@EnableMongoRepositories(basePackageClasses = SpringDataMongoOrderRepository.class)
public class MongoDBConfiguration {
}
Мы использовали свойство basePackageClasses
, поскольку эти репозитории могут находиться только на уровне инфраструктуры. Следовательно, у Spring нет причин сканировать все приложение. Кроме того, этот класс может содержать все, что связано с установлением соединения между MongoDB и нашим приложением.
Наконец, мы реализуем OrderRepository
из доменного слоя. Мы будем использовать наш SpringDataMongoOrderRepository
в нашей реализации:
@Component
public class MongoDbOrderRepository implements OrderRepository {
private SpringDataMongoOrderRepository orderRepository;
@Autowired
public MongoDbOrderRepository(SpringDataMongoOrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Override
public Optional<Order> findById(UUID id) {
return orderRepository.findById(id);
}
@Override
public void save(Order order) {
orderRepository.save(order);
}
}
Эта реализация хранит наш заказ
в MongoDB. В шестиугольной архитектуре эта реализация также является адаптером.
7. Преимущества
Первое преимущество этого подхода заключается в том, что мы разделяем работу для каждого слоя . Мы можем сосредоточиться на одном слое, не затрагивая другие.
Кроме того, их, естественно, легче понять, потому что каждый из них фокусируется на своей логике.
Еще одним большим преимуществом является то, что мы изолировали логику предметной области от всего остального. Доменная часть содержит только бизнес-логику и может быть легко перемещена в другую среду .
Фактически, давайте изменим уровень инфраструктуры, чтобы использовать Cassandra в качестве базы данных:
@Component
public class CassandraDbOrderRepository implements OrderRepository {
private final SpringDataCassandraOrderRepository orderRepository;
@Autowired
public CassandraDbOrderRepository(SpringDataCassandraOrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Override
public Optional<Order> findById(UUID id) {
Optional<OrderEntity> orderEntity = orderRepository.findById(id);
if (orderEntity.isPresent()) {
return Optional.of(orderEntity.get()
.toOrder());
} else {
return Optional.empty();
}
}
@Override
public void save(Order order) {
orderRepository.save(new OrderEntity(order));
}
}
В отличие от MongoDB, теперь мы используем OrderEntity
для сохранения домена в базе данных.
Если мы добавим технологические аннотации к нашему объекту домена Order
, то мы нарушим разделение между инфраструктурным и доменным уровнями .
Репозиторий адаптирует домен к нашим потребностям в сохранении.
Давайте сделаем еще один шаг и превратим наше приложение RESTful в приложение командной строки:
@Component
public class CliOrderController {
private static final Logger LOG = LoggerFactory.getLogger(CliOrderController.class);
private final OrderService orderService;
@Autowired
public CliOrderController(OrderService orderService) {
this.orderService = orderService;
}
public void createCompleteOrder() {
LOG.info("<<Create complete order>>");
UUID orderId = createOrder();
orderService.completeOrder(orderId);
}
public void createIncompleteOrder() {
LOG.info("<<Create incomplete order>>");
UUID orderId = createOrder();
}
private UUID createOrder() {
LOG.info("Placing a new order with two products");
Product mobilePhone = new Product(UUID.randomUUID(), BigDecimal.valueOf(200), "mobile");
Product razor = new Product(UUID.randomUUID(), BigDecimal.valueOf(50), "razor");
LOG.info("Creating order with mobile phone");
UUID orderId = orderService.createOrder(mobilePhone);
LOG.info("Adding a razor to the order");
orderService.addProduct(orderId, razor);
return orderId;
}
}
В отличие от предыдущего, теперь у нас есть набор предопределенных действий, которые взаимодействуют с нашим доменом. Мы могли бы использовать это, например, для заполнения нашего приложения фиктивными данными.
Несмотря на то, что мы полностью изменили назначение приложения, мы не коснулись доменного слоя.
8. Заключение
В этой статье мы узнали, как разделить логику, связанную с нашим приложением, на определенные слои.
Во-первых, мы определили три основных уровня: приложение, домен и инфраструктура. После этого мы описали, как их заполнить и объяснили преимущества.
Затем мы придумали реализацию для каждого слоя:
Наконец, мы поменяли местами уровни приложения и инфраструктуры, не затрагивая домен.
Как всегда, код этих примеров доступен на GitHub .