Organizzazione dei livelli utilizzando architettura esagonale, DDD e Spring

Java Top

Ho appena annunciato il nuovo corso Learn Spring , incentrato sui fondamenti di Spring 5 e Spring Boot 2:

>> SCOPRI IL CORSO

1. Panoramica

In questo tutorial, implementeremo un'applicazione Spring utilizzando DDD. Inoltre, organizzeremo i livelli con l'aiuto dell'architettura esagonale.

Con questo approccio, possiamo facilmente scambiare i diversi livelli dell'applicazione.

2. Architettura esagonale

L'architettura esagonale è un modello di progettazione di applicazioni software attorno alla logica di dominio per isolarla da fattori esterni.

La logica del dominio è specificata in un business core, che chiameremo parte interna, il resto essendo parti esterne. L'accesso alla logica di dominio dall'esterno è disponibile tramite porte e adattatori.

3. Principi

In primo luogo, dobbiamo definire i principi per dividere il nostro codice. Come già brevemente spiegato, l'architettura esagonale definisce l'interno e l'esterno .

Quello che faremo invece è dividere la nostra applicazione in tre livelli; applicazione (esterno), dominio (interno) e infrastruttura (esterno):

Attraverso il livello dell'applicazione, l'utente o qualsiasi altro programma interagisce con l'applicazione. Quest'area dovrebbe contenere elementi come interfacce utente, controller RESTful e librerie di serializzazione JSON. Include tutto ciò che espone l'ingresso alla nostra applicazione e orchestra l'esecuzione della logica di dominio.

Nel livello del dominio, manteniamo il codice che tocca e implementa la logica aziendale . Questo è il cuore della nostra applicazione. Inoltre, questo livello dovrebbe essere isolato sia dalla parte dell'applicazione che dalla parte dell'infrastruttura. Inoltre, dovrebbe contenere anche interfacce che definiscono l'API per comunicare con parti esterne, come il database, con cui interagisce il dominio.

Infine, il livello dell'infrastruttura è la parte che contiene tutto ciò di cui l'applicazione ha bisogno per funzionare , come la configurazione del database o la configurazione di Spring. Inoltre, implementa anche interfacce dipendenti dall'infrastruttura dal livello di dominio.

4. Livello di dominio

Cominciamo implementando il nostro livello principale, che è il livello del dominio.

Innanzitutto, dovremmo creare la classe Order :

public class Order { private UUID id; private OrderStatus status; private List 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 }

Questa è la nostra radice aggregata . Tutto ciò che riguarda la nostra logica aziendale passerà attraverso questa lezione. Inoltre, Order è responsabile di mantenersi nello stato corretto:

  • L'ordine può essere creato solo con l'ID specificato e in base a un prodotto: anche il costruttore stesso entra nell'ordine con lo stato CREATO
  • Una volta completato l'ordine, la modifica di OrderItem è impossibile
  • È impossibile modificare l' Ordine dall'esterno dell'oggetto del dominio, come con un setter

Inoltre, la classe Order è anche responsabile della creazione del proprio OrderItem .

Creiamo la classe OrderItem quindi:

public class OrderItem { private UUID productId; private BigDecimal price; public OrderItem(Product product) { this.productId = product.getId(); this.price = product.getPrice(); } // getters }

Come possiamo vedere, OrderItem viene creato in base a un prodotto . Mantiene il riferimento ad esso e memorizza il prezzo corrente del Prodotto .

Successivamente, creeremo un'interfaccia del repository (una porta in Hexagonal Architecture). L'implementazione dell'interfaccia avverrà nel livello infrastruttura:

public interface OrderRepository { Optional findById(UUID id); void save(Order order); }

Infine, dobbiamo assicurarci che l' Ordine venga sempre salvato dopo ogni azione. Per fare ciò, definiremo un servizio di dominio, che di solito contiene una logica che non può far parte della nostra radice :

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

In un'architettura esagonale, questo servizio è un adattatore che implementa la porta. Inoltre, non lo registreremo come bean Spring perché, dal punto di vista del dominio, si trova nella parte interna e la configurazione Spring è all'esterno. Lo collegheremo manualmente a Spring nel livello infrastruttura un po 'più tardi.

Poiché lo strato di dominio è completamente disaccoppiato da livelli applicativi e infrastruttura, si può anche verificare in modo indipendente :

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. Livello applicazione

In questa sezione, implementeremo il livello dell'applicazione. Consentiremo all'utente di comunicare con la nostra applicazione tramite un'API RESTful.

Pertanto, creiamo 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); } }

Questo semplice controller Spring Rest è responsabile dell'orchestrazione dell'esecuzione della logica di dominio .

Questo controller adatta l'interfaccia RESTful esterna al nostro dominio. Lo fa chiamando i metodi appropriati da OrderService (port).

6. Livello infrastruttura

Il livello infrastruttura contiene la logica necessaria per eseguire l'applicazione.

Pertanto, inizieremo creando le classi di configurazione. Per prima cosa, implementiamo una classe che registrerà il nostro OrderService come bean Spring:

@Configuration public class BeanConfiguration { @Bean OrderService orderService(OrderRepository orderRepository) { return new DomainOrderService(orderRepository); } }

Next, let's create the configuration responsible for enabling the Spring Data repositories we'll use:

@EnableMongoRepositories(basePackageClasses = SpringDataMongoOrderRepository.class) public class MongoDBConfiguration { }

We have used the basePackageClasses property because those repositories can only be in the infrastructure layer. Hence, there's no reason for Spring to scan the whole application. Furthermore, this class can contain everything related to establishing a connection between MongoDB and our application.

Lastly, we'll implement the OrderRepository from the domain layer. We'll use our SpringDataMongoOrderRepository in our implementation:

@Component public class MongoDbOrderRepository implements OrderRepository { private SpringDataMongoOrderRepository orderRepository; @Autowired public MongoDbOrderRepository(SpringDataMongoOrderRepository orderRepository) { this.orderRepository = orderRepository; } @Override public Optional findById(UUID id) { return orderRepository.findById(id); } @Override public void save(Order order) { orderRepository.save(order); } }

This implementation stores our Order in MongoDB. In a hexagonal architecture, this implementation is also an adapter.

7. Benefits

The first advantage of this approach is that we separate work for each layer. We can focus on one layer without affecting others.

Furthermore, they're naturally easier to understand because each of them focuses on its logic.

Another big advantage is that we've isolated the domain logic from everything else. The domain part only contains business logic and can be easily moved to a different environment.

In fact, let's change the infrastructure layer to use Cassandra as a database:

@Component public class CassandraDbOrderRepository implements OrderRepository { private final SpringDataCassandraOrderRepository orderRepository; @Autowired public CassandraDbOrderRepository(SpringDataCassandraOrderRepository orderRepository) { this.orderRepository = orderRepository; } @Override public Optional findById(UUID id) { Optional 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)); } }

Unlike MongoDB, we now use an OrderEntity to persist the domain in the database.

If we add technology-specific annotations to our Order domain object, then we violate the decoupling between infrastructure and domain layers.

The repository adapts the domain to our persistence needs.

Let's go a step further and transform our RESTful application into a command-line application:

@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("<>"); UUID orderId = createOrder(); orderService.completeOrder(orderId); } public void createIncompleteOrder() { LOG.info("<>"); 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; } }

Unlike before, we now have hardwired a set of predefined actions that interact with our domain. We could use this to populate our application with mocked data for example.

Even though we completely changed the purpose of the application, we haven't touched the domain layer.

8. Conclusion

In this article, we've learned how to separate the logic related to our application into specific layers.

Innanzitutto, abbiamo definito tre livelli principali: applicazione, dominio e infrastruttura. Successivamente, abbiamo descritto come riempirli e spiegato i vantaggi.

Quindi, siamo arrivati ​​all'implementazione per ogni livello:

Infine, abbiamo scambiato i livelli dell'applicazione e dell'infrastruttura senza influire sul dominio.

Come sempre, il codice per questi esempi è disponibile su GitHub.

Fondo Java

Ho appena annunciato il nuovo corso Learn Spring , incentrato sui fondamenti di Spring 5 e Spring Boot 2:

>> SCOPRI IL CORSO