Organisieren von Ebenen mithilfe von hexagonaler Architektur, DDD und Spring

Java Top

Ich habe gerade den neuen Learn Spring- Kurs angekündigt , der sich auf die Grundlagen von Spring 5 und Spring Boot 2 konzentriert:

>> Überprüfen Sie den Kurs

1. Übersicht

In diesem Tutorial implementieren wir eine Spring-Anwendung mit DDD. Zusätzlich organisieren wir Ebenen mithilfe der hexagonalen Architektur.

Mit diesem Ansatz können wir die verschiedenen Ebenen der Anwendung leicht austauschen.

2. Sechseckige Architektur

Die hexagonale Architektur ist ein Modell für das Entwerfen von Softwareanwendungen rund um die Domänenlogik , um sie von externen Faktoren zu isolieren.

Die Domänenlogik wird in einem Geschäftskern angegeben, den wir als inneren Teil bezeichnen, der Rest als äußere Teile. Der Zugriff auf die Domänenlogik von außen ist über Ports und Adapter möglich.

3. Grundsätze

Erstens sollten wir Prinzipien definieren, um unseren Code zu teilen. Wie bereits kurz erläutert, definiert die hexagonale Architektur den inneren und den äußeren Teil .

Stattdessen teilen wir unsere Anwendung in drei Ebenen auf. Anwendung (außen), Domäne (innen) und Infrastruktur (außen):

Über die Anwendungsschicht interagiert der Benutzer oder ein anderes Programm mit der Anwendung. Dieser Bereich sollte beispielsweise Benutzeroberflächen, RESTful-Controller und JSON-Serialisierungsbibliotheken enthalten. Es enthält alles , was den Zugriff auf unsere Anwendung ermöglicht und die Ausführung der Domänenlogik koordiniert.

In der Domänenschicht behalten wir den Code bei, der die Geschäftslogik berührt und implementiert . Dies ist der Kern unserer Anwendung. Darüber hinaus sollte diese Schicht sowohl vom Anwendungsteil als auch vom Infrastrukturteil isoliert sein. Darüber hinaus sollte es Schnittstellen enthalten, die die API für die Kommunikation mit externen Teilen definieren, z. B. der Datenbank, mit der die Domäne interagiert.

Schließlich ist die Infrastrukturschicht der Teil, der alles enthält, was die Anwendung zum Arbeiten benötigt, z. B. Datenbankkonfiguration oder Spring-Konfiguration. Außerdem werden infrastrukturabhängige Schnittstellen aus der Domänenschicht implementiert.

4. Domänenschicht

Beginnen wir mit der Implementierung unserer Kernschicht, der Domänenschicht.

Zunächst sollten wir die Order- Klasse erstellen :

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 }

Dies ist unsere Gesamtwurzel . Alles, was mit unserer Geschäftslogik zu tun hat, wird diese Klasse durchlaufen. Darüber hinaus ist Order dafür verantwortlich, sich im richtigen Zustand zu halten:

  • Die Bestellung kann nur mit der angegebenen ID und basierend auf einem Produkt erstellt werden - der Konstruktor selbst gibt die Bestellung auch mit dem Status CREATED ein
  • Sobald die Bestellung abgeschlossen ist, kann das Bestellelement nicht mehr geändert werden
  • Es ist unmöglich, die Reihenfolge von außerhalb des Domänenobjekts zu ändern , wie bei einem Setter

Darüber hinaus ist die Order- Klasse auch für die Erstellung ihres OrderItem verantwortlich .

Erstellen wir dann die OrderItem- Klasse:

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

Wie wir sehen können, wird OrderItem basierend auf einem Produkt erstellt . Es behält den Verweis darauf bei und speichert den aktuellen Preis des Produkts .

Als Nächstes erstellen wir eine Repository-Schnittstelle (einen Port in Hexagonal Architecture). Die Implementierung der Schnittstelle erfolgt in der Infrastrukturschicht:

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

Zuletzt sollten wir sicherstellen, dass die Bestellung nach jeder Aktion immer gespeichert wird. Dazu definieren wir einen Domänendienst, der normalerweise Logik enthält, die nicht Teil unseres Stamms sein kann :

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 einer hexagonalen Architektur ist dieser Dienst ein Adapter, der den Port implementiert. Außerdem wird es nicht als Spring Bean registriert, da dies aus Sicht der Domäne im inneren Teil und die Spring-Konfiguration im äußeren Teil liegt. Wir werden es etwas später manuell mit Spring in der Infrastrukturschicht verbinden.

Da die Domänenschicht vollständig von der Anwendungs- und Infrastrukturschicht entkoppelt ist , können wir sie auch unabhängig testen :

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. Anwendungsschicht

In diesem Abschnitt implementieren wir die Anwendungsschicht. Wir ermöglichen dem Benutzer die Kommunikation mit unserer Anwendung über eine RESTful-API.

Erstellen wir daher den 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); } }

Dieser einfache Spring Rest-Controller ist für die Orchestrierung der Ausführung der Domänenlogik verantwortlich .

Dieser Controller passt die externe RESTful-Schnittstelle an unsere Domain an. Dazu werden die entsprechenden Methoden von OrderService (Port) aufgerufen .

6. Infrastrukturschicht

Die Infrastrukturschicht enthält die Logik, die zum Ausführen der Anwendung erforderlich ist.

Daher erstellen wir zunächst die Konfigurationsklassen. Implementieren wir zunächst eine Klasse, die unseren OrderService als Spring Bean registriert :

@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.

Zunächst haben wir drei Hauptschichten definiert: Anwendung, Domäne und Infrastruktur. Danach haben wir beschrieben, wie man sie füllt und die Vorteile erklärt.

Dann haben wir die Implementierung für jede Ebene entwickelt:

Schließlich haben wir die Anwendungs- und Infrastrukturschichten ausgetauscht, ohne die Domäne zu beeinträchtigen.

Wie immer ist der Code für diese Beispiele auf GitHub verfügbar.

Java unten

Ich habe gerade den neuen Learn Spring- Kurs angekündigt , der sich auf die Grundlagen von Spring 5 und Spring Boot 2 konzentriert:

>> Überprüfen Sie den Kurs