├── .gitignore ├── LICENSE ├── README.md ├── invoicing ├── pom.xml └── src │ └── main │ ├── java │ └── net │ │ └── pkhapps │ │ └── ddd │ │ └── invoicing │ │ ├── InvoicingApp.java │ │ ├── application │ │ ├── InvoiceCreator.java │ │ └── InvoiceService.java │ │ ├── domain │ │ └── model │ │ │ ├── Invoice.java │ │ │ ├── InvoiceId.java │ │ │ ├── InvoiceItem.java │ │ │ ├── InvoiceItemId.java │ │ │ ├── InvoiceRepository.java │ │ │ ├── OrderId.java │ │ │ ├── OrderProcessedEvent.java │ │ │ └── converter │ │ │ └── OrderIdAttributeConverter.java │ │ ├── infra │ │ └── hibernate │ │ │ ├── InvoiceIdType.java │ │ │ ├── InvoiceItemIdType.java │ │ │ └── package-info.java │ │ ├── integration │ │ ├── OrderProcessedEventTranslator.java │ │ └── OrderStateChangedEvent.java │ │ └── rest │ │ ├── client │ │ ├── Order.java │ │ ├── OrderClient.java │ │ ├── OrderItem.java │ │ └── RecipientAddress.java │ │ └── controller │ │ └── InvoiceServiceController.java │ └── resources │ └── application.properties ├── orders ├── pom.xml └── src │ └── main │ ├── java │ └── net │ │ └── pkhapps │ │ └── ddd │ │ └── orders │ │ ├── DataGenerator.java │ │ ├── OrdersApp.java │ │ ├── application │ │ ├── OrderCatalog.java │ │ ├── ProductCatalog.java │ │ └── form │ │ │ ├── OrderForm.java │ │ │ ├── OrderItemForm.java │ │ │ └── RecipientAddressForm.java │ │ ├── domain │ │ └── model │ │ │ ├── Order.java │ │ │ ├── OrderId.java │ │ │ ├── OrderItem.java │ │ │ ├── OrderItemId.java │ │ │ ├── OrderRepository.java │ │ │ ├── OrderState.java │ │ │ ├── OrderStateChange.java │ │ │ ├── Product.java │ │ │ ├── ProductId.java │ │ │ ├── RecipientAddress.java │ │ │ ├── converter │ │ │ └── ProductIdConverter.java │ │ │ └── event │ │ │ ├── OrderCreated.java │ │ │ └── OrderStateChanged.java │ │ ├── infra │ │ └── hibernate │ │ │ ├── OrderIdType.java │ │ │ ├── OrderItemIdType.java │ │ │ └── package-info.java │ │ ├── rest │ │ ├── client │ │ │ └── ProductCatalogClient.java │ │ └── controller │ │ │ └── OrderCatalogController.java │ │ └── ui │ │ ├── CreateOrderView.java │ │ ├── OrderBrowserView.java │ │ ├── OrderDetailsView.java │ │ └── TabContainer.java │ └── resources │ └── application.properties ├── pom.xml ├── product-catalog ├── pom.xml └── src │ └── main │ ├── java │ └── net │ │ └── pkhapps │ │ └── ddd │ │ └── productcatalog │ │ ├── DataGenerator.java │ │ ├── ProductCatalogApp.java │ │ ├── application │ │ └── ProductCatalog.java │ │ ├── domain │ │ └── model │ │ │ ├── Product.java │ │ │ ├── ProductId.java │ │ │ └── ProductRepository.java │ │ ├── infra │ │ └── hibernate │ │ │ ├── ProductIdType.java │ │ │ └── package-info.java │ │ └── rest │ │ └── ProductCatalogController.java │ └── resources │ └── application.properties ├── shared-kernel ├── pom.xml └── src │ ├── main │ └── java │ │ └── net │ │ └── pkhapps │ │ └── ddd │ │ └── shared │ │ ├── SharedConfiguration.java │ │ ├── domain │ │ ├── base │ │ │ ├── AbstractAggregateRoot.java │ │ │ ├── AbstractEntity.java │ │ │ ├── ConcurrencySafeDomainObject.java │ │ │ ├── DeletableDomainObject.java │ │ │ ├── DomainEvent.java │ │ │ ├── DomainObject.java │ │ │ ├── DomainObjectId.java │ │ │ ├── IdentifiableDomainObject.java │ │ │ └── ValueObject.java │ │ ├── financial │ │ │ ├── Currency.java │ │ │ ├── CurrencyConverter.java │ │ │ ├── Money.java │ │ │ ├── VAT.java │ │ │ └── converter │ │ │ │ └── VATAttributeConverter.java │ │ └── geo │ │ │ ├── Address.java │ │ │ ├── CityName.java │ │ │ ├── Country.java │ │ │ ├── PostalCode.java │ │ │ └── converter │ │ │ ├── CityNameConverter.java │ │ │ └── PostalCodeConverter.java │ │ ├── infra │ │ ├── eventlog │ │ │ ├── DomainEventLog.java │ │ │ ├── DomainEventLogAppender.java │ │ │ ├── DomainEventLogId.java │ │ │ ├── DomainEventLogService.java │ │ │ ├── ProcessedRemoteEvent.java │ │ │ ├── ProcessedRemoteEventRepository.java │ │ │ ├── RemoteEventLog.java │ │ │ ├── RemoteEventLogService.java │ │ │ ├── RemoteEventProcessor.java │ │ │ ├── RemoteEventTranslator.java │ │ │ ├── StoredDomainEvent.java │ │ │ └── StoredDomainEventRepository.java │ │ ├── hibernate │ │ │ ├── DomainObjectIdCustomType.java │ │ │ └── DomainObjectIdTypeDescriptor.java │ │ └── jackson │ │ │ └── RawJsonDeserializer.java │ │ ├── rest │ │ ├── client │ │ │ ├── CurrencyConverterClient.java │ │ │ └── RemoteEventLogServiceClient.java │ │ └── controller │ │ │ └── EventLogController.java │ │ └── ui │ │ └── converter │ │ ├── StringToCityNameConverter.java │ │ └── StringToPostalCodeConverter.java │ └── test │ └── java │ └── net │ └── pkhapps │ └── ddd │ └── shared │ └── domain │ └── financial │ ├── MoneyTest.java │ └── VATTest.java └── shipping ├── pom.xml └── src └── main ├── java └── net │ └── pkhapps │ └── ddd │ └── shipping │ ├── ShippingApp.java │ ├── application │ ├── ShippingListCreator.java │ └── ShippingService.java │ ├── domain │ ├── OrderId.java │ ├── PickingList.java │ ├── PickingListId.java │ ├── PickingListItem.java │ ├── PickingListRepository.java │ ├── PickingListState.java │ ├── ProductId.java │ └── converter │ │ ├── OrderIdConverter.java │ │ └── ProductIdConverter.java │ ├── infra │ └── hibernate │ │ ├── PickingListIdType.java │ │ └── package-info.java │ ├── integration │ ├── OrderCreatedEvent.java │ └── OrderCreatedEventTranslator.java │ ├── rest │ ├── client │ │ ├── Order.java │ │ ├── OrderCatalogClient.java │ │ ├── OrderItem.java │ │ ├── OrderState.java │ │ └── RecipientAddress.java │ └── controller │ │ └── ShippingServiceController.java │ └── ui │ ├── PickingListBrowserView.java │ └── PickingListDetailsView.java └── resources └── application.properties /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .idea/ 3 | *.iml 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DDD Example 2 | 3 | This is an example project for my Vaadin TechLunch presentation on November 22, 2018 about 4 | *Domain Driven Design and the Hexagonal Architecture*. It demonstrates how you can use Spring Boot to create a 5 | semi-complex system consisting of multiple bounded contexts, represented here as individual Spring Boot applications. 6 | REST and domain events are used to integrate the contexts with each other. 7 | 8 | Here are some notes to pay special attention to: 9 | 10 | ## Value Objects as Entity IDs 11 | 12 | JPA does not support custom types for `@Id` fields out of the box and you can only use `AttributeConverter` for 13 | non-`@Id` fields. In this example project, I'm using Hibernate custom types which works with one caveat: you can't 14 | use them with `@GeneratedValue` without specifying your own generation strategy. 15 | 16 | The built-in strategies assume an integral data type (such as integer, long, `BigInteger`, etc.) and even if your custom 17 | type is only a wrapper around a long, the built-in strategies will not recognize it as such. 18 | 19 | In this example project, I've solved the problem by using UUIDs and creating the IDs immediately when the entity objects 20 | are created. This has the added advantage of making the ID known and available before anything is persisted, which can 21 | be useful sometimes. 22 | 23 | However, if you want to use sequences or identity columns for ID generation, you have to either create your own ID 24 | generator strategy or use integral types and then wrap them manually in your public API. The advantage with this 25 | approach is that you no longer need any Hibernate custom types but can use `AttributeConverter`s for your reference 26 | fields. 27 | 28 | ## Domain Objects, REST controllers and JSON 29 | 30 | In this application, I'm using `@JsonProperty` annotations directly on my domain objects (entities, value objects, etc.) 31 | and returning them directly from my REST controllers. I'm doing this to save time but in real-world applications this is 32 | not a good practice. 33 | 34 | You will want to have your external REST API to remain as stable as possible, while being able to evolve the domain 35 | model as you learn new things and new requirements emerge. This is hard if your REST API is directly based on the domain 36 | model. Therefore, in a real world application, I very much recommmend you to use dedicated DTOs for your REST APIs. 37 | 38 | ## Lack of Pagination 39 | 40 | To keep things simple, I don't use pagination anywhere (except for the domain event log). In real-world applications, 41 | you should *always* use pagination for unbounded queries (i.e. queries that you don't know for sure will return only 42 | a small and limited number of items). 43 | 44 | ## Domain Event Distribution through REST 45 | 46 | The domain event log and REST protocol is based on the approach presented in *Implementing Domain Driven Design* by 47 | *Vaughn Vernon*. The idea itself is production-ready but the implementation in this example project is not. The handling 48 | of JSON is not optimal, caching headers are missing from the REST responses, there is no test coverage and the solution 49 | is not resilient enough. You can however use what's here as a basis for a production ready implementation, but don't 50 | use it 'as-is' in real-world applications. 51 | 52 | ## @NonNull and @Nullable 53 | 54 | I'm using the `@NonNull` and `@Nullable` annotations everywhere to make it crystal-clear which parameters can or can't 55 | be null and which methods return or don't return null. This is a practice I've started to use lately and so far I really 56 | like it. In this project I'm using the Spring annotations, but I've also used JetBrains' annotations or the 57 | JSR-305 annotations. 58 | 59 | In addition, I often use `Objects.requireNonNull(..)` in the beginning of each method that accepts non-null parameters. 60 | 61 | With the combination of annotations and explicit null-checks I hope to avoid tracking down annoying NPE-bugs in the 62 | future. 63 | 64 | ## The Absence of Getters 65 | 66 | In this project I've avoided getter methods wherever I can. I find it makes the code more fluent and easier to read. 67 | However, I've also discovered that since the usage of getter methods is such a large practice in the Java world, a lof 68 | of conventions and IDE tools no longer work out-of-the-box. This in turn increases the need for annotations and manual 69 | coding. 70 | 71 | Since there is no technical difference beween `person.firstName()` and `person.getFirstName()`, you have to consider 72 | whether leaving out the `get` prefix is really worth the effort. 73 | 74 | ## Setters and Bean Validation 75 | 76 | For all the domain classes, the API is written in such a way that you can't put the aggregate into an inconsistent 77 | state, nor can you edit all the properties after creation (there are virtually no public setters). This means that Bean 78 | Validation (JSR-303) is not needed in the domain layer. 79 | 80 | This in turn means that different objects need to be used in the UI layer for data binding. In this project I'm calling 81 | these objects *form objects*. The naming is from back in the day when you had to fill out forms on paper, mail them 82 | to the retailer who then entered the information into the system. You could also call these objects *request objects* 83 | (since you use them to request a specific operation), *data transfer objects (DTO)s* or something else. 84 | 85 | When an application service receives a form object, it moves the information into the domain layer. To save some time, 86 | you could add JSR-303 annotations to the form object classes and let the bean validator validate the form object before 87 | the application service does anything else with it. Any validation errors could then be reported to the user in a user- 88 | friendly manner. 89 | -------------------------------------------------------------------------------- /invoicing/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | master-pom 7 | net.pkhapps.ddd 8 | 1.0-SNAPSHOT 9 | 10 | 4.0.0 11 | 12 | invoicing 13 | 14 | 15 | 16 | net.pkhapps.ddd 17 | shared-kernel 18 | ${project.version} 19 | 20 | 21 | org.springframework.boot 22 | spring-boot-starter-web 23 | 24 | 25 | com.vaadin 26 | vaadin-spring-boot-starter 27 | 28 | 29 | com.h2database 30 | h2 31 | 32 | 33 | 34 | 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-maven-plugin 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/InvoicingApp.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing; 2 | 3 | import net.pkhapps.ddd.shared.SharedConfiguration; 4 | import net.pkhapps.ddd.shared.infra.eventlog.RemoteEventLogService; 5 | import net.pkhapps.ddd.shared.rest.client.RemoteEventLogServiceClient; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.boot.SpringApplication; 8 | import org.springframework.boot.autoconfigure.SpringBootApplication; 9 | import org.springframework.boot.autoconfigure.domain.EntityScan; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.Import; 12 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 13 | 14 | import java.time.Clock; 15 | 16 | @SpringBootApplication 17 | @EnableJpaRepositories 18 | @EntityScan 19 | @Import(SharedConfiguration.class) 20 | public class InvoicingApp { 21 | 22 | public static void main(String[] args) { 23 | SpringApplication.run(InvoicingApp.class, args); 24 | } 25 | 26 | @Bean 27 | public Clock clock() { 28 | return Clock.systemUTC(); 29 | } 30 | 31 | @Bean 32 | public RemoteEventLogService orderEvents(@Value("${app.orders.url}") String serverUrl, 33 | @Value("${app.orders.connect-timeout-ms}") int connectTimeout, 34 | @Value("${app.orders.read-timeout-ms}") int readTimeout) { 35 | return new RemoteEventLogServiceClient(serverUrl, connectTimeout, readTimeout); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/application/InvoiceCreator.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.application; 2 | 3 | import net.pkhapps.ddd.invoicing.domain.model.Invoice; 4 | import net.pkhapps.ddd.invoicing.domain.model.InvoiceRepository; 5 | import net.pkhapps.ddd.invoicing.domain.model.OrderProcessedEvent; 6 | import net.pkhapps.ddd.invoicing.rest.client.Order; 7 | import net.pkhapps.ddd.invoicing.rest.client.OrderClient; 8 | import net.pkhapps.ddd.shared.domain.geo.Address; 9 | import org.springframework.lang.NonNull; 10 | import org.springframework.stereotype.Service; 11 | import org.springframework.transaction.event.TransactionPhase; 12 | import org.springframework.transaction.event.TransactionalEventListener; 13 | 14 | import java.time.Clock; 15 | 16 | @Service 17 | class InvoiceCreator { 18 | 19 | private static final int DEFAULT_TERMS = 14; 20 | private final InvoiceRepository invoiceRepository; 21 | private final OrderClient orderClient; 22 | private final Clock clock; 23 | 24 | InvoiceCreator(InvoiceRepository invoiceRepository, OrderClient orderClient, Clock clock) { 25 | this.invoiceRepository = invoiceRepository; 26 | this.orderClient = orderClient; 27 | this.clock = clock; 28 | } 29 | 30 | @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 31 | public void onOrderProcessedEvent(OrderProcessedEvent orderProcessedEvent) { 32 | var orderId = orderProcessedEvent.orderId(); 33 | // Check if we have done this before (events can receive many times in case of errors) 34 | if (invoiceRepository.findByOrderId(orderId).count() == 0) { 35 | orderClient.findById(orderId).map(this::createInvoice).ifPresent(invoiceRepository::save); 36 | } 37 | } 38 | 39 | @NonNull 40 | private Invoice createInvoice(@NonNull Order order) { 41 | var billingAddress = order.billingAddress(); 42 | var invoice = new Invoice(clock.instant(), order.orderId(), DEFAULT_TERMS, billingAddress.name(), 43 | new Address(billingAddress.address1(), 44 | billingAddress.address2(), 45 | billingAddress.cityName(), 46 | billingAddress.postalCode(), 47 | billingAddress.country()), 48 | order.currency()); 49 | order.items().forEach(item -> invoice.addItem(item.description(), item.price(), item.vat(), item.qty())); 50 | return invoice; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/application/InvoiceService.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.application; 2 | 3 | import net.pkhapps.ddd.invoicing.domain.model.Invoice; 4 | import net.pkhapps.ddd.invoicing.domain.model.InvoiceId; 5 | import net.pkhapps.ddd.invoicing.domain.model.InvoiceRepository; 6 | import org.springframework.lang.NonNull; 7 | import org.springframework.stereotype.Service; 8 | import org.springframework.transaction.annotation.Propagation; 9 | import org.springframework.transaction.annotation.Transactional; 10 | 11 | import java.util.List; 12 | import java.util.Optional; 13 | 14 | @Service 15 | @Transactional(propagation = Propagation.REQUIRES_NEW) 16 | public class InvoiceService { 17 | 18 | private final InvoiceRepository invoiceRepository; 19 | 20 | InvoiceService(InvoiceRepository invoiceRepository) { 21 | this.invoiceRepository = invoiceRepository; 22 | } 23 | 24 | @NonNull 25 | public List findAll() { 26 | return invoiceRepository.findAll(); 27 | } 28 | 29 | @NonNull 30 | public Optional findById(@NonNull InvoiceId invoiceId) { 31 | return invoiceRepository.findById(invoiceId); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/domain/model/Invoice.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.domain.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.base.AbstractAggregateRoot; 5 | import net.pkhapps.ddd.shared.domain.base.ConcurrencySafeDomainObject; 6 | import net.pkhapps.ddd.shared.domain.financial.Currency; 7 | import net.pkhapps.ddd.shared.domain.financial.Money; 8 | import net.pkhapps.ddd.shared.domain.financial.VAT; 9 | import net.pkhapps.ddd.shared.domain.geo.Address; 10 | import org.springframework.lang.NonNull; 11 | 12 | import javax.persistence.*; 13 | import java.time.Instant; 14 | import java.time.LocalDate; 15 | import java.time.ZoneId; 16 | import java.util.HashSet; 17 | import java.util.Objects; 18 | import java.util.Set; 19 | import java.util.stream.Stream; 20 | 21 | @Entity 22 | @Table(name = "invoices") 23 | public class Invoice extends AbstractAggregateRoot implements ConcurrencySafeDomainObject { 24 | 25 | @Version 26 | private Long version; 27 | 28 | @Column(name = "created_on", nullable = false) 29 | private Instant createdOn; 30 | 31 | @Column(name = "order_id", nullable = false) 32 | private OrderId orderId; 33 | 34 | @Column(name = "terms", nullable = false) 35 | private int terms; 36 | 37 | @Column(name = "due_date", nullable = false) 38 | private LocalDate dueDate; 39 | 40 | @Column(name = "recipient_name", nullable = false) 41 | private String recipientName; 42 | 43 | @Embedded 44 | private Address address; 45 | 46 | @OneToMany(mappedBy = "invoice", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) 47 | private Set items; 48 | 49 | @Column(name = "currency", nullable = false) 50 | @Enumerated(EnumType.STRING) 51 | private Currency currency; 52 | 53 | @Column(name = "total_excl_vat", nullable = false) 54 | private int totalExcludingVat; 55 | 56 | @Column(name = "total_vat", nullable = false) 57 | private int totalVat; 58 | 59 | @Column(name = "total_incl_vat", nullable = false) 60 | private int totalIncludingVat; 61 | 62 | @SuppressWarnings("unused") // Used by JPA only 63 | private Invoice() { 64 | } 65 | 66 | public Invoice(@NonNull Instant createdOn, @NonNull OrderId orderId, int terms, @NonNull String recipientName, 67 | @NonNull Address address, @NonNull Currency currency) { 68 | super(InvoiceId.randomId(InvoiceId.class)); 69 | items = new HashSet<>(); 70 | setCreatedOn(createdOn); 71 | setOrderId(orderId); 72 | setTerms(terms); 73 | setRecipientName(recipientName); 74 | setAddress(address); 75 | setCurrency(currency); 76 | calculateTotals(); 77 | } 78 | 79 | @NonNull 80 | @JsonProperty("createdOn") 81 | public Instant createdOn() { 82 | return createdOn; 83 | } 84 | 85 | private void setCreatedOn(@NonNull Instant createdOn) { 86 | this.createdOn = Objects.requireNonNull(createdOn, "createdOn must not be null"); 87 | } 88 | 89 | @NonNull 90 | @JsonProperty("orderId") 91 | public OrderId orderId() { 92 | return orderId; 93 | } 94 | 95 | private void setOrderId(@NonNull OrderId orderId) { 96 | this.orderId = Objects.requireNonNull(orderId, "orderId must not be null"); 97 | } 98 | 99 | @JsonProperty("terms") 100 | public int terms() { 101 | return terms; 102 | } 103 | 104 | private void setTerms(int terms) { 105 | this.terms = terms; 106 | dueDate = LocalDate.ofInstant(createdOn(), ZoneId.systemDefault()).plusDays(terms); 107 | } 108 | 109 | @NonNull 110 | @JsonProperty("dueDate") 111 | public LocalDate dueDate() { 112 | return dueDate; 113 | } 114 | 115 | @NonNull 116 | @JsonProperty("recipientName") 117 | public String recipientName() { 118 | return recipientName; 119 | } 120 | 121 | private void setRecipientName(@NonNull String recipientName) { 122 | this.recipientName = Objects.requireNonNull(recipientName, "recipientName must not be null"); 123 | } 124 | 125 | @NonNull 126 | @JsonProperty("address") 127 | public Address address() { 128 | return address; 129 | } 130 | 131 | private void setAddress(@NonNull Address address) { 132 | this.address = Objects.requireNonNull(address, "address must not be null"); 133 | } 134 | 135 | @NonNull 136 | @JsonProperty("items") 137 | public Stream items() { 138 | return items.stream(); 139 | } 140 | 141 | @NonNull 142 | public InvoiceItem addItem(@NonNull String description, @NonNull Money price, @NonNull VAT vat, int quantity) { 143 | if (price.currency() != currency) { 144 | throw new IllegalArgumentException("Item price must be in same currency as invoice"); 145 | } 146 | var item = new InvoiceItem(this, description, price, vat, quantity); 147 | items.add(item); 148 | calculateTotals(); 149 | return item; 150 | } 151 | 152 | @NonNull 153 | @JsonProperty("currency") 154 | public Currency currency() { 155 | return currency; 156 | } 157 | 158 | private void setCurrency(@NonNull Currency currency) { 159 | this.currency = Objects.requireNonNull(currency, "currency must not be null"); 160 | } 161 | 162 | @NonNull 163 | @JsonProperty("totalExcludingVat") 164 | public Money totalExcludingVat() { 165 | return new Money(currency, totalExcludingVat); 166 | } 167 | 168 | @NonNull 169 | @JsonProperty("totalVat") 170 | public Money getTotalVat() { 171 | return new Money(currency, totalVat); 172 | } 173 | 174 | @NonNull 175 | @JsonProperty("totalIncludingVat") 176 | public Money totalIncludingVat() { 177 | return new Money(currency, totalIncludingVat); 178 | } 179 | 180 | private void calculateTotals() { 181 | totalExcludingVat = items() 182 | .map(InvoiceItem::subtotalExcludingVat) 183 | .reduce(new Money(currency, 0), Money::add) 184 | .fixedPointAmount(); 185 | totalVat = items() 186 | .map(InvoiceItem::subtotalVat) 187 | .reduce(new Money(currency, 0), Money::add) 188 | .fixedPointAmount(); 189 | totalIncludingVat = totalExcludingVat + totalVat; 190 | } 191 | 192 | @Override 193 | public Long version() { 194 | return version; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/domain/model/InvoiceId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.domain.model; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 4 | import org.springframework.lang.NonNull; 5 | 6 | public class InvoiceId extends DomainObjectId { 7 | public InvoiceId(@NonNull String uuid) { 8 | super(uuid); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/domain/model/InvoiceItem.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.domain.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.base.AbstractEntity; 5 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 6 | import net.pkhapps.ddd.shared.domain.financial.Currency; 7 | import net.pkhapps.ddd.shared.domain.financial.Money; 8 | import net.pkhapps.ddd.shared.domain.financial.VAT; 9 | import org.springframework.lang.NonNull; 10 | 11 | import javax.persistence.*; 12 | import java.util.Objects; 13 | 14 | @Entity 15 | @Table(name = "invoice_items") 16 | public class InvoiceItem extends AbstractEntity { 17 | 18 | @ManyToOne(optional = false) 19 | @JoinColumn(name = "invoice_id", nullable = false) 20 | private Invoice invoice; 21 | 22 | @Column(name = "description", nullable = false) 23 | private String description; 24 | 25 | @Column(name = "currency", nullable = false) 26 | @Enumerated(EnumType.STRING) 27 | private Currency currency; 28 | 29 | @Column(name = "price", nullable = false) 30 | private int price; 31 | 32 | @Column(name = "vat", nullable = false) 33 | private VAT vat; 34 | 35 | @Column(name = "qty", nullable = false) 36 | private int quantity; 37 | 38 | @Column(name = "subtotal_excl_vat", nullable = false) 39 | private int subtotalExcludingVat; 40 | 41 | @Column(name = "subtotal_vat", nullable = false) 42 | private int subtotalVat; 43 | 44 | @Column(name = "subtotal_incl_vat", nullable = false) 45 | private int subtotalIncludingVat; 46 | 47 | @SuppressWarnings("unused") // Used by JPA only 48 | private InvoiceItem() { 49 | } 50 | 51 | InvoiceItem(@NonNull Invoice invoice, @NonNull String description, @NonNull Money price, @NonNull VAT vat, int quantity) { 52 | super(DomainObjectId.randomId(InvoiceItemId.class)); 53 | setInvoice(invoice); 54 | setDescription(description); 55 | setPrice(price); 56 | setVat(vat); 57 | setQuantity(quantity); 58 | calculateSubTotals(); 59 | } 60 | 61 | @NonNull 62 | public Invoice invoice() { 63 | return invoice; 64 | } 65 | 66 | private void setInvoice(@NonNull Invoice invoice) { 67 | this.invoice = Objects.requireNonNull(invoice, "invoice must not be null"); 68 | } 69 | 70 | @NonNull 71 | @JsonProperty("description") 72 | public String description() { 73 | return description; 74 | } 75 | 76 | private void setDescription(@NonNull String description) { 77 | this.description = Objects.requireNonNull(description, "description must not be null"); 78 | } 79 | 80 | @NonNull 81 | @JsonProperty("price") 82 | public Money price() { 83 | return Money.valueOf(currency, price); 84 | } 85 | 86 | private void setPrice(@NonNull Money price) { 87 | Objects.requireNonNull(price, "price must not be null"); 88 | this.currency = price.currency(); 89 | this.price = price.fixedPointAmount(); 90 | } 91 | 92 | @NonNull 93 | @JsonProperty("vat") 94 | public VAT vat() { 95 | return vat; 96 | } 97 | 98 | private void setVat(@NonNull VAT vat) { 99 | this.vat = Objects.requireNonNull(vat, "vat must not be null"); 100 | } 101 | 102 | @JsonProperty("qty") 103 | public int quantity() { 104 | return quantity; 105 | } 106 | 107 | private void setQuantity(int quantity) { 108 | this.quantity = quantity; 109 | } 110 | 111 | @NonNull 112 | @JsonProperty("subtotalExcludingVat") 113 | public Money subtotalExcludingVat() { 114 | return new Money(currency, subtotalExcludingVat); 115 | } 116 | 117 | @NonNull 118 | @JsonProperty("subtotalIncludingVat") 119 | public Money subtotalIncludingVat() { 120 | return new Money(currency, subtotalIncludingVat); 121 | } 122 | 123 | @NonNull 124 | @JsonProperty("subtotalVat") 125 | public Money subtotalVat() { 126 | return new Money(currency, subtotalVat); 127 | } 128 | 129 | private void calculateSubTotals() { 130 | subtotalExcludingVat = price().multiply(quantity).fixedPointAmount(); 131 | subtotalVat = vat.calculateTax(new Money(currency, subtotalExcludingVat)).fixedPointAmount(); 132 | subtotalIncludingVat = subtotalExcludingVat + subtotalVat; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/domain/model/InvoiceItemId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.domain.model; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 4 | import org.springframework.lang.NonNull; 5 | 6 | public class InvoiceItemId extends DomainObjectId { 7 | public InvoiceItemId(@NonNull String uuid) { 8 | super(uuid); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/domain/model/InvoiceRepository.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.domain.model; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.lang.NonNull; 5 | 6 | import java.util.stream.Stream; 7 | 8 | public interface InvoiceRepository extends JpaRepository { 9 | @NonNull 10 | Stream findByOrderId(@NonNull OrderId orderId); 11 | } 12 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/domain/model/OrderId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.domain.model; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 4 | import org.springframework.lang.NonNull; 5 | 6 | public class OrderId extends DomainObjectId { 7 | public OrderId(@NonNull String uuid) { 8 | super(uuid); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/domain/model/OrderProcessedEvent.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.domain.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import net.pkhapps.ddd.shared.domain.base.DomainEvent; 6 | import org.springframework.lang.NonNull; 7 | 8 | import java.time.Instant; 9 | import java.util.Objects; 10 | 11 | public class OrderProcessedEvent implements DomainEvent { 12 | 13 | @JsonProperty("orderId") 14 | private final OrderId orderId; 15 | @JsonProperty("occurredOn") 16 | private final Instant occurredOn; 17 | 18 | @JsonCreator 19 | public OrderProcessedEvent(@JsonProperty("orderId") @NonNull OrderId orderId, 20 | @JsonProperty("occurredOn") @NonNull Instant occurredOn) { 21 | this.orderId = Objects.requireNonNull(orderId, "orderId must not be null"); 22 | this.occurredOn = Objects.requireNonNull(occurredOn, "occurredOn must not be null"); 23 | } 24 | 25 | @NonNull 26 | public OrderId orderId() { 27 | return orderId; 28 | } 29 | 30 | @Override 31 | @NonNull 32 | public Instant occurredOn() { 33 | return occurredOn; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/domain/model/converter/OrderIdAttributeConverter.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.domain.model.converter; 2 | 3 | import net.pkhapps.ddd.invoicing.domain.model.OrderId; 4 | 5 | import javax.persistence.AttributeConverter; 6 | import javax.persistence.Converter; 7 | 8 | @Converter(autoApply = true) 9 | public class OrderIdAttributeConverter implements AttributeConverter { 10 | 11 | @Override 12 | public String convertToDatabaseColumn(OrderId attribute) { 13 | return attribute == null ? null : attribute.toUUID(); 14 | } 15 | 16 | @Override 17 | public OrderId convertToEntityAttribute(String dbData) { 18 | return dbData == null ? null : new OrderId(dbData); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/infra/hibernate/InvoiceIdType.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.infra.hibernate; 2 | 3 | import net.pkhapps.ddd.invoicing.domain.model.InvoiceId; 4 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdCustomType; 5 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdTypeDescriptor; 6 | 7 | public class InvoiceIdType extends DomainObjectIdCustomType { 8 | 9 | private static final DomainObjectIdTypeDescriptor TYPE_DESCRIPTOR = 10 | new DomainObjectIdTypeDescriptor<>(InvoiceId.class, InvoiceId::new); 11 | 12 | public InvoiceIdType() { 13 | super(TYPE_DESCRIPTOR); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/infra/hibernate/InvoiceItemIdType.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.infra.hibernate; 2 | 3 | import net.pkhapps.ddd.invoicing.domain.model.InvoiceItemId; 4 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdCustomType; 5 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdTypeDescriptor; 6 | 7 | public class InvoiceItemIdType extends DomainObjectIdCustomType { 8 | 9 | private static final DomainObjectIdTypeDescriptor TYPE_DESCRIPTOR = 10 | new DomainObjectIdTypeDescriptor<>(InvoiceItemId.class, InvoiceItemId::new); 11 | 12 | public InvoiceItemIdType() { 13 | super(TYPE_DESCRIPTOR); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/infra/hibernate/package-info.java: -------------------------------------------------------------------------------- 1 | @TypeDefs({ 2 | @TypeDef(defaultForType = InvoiceId.class, typeClass = InvoiceIdType.class), 3 | @TypeDef(defaultForType = InvoiceItemId.class, typeClass = InvoiceItemIdType.class) 4 | }) 5 | package net.pkhapps.ddd.invoicing.infra.hibernate; 6 | 7 | import net.pkhapps.ddd.invoicing.domain.model.InvoiceId; 8 | import net.pkhapps.ddd.invoicing.domain.model.InvoiceItemId; 9 | import org.hibernate.annotations.TypeDef; 10 | import org.hibernate.annotations.TypeDefs; -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/integration/OrderProcessedEventTranslator.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.integration; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import net.pkhapps.ddd.invoicing.domain.model.OrderProcessedEvent; 5 | import net.pkhapps.ddd.shared.domain.base.DomainEvent; 6 | import net.pkhapps.ddd.shared.infra.eventlog.RemoteEventTranslator; 7 | import net.pkhapps.ddd.shared.infra.eventlog.StoredDomainEvent; 8 | import org.springframework.lang.NonNull; 9 | import org.springframework.stereotype.Service; 10 | 11 | import java.util.Optional; 12 | 13 | @Service 14 | class OrderProcessedEventTranslator implements RemoteEventTranslator { 15 | 16 | private final ObjectMapper objectMapper; 17 | 18 | OrderProcessedEventTranslator(ObjectMapper objectMapper) { 19 | this.objectMapper = objectMapper; 20 | } 21 | 22 | @Override 23 | public boolean supports(@NonNull StoredDomainEvent remoteEvent) { 24 | return remoteEvent.domainEventClassName().equals("net.pkhapps.ddd.orders.domain.model.event.OrderStateChanged"); 25 | } 26 | 27 | @Override 28 | @NonNull 29 | public Optional translate(@NonNull StoredDomainEvent remoteEvent) { 30 | var orderStateChanged = remoteEvent.toDomainEvent(objectMapper, OrderStateChangedEvent.class); 31 | if (orderStateChanged.state().equals("PROCESSED")) { 32 | return Optional.of(new OrderProcessedEvent(orderStateChanged.orderId(), orderStateChanged.occurredOn())); 33 | } 34 | return Optional.empty(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/integration/OrderStateChangedEvent.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.integration; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import net.pkhapps.ddd.invoicing.domain.model.OrderId; 6 | import net.pkhapps.ddd.shared.domain.base.DomainEvent; 7 | import org.springframework.lang.NonNull; 8 | 9 | import java.time.Instant; 10 | import java.util.Objects; 11 | 12 | class OrderStateChangedEvent implements DomainEvent { 13 | 14 | private final Instant occurredOn; 15 | private final OrderId orderId; 16 | private final String state; 17 | 18 | @JsonCreator 19 | public OrderStateChangedEvent(@NonNull @JsonProperty("occurredOn") Instant occurredOn, 20 | @NonNull @JsonProperty("orderId") OrderId orderId, 21 | @NonNull @JsonProperty("state") String state) { 22 | this.occurredOn = Objects.requireNonNull(occurredOn, "occurredOn must not be null"); 23 | this.orderId = Objects.requireNonNull(orderId, "orderId must not be null"); 24 | this.state = Objects.requireNonNull(state, "state must not be null"); 25 | } 26 | 27 | @Override 28 | @NonNull 29 | public Instant occurredOn() { 30 | return occurredOn; 31 | } 32 | 33 | @NonNull 34 | public OrderId orderId() { 35 | return orderId; 36 | } 37 | 38 | @NonNull 39 | public String state() { 40 | return state; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/rest/client/Order.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.rest.client; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.invoicing.domain.model.OrderId; 5 | import net.pkhapps.ddd.shared.domain.financial.Currency; 6 | 7 | import java.util.Set; 8 | import java.util.stream.Stream; 9 | 10 | public class Order { 11 | 12 | @JsonProperty("id") 13 | private OrderId id; 14 | 15 | @JsonProperty("currency") 16 | private Currency currency; 17 | 18 | @JsonProperty("billingAddress") 19 | private RecipientAddress billingAddress; 20 | 21 | @JsonProperty("items") 22 | private Set items; 23 | 24 | Order() { 25 | } 26 | 27 | public OrderId orderId() { 28 | return id; 29 | } 30 | 31 | public Currency currency() { 32 | return currency; 33 | } 34 | 35 | public RecipientAddress billingAddress() { 36 | return billingAddress; 37 | } 38 | 39 | public Stream items() { 40 | return items.stream(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/rest/client/OrderClient.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.rest.client; 2 | 3 | import net.pkhapps.ddd.invoicing.domain.model.OrderId; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.http.client.SimpleClientHttpRequestFactory; 7 | import org.springframework.lang.NonNull; 8 | import org.springframework.stereotype.Service; 9 | import org.springframework.web.client.RestTemplate; 10 | import org.springframework.web.util.UriComponentsBuilder; 11 | 12 | import java.util.Optional; 13 | 14 | @Service 15 | public class OrderClient { 16 | 17 | private final RestTemplate restTemplate; 18 | private final String serverUrl; 19 | 20 | OrderClient(@Value("${app.orders.url}") String serverUrl, 21 | @Value("${app.orders.connect-timeout-ms}") int connectTimeout, 22 | @Value("${app.orders.read-timeout-ms}") int readTimeout) { 23 | this.serverUrl = serverUrl; 24 | restTemplate = new RestTemplate(); 25 | var requestFactory = new SimpleClientHttpRequestFactory(); 26 | requestFactory.setConnectTimeout(connectTimeout); 27 | requestFactory.setReadTimeout(readTimeout); 28 | restTemplate.setRequestFactory(requestFactory); 29 | } 30 | 31 | @NonNull 32 | public Optional findById(@NonNull OrderId orderId) { 33 | var uri = UriComponentsBuilder.fromUriString(serverUrl).path("/api/orders/{id}"); 34 | try { 35 | ResponseEntity response = restTemplate.getForEntity(uri.build(orderId.toUUID()), Order.class); 36 | return Optional.ofNullable(response.getBody()); 37 | } catch (Exception ex) { 38 | return Optional.empty(); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/rest/client/OrderItem.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.rest.client; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.financial.Money; 5 | import net.pkhapps.ddd.shared.domain.financial.VAT; 6 | 7 | public class OrderItem { 8 | 9 | @JsonProperty("description") 10 | private String description; 11 | 12 | @JsonProperty("price") 13 | private Money price; 14 | 15 | @JsonProperty("valueAddedTax") 16 | private VAT vat; 17 | 18 | @JsonProperty("qty") 19 | private int qty; 20 | 21 | OrderItem() { 22 | } 23 | 24 | public String description() { 25 | return description; 26 | } 27 | 28 | public Money price() { 29 | return price; 30 | } 31 | 32 | public VAT vat() { 33 | return vat; 34 | } 35 | 36 | public int qty() { 37 | return qty; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/rest/client/RecipientAddress.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.rest.client; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.geo.CityName; 5 | import net.pkhapps.ddd.shared.domain.geo.Country; 6 | import net.pkhapps.ddd.shared.domain.geo.PostalCode; 7 | 8 | public class RecipientAddress { 9 | 10 | @JsonProperty("name") 11 | private String name; 12 | 13 | @JsonProperty("address1") 14 | private String address1; 15 | 16 | @JsonProperty("address2") 17 | private String address2; 18 | 19 | @JsonProperty("postalCode") 20 | private PostalCode postalCode; 21 | 22 | @JsonProperty("city") 23 | private CityName cityName; 24 | 25 | @JsonProperty("country") 26 | private Country country; 27 | 28 | RecipientAddress() { 29 | } 30 | 31 | public String name() { 32 | return name; 33 | } 34 | 35 | public String address1() { 36 | return address1; 37 | } 38 | 39 | public String address2() { 40 | return address2; 41 | } 42 | 43 | public PostalCode postalCode() { 44 | return postalCode; 45 | } 46 | 47 | public CityName cityName() { 48 | return cityName; 49 | } 50 | 51 | public Country country() { 52 | return country; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /invoicing/src/main/java/net/pkhapps/ddd/invoicing/rest/controller/InvoiceServiceController.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.invoicing.rest.controller; 2 | 3 | import net.pkhapps.ddd.invoicing.application.InvoiceService; 4 | import net.pkhapps.ddd.invoicing.domain.model.Invoice; 5 | import net.pkhapps.ddd.invoicing.domain.model.InvoiceId; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.PathVariable; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | import java.util.List; 13 | 14 | @RestController 15 | @RequestMapping("/api/invoices") 16 | class InvoiceServiceController { 17 | 18 | private final InvoiceService invoiceService; 19 | 20 | InvoiceServiceController(InvoiceService invoiceService) { 21 | this.invoiceService = invoiceService; 22 | } 23 | 24 | @GetMapping 25 | public List findAll() { 26 | return invoiceService.findAll(); 27 | } 28 | 29 | @GetMapping("/{id}") 30 | public ResponseEntity findById(@PathVariable("id") String invoiceId) { 31 | return invoiceService.findById(new InvoiceId(invoiceId)) 32 | .map(ResponseEntity::ok) 33 | .orElse(ResponseEntity.notFound().build()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /invoicing/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.h2.console.enabled=true 2 | spring.h2.console.path=/h2-console 3 | spring.datasource.url=jdbc:h2:mem:invoicing;DB_CLOSE_DELAY=-1. 4 | server.port=9004 5 | 6 | app.orders.url=http://localhost:9002 7 | app.orders.connect-timeout-ms=1000 8 | app.orders.read-timeout-ms=5000 9 | logging.level.net.pkhapps.ddd=debug 10 | -------------------------------------------------------------------------------- /orders/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | master-pom 7 | net.pkhapps.ddd 8 | 1.0-SNAPSHOT 9 | 10 | 4.0.0 11 | 12 | orders 13 | 14 | 15 | 16 | net.pkhapps.ddd 17 | shared-kernel 18 | ${project.version} 19 | 20 | 21 | org.springframework.boot 22 | spring-boot-starter-web 23 | 24 | 25 | com.vaadin 26 | vaadin-spring-boot-starter 27 | 28 | 29 | com.h2database 30 | h2 31 | 32 | 33 | 34 | 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-maven-plugin 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/DataGenerator.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders; 2 | 3 | import net.pkhapps.ddd.orders.domain.model.*; 4 | import net.pkhapps.ddd.shared.domain.financial.Currency; 5 | import net.pkhapps.ddd.shared.domain.financial.CurrencyConverter; 6 | import net.pkhapps.ddd.shared.domain.financial.Money; 7 | import net.pkhapps.ddd.shared.domain.financial.VAT; 8 | import net.pkhapps.ddd.shared.domain.geo.CityName; 9 | import net.pkhapps.ddd.shared.domain.geo.Country; 10 | import net.pkhapps.ddd.shared.domain.geo.PostalCode; 11 | 12 | import javax.annotation.PostConstruct; 13 | import java.time.Instant; 14 | import java.util.UUID; 15 | 16 | //@Component Disabled for now 17 | public class DataGenerator { 18 | 19 | private final OrderRepository orderRepository; 20 | private final CurrencyConverter currencyConverter; 21 | 22 | public DataGenerator(OrderRepository orderRepository, CurrencyConverter currencyConverter) { 23 | this.orderRepository = orderRepository; 24 | this.currencyConverter = currencyConverter; 25 | } 26 | 27 | @PostConstruct 28 | public void generateData() { 29 | System.out.println(orderRepository.findAll()); 30 | 31 | Order order = new Order(Instant.now(), Currency.EUR, 32 | new RecipientAddress("Joe Cool", "Street", null, new CityName("City"), new PostalCode("12345"), Country.FINLAND), 33 | new RecipientAddress("Maxwell Smart", "Road", null, new CityName("Town"), new PostalCode("67890"), Country.SWEDEN)); 34 | order.addItem(new Product(new ProductId(UUID.randomUUID().toString()), "Product 1", new VAT(24), new Money(Currency.EUR, 25.00)), 10, currencyConverter); 35 | order.addItem(new Product(new ProductId(UUID.randomUUID().toString()), "Product 2", new VAT(24), new Money(Currency.EUR, 150.00)), 1, currencyConverter); 36 | orderRepository.save(order); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/OrdersApp.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders; 2 | 3 | import net.pkhapps.ddd.shared.SharedConfiguration; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.boot.autoconfigure.domain.EntityScan; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Import; 9 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 10 | 11 | import java.time.Clock; 12 | 13 | @SpringBootApplication 14 | @EnableJpaRepositories 15 | @EntityScan 16 | @Import(SharedConfiguration.class) 17 | public class OrdersApp { 18 | 19 | public static void main(String[] args) { 20 | SpringApplication.run(OrdersApp.class, args); 21 | } 22 | 23 | @Bean 24 | public Clock clock() { 25 | return Clock.systemUTC(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/application/OrderCatalog.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.application; 2 | 3 | import net.pkhapps.ddd.orders.application.form.OrderForm; 4 | import net.pkhapps.ddd.orders.application.form.RecipientAddressForm; 5 | import net.pkhapps.ddd.orders.domain.model.Order; 6 | import net.pkhapps.ddd.orders.domain.model.OrderId; 7 | import net.pkhapps.ddd.orders.domain.model.OrderRepository; 8 | import net.pkhapps.ddd.orders.domain.model.RecipientAddress; 9 | import net.pkhapps.ddd.orders.domain.model.event.OrderCreated; 10 | import net.pkhapps.ddd.shared.domain.financial.CurrencyConverter; 11 | import org.springframework.context.ApplicationEventPublisher; 12 | import org.springframework.lang.NonNull; 13 | import org.springframework.stereotype.Service; 14 | import org.springframework.transaction.annotation.Propagation; 15 | import org.springframework.transaction.annotation.Transactional; 16 | 17 | import javax.annotation.Nonnull; 18 | import javax.validation.ConstraintViolationException; 19 | import javax.validation.Validator; 20 | import java.time.Clock; 21 | import java.util.List; 22 | import java.util.Objects; 23 | import java.util.Optional; 24 | 25 | @Service 26 | @Transactional(propagation = Propagation.REQUIRES_NEW) 27 | public class OrderCatalog { 28 | 29 | private final Validator validator; 30 | private final OrderRepository orderRepository; 31 | private final ApplicationEventPublisher applicationEventPublisher; 32 | private final Clock clock; 33 | private final CurrencyConverter currencyConverter; 34 | 35 | OrderCatalog(Validator validator, 36 | OrderRepository orderRepository, 37 | ApplicationEventPublisher applicationEventPublisher, 38 | Clock clock, 39 | CurrencyConverter currencyConverter) { 40 | this.validator = validator; 41 | this.orderRepository = orderRepository; 42 | this.applicationEventPublisher = applicationEventPublisher; 43 | this.clock = clock; 44 | this.currencyConverter = currencyConverter; 45 | } 46 | 47 | @NonNull 48 | public OrderId createOrder(@NonNull OrderForm form) { 49 | Objects.requireNonNull(form, "form must not be null"); 50 | var constraintViolations = validator.validate(form); 51 | if (constraintViolations.size() > 0) { 52 | throw new ConstraintViolationException("The OrderForm is not valid", constraintViolations); 53 | } 54 | var order = orderRepository.saveAndFlush(toDomainModel(form)); 55 | applicationEventPublisher.publishEvent(new OrderCreated(order.id(), order.orderedOn())); 56 | return order.id(); 57 | } 58 | 59 | @NonNull 60 | public List findAll() { 61 | return orderRepository.findAll(); 62 | } 63 | 64 | @NonNull 65 | public Optional findById(@NonNull OrderId orderId) { 66 | Objects.requireNonNull(orderId, "orderId must not be null"); 67 | return orderRepository.findById(orderId); 68 | } 69 | 70 | public void startProcessing(@Nonnull OrderId orderId) { 71 | orderRepository.findById(orderId).ifPresent(order -> { 72 | order.startProcessing(clock); 73 | orderRepository.save(order); 74 | }); 75 | } 76 | 77 | public void finishProcessing(@Nonnull OrderId orderId) { 78 | orderRepository.findById(orderId).ifPresent(order -> { 79 | order.finishProcessing(clock); 80 | orderRepository.save(order); 81 | }); 82 | } 83 | 84 | @NonNull 85 | private Order toDomainModel(@NonNull OrderForm orderForm) { 86 | var order = new Order(clock.instant(), orderForm.getCurrency(), 87 | toDomainModel(orderForm.getBillingAddress()), 88 | toDomainModel(orderForm.getShippingAddress())); 89 | orderForm.getItems().forEach(item -> order.addItem(item.getProduct(), item.getQuantity(), currencyConverter)); 90 | return order; 91 | } 92 | 93 | @NonNull 94 | private RecipientAddress toDomainModel(@NonNull RecipientAddressForm form) { 95 | return new RecipientAddress(form.getName(), form.getAddressLine1(), form.getAddressLine2(), form.getCity(), 96 | form.getPostalCode(), form.getCountry()); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/application/ProductCatalog.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.application; 2 | 3 | import net.pkhapps.ddd.orders.domain.model.Product; 4 | 5 | import java.util.List; 6 | 7 | public interface ProductCatalog { 8 | 9 | List findAll(); 10 | } 11 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/application/form/OrderForm.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.application.form; 2 | 3 | import net.pkhapps.ddd.shared.domain.financial.Currency; 4 | 5 | import javax.validation.Valid; 6 | import javax.validation.constraints.NotEmpty; 7 | import javax.validation.constraints.NotNull; 8 | import java.io.Serializable; 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | 12 | public class OrderForm implements Serializable { 13 | 14 | @NotNull 15 | private Currency currency; 16 | @Valid 17 | @NotNull 18 | private RecipientAddressForm billingAddress = new RecipientAddressForm(); 19 | @Valid 20 | @NotNull 21 | private RecipientAddressForm shippingAddress = new RecipientAddressForm(); 22 | @Valid 23 | @NotEmpty 24 | private List items = new ArrayList<>(); 25 | 26 | public Currency getCurrency() { 27 | return currency; 28 | } 29 | 30 | public void setCurrency(Currency currency) { 31 | this.currency = currency; 32 | } 33 | 34 | public RecipientAddressForm getBillingAddress() { 35 | return billingAddress; 36 | } 37 | 38 | public RecipientAddressForm getShippingAddress() { 39 | return shippingAddress; 40 | } 41 | 42 | public List getItems() { 43 | return items; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/application/form/OrderItemForm.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.application.form; 2 | 3 | import net.pkhapps.ddd.orders.domain.model.Product; 4 | 5 | import javax.validation.constraints.Min; 6 | import javax.validation.constraints.NotNull; 7 | import java.io.Serializable; 8 | 9 | public class OrderItemForm implements Serializable { 10 | 11 | @NotNull 12 | private Product product; 13 | @Min(1) 14 | private int quantity = 1; 15 | 16 | public Product getProduct() { 17 | return product; 18 | } 19 | 20 | public void setProduct(Product product) { 21 | this.product = product; 22 | } 23 | 24 | public int getQuantity() { 25 | return quantity; 26 | } 27 | 28 | public void setQuantity(int quantity) { 29 | this.quantity = quantity; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/application/form/RecipientAddressForm.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.application.form; 2 | 3 | import net.pkhapps.ddd.shared.domain.geo.CityName; 4 | import net.pkhapps.ddd.shared.domain.geo.Country; 5 | import net.pkhapps.ddd.shared.domain.geo.PostalCode; 6 | 7 | import javax.validation.constraints.NotEmpty; 8 | import javax.validation.constraints.NotNull; 9 | import java.io.Serializable; 10 | 11 | public class RecipientAddressForm implements Serializable { 12 | 13 | @NotEmpty 14 | private String name; 15 | @NotEmpty 16 | private String addressLine1; 17 | private String addressLine2; 18 | @NotNull 19 | private CityName city; 20 | @NotNull 21 | private PostalCode postalCode; 22 | @NotNull 23 | private Country country; 24 | 25 | public String getName() { 26 | return name; 27 | } 28 | 29 | public void setName(String name) { 30 | this.name = name; 31 | } 32 | 33 | public String getAddressLine1() { 34 | return addressLine1; 35 | } 36 | 37 | public void setAddressLine1(String addressLine1) { 38 | this.addressLine1 = addressLine1; 39 | } 40 | 41 | public String getAddressLine2() { 42 | return addressLine2; 43 | } 44 | 45 | public void setAddressLine2(String addressLine2) { 46 | this.addressLine2 = addressLine2; 47 | } 48 | 49 | public CityName getCity() { 50 | return city; 51 | } 52 | 53 | public void setCity(CityName city) { 54 | this.city = city; 55 | } 56 | 57 | public PostalCode getPostalCode() { 58 | return postalCode; 59 | } 60 | 61 | public void setPostalCode(PostalCode postalCode) { 62 | this.postalCode = postalCode; 63 | } 64 | 65 | public Country getCountry() { 66 | return country; 67 | } 68 | 69 | public void setCountry(Country country) { 70 | this.country = country; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/OrderId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 4 | 5 | public class OrderId extends DomainObjectId { 6 | public OrderId(String uuid) { 7 | super(uuid); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/OrderItem.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.base.AbstractEntity; 5 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 6 | import net.pkhapps.ddd.shared.domain.financial.Currency; 7 | import net.pkhapps.ddd.shared.domain.financial.Money; 8 | import net.pkhapps.ddd.shared.domain.financial.VAT; 9 | import org.springframework.lang.NonNull; 10 | 11 | import javax.persistence.*; 12 | import java.util.Objects; 13 | 14 | @Entity 15 | @Table(name = "order_items", uniqueConstraints = @UniqueConstraint(columnNames = {"product_id", "order_id"})) 16 | public class OrderItem extends AbstractEntity { 17 | 18 | @Column(name = "product_id", nullable = false) 19 | private ProductId productId; 20 | @Column(name = "item_description", nullable = false) 21 | private String itemDescription; 22 | @Column(name = "item_currency", nullable = false) 23 | @Enumerated(EnumType.STRING) 24 | private Currency itemPriceCurrency; 25 | @Column(name = "item_price", nullable = false) 26 | private int itemPrice; 27 | @Column(name = "value_added_tax", nullable = false) 28 | private VAT valueAddedTax; 29 | @Column(name = "qty", nullable = false) 30 | private int quantity; 31 | 32 | @SuppressWarnings("unused") // Used by JPA only 33 | private OrderItem() { 34 | } 35 | 36 | OrderItem(@NonNull ProductId productId, @NonNull String itemDescription, @NonNull Money itemPrice, 37 | @NonNull VAT valueAddedTax) { 38 | super(DomainObjectId.randomId(OrderItemId.class)); 39 | setProductId(productId); 40 | setItemDescription(itemDescription); 41 | setItemPrice(itemPrice); 42 | setValueAddedTax(valueAddedTax); 43 | } 44 | 45 | @NonNull 46 | @JsonProperty("productId") 47 | public ProductId productId() { 48 | return productId; 49 | } 50 | 51 | private void setProductId(@NonNull ProductId productId) { 52 | this.productId = Objects.requireNonNull(productId, "productId must not be null"); 53 | } 54 | 55 | @NonNull 56 | @JsonProperty("description") 57 | public String itemDescription() { 58 | return itemDescription; 59 | } 60 | 61 | private void setItemDescription(@NonNull String itemDescription) { 62 | this.itemDescription = Objects.requireNonNull(itemDescription, "itemDescription must not be null"); 63 | } 64 | 65 | @NonNull 66 | @JsonProperty("price") 67 | public Money itemPrice() { 68 | return Money.valueOf(itemPriceCurrency, itemPrice); 69 | } 70 | 71 | private void setItemPrice(@NonNull Money itemPrice) { 72 | Objects.requireNonNull(itemPrice, "itemPrice must not be null"); 73 | this.itemPrice = itemPrice.fixedPointAmount(); 74 | this.itemPriceCurrency = itemPrice.currency(); 75 | } 76 | 77 | @NonNull 78 | @JsonProperty("valueAddedTax") 79 | public VAT valueAddedTax() { 80 | return valueAddedTax; 81 | } 82 | 83 | private void setValueAddedTax(@NonNull VAT valueAddedTax) { 84 | this.valueAddedTax = Objects.requireNonNull(valueAddedTax, "valueAddedTax must not be null"); 85 | } 86 | 87 | @NonNull 88 | @JsonProperty("qty") 89 | public int quantity() { 90 | return quantity; 91 | } 92 | 93 | public void setQuantity(int quantity) { 94 | if (quantity < 0) { 95 | throw new IllegalArgumentException("Quantity cannot be negative"); 96 | } 97 | this.quantity = quantity; 98 | } 99 | 100 | @NonNull 101 | @JsonProperty("subtotalExcludingVAT") 102 | public Money subtotalExcludingTax() { 103 | return itemPrice().multiply(quantity()); 104 | } 105 | 106 | @NonNull 107 | @JsonProperty("subtotalVAT") 108 | public Money subtotalTax() { 109 | return valueAddedTax().calculateTax(subtotalExcludingTax()); 110 | } 111 | 112 | @NonNull 113 | @JsonProperty("subtotalIncludingVAT") 114 | public Money subtotalIncludingTax() { 115 | return valueAddedTax().addTax(subtotalExcludingTax()); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/OrderItemId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 4 | 5 | public class OrderItemId extends DomainObjectId { 6 | public OrderItemId(String uuid) { 7 | super(uuid); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/OrderRepository.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | public interface OrderRepository extends JpaRepository { 6 | } 7 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/OrderState.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model; 2 | 3 | public enum OrderState { 4 | RECEIVED, PROCESSING, CANCELLED, PROCESSED 5 | } 6 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/OrderStateChange.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.base.ValueObject; 5 | import org.springframework.lang.NonNull; 6 | 7 | import javax.persistence.Column; 8 | import javax.persistence.Embeddable; 9 | import javax.persistence.EnumType; 10 | import javax.persistence.Enumerated; 11 | import java.time.Instant; 12 | import java.util.Objects; 13 | 14 | @Embeddable 15 | public class OrderStateChange implements ValueObject { 16 | 17 | @Column(name = "changed_on", nullable = false) 18 | private Instant changedOn; 19 | @Column(name = "state", nullable = false) 20 | @Enumerated(EnumType.STRING) 21 | private OrderState state; 22 | 23 | @SuppressWarnings("unused") // Used by JPA only 24 | private OrderStateChange() { 25 | } 26 | 27 | OrderStateChange(@NonNull Instant changedOn, @NonNull OrderState state) { 28 | this.changedOn = Objects.requireNonNull(changedOn, "changedOn must not be null"); 29 | this.state = Objects.requireNonNull(state, "state must not be null"); 30 | } 31 | 32 | @NonNull 33 | @JsonProperty("changedOn") 34 | public Instant changedOn() { 35 | return changedOn; 36 | } 37 | 38 | @NonNull 39 | @JsonProperty("state") 40 | public OrderState state() { 41 | return state; 42 | } 43 | 44 | @Override 45 | public boolean equals(Object o) { 46 | if (this == o) return true; 47 | if (o == null || getClass() != o.getClass()) return false; 48 | OrderStateChange that = (OrderStateChange) o; 49 | return Objects.equals(changedOn, that.changedOn) && 50 | state == that.state; 51 | } 52 | 53 | @Override 54 | public int hashCode() { 55 | return Objects.hash(changedOn, state); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/Product.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import net.pkhapps.ddd.shared.domain.base.ValueObject; 6 | import net.pkhapps.ddd.shared.domain.financial.Money; 7 | import net.pkhapps.ddd.shared.domain.financial.VAT; 8 | import org.springframework.lang.NonNull; 9 | 10 | import java.util.Objects; 11 | 12 | public class Product implements ValueObject { 13 | 14 | private final ProductId id; 15 | private final String name; 16 | private final VAT valueAddedTax; 17 | private final Money price; 18 | 19 | @JsonCreator 20 | public Product(@JsonProperty("id") @NonNull ProductId id, 21 | @JsonProperty("name") @NonNull String name, 22 | @JsonProperty("valueAddedTax") @NonNull VAT valueAddedTax, 23 | @JsonProperty("priceExcludingVAT") @NonNull Money price) { 24 | this.id = Objects.requireNonNull(id, "id must not be null"); 25 | this.name = Objects.requireNonNull(name, "name must not be null"); 26 | this.valueAddedTax = Objects.requireNonNull(valueAddedTax, "valueAddedTax must not be null"); 27 | this.price = Objects.requireNonNull(price, "price must not be null"); 28 | } 29 | 30 | @NonNull 31 | public ProductId id() { 32 | return id; 33 | } 34 | 35 | @NonNull 36 | public String name() { 37 | return name; 38 | } 39 | 40 | @NonNull 41 | public VAT valueAddedTax() { 42 | return valueAddedTax; 43 | } 44 | 45 | @NonNull 46 | public Money price() { 47 | return price; 48 | } 49 | 50 | @Override 51 | public boolean equals(Object o) { 52 | if (this == o) return true; 53 | if (o == null || getClass() != o.getClass()) return false; 54 | Product product = (Product) o; 55 | return Objects.equals(id, product.id) && 56 | Objects.equals(name, product.name) && 57 | Objects.equals(valueAddedTax, product.valueAddedTax) && 58 | Objects.equals(price, product.price); 59 | } 60 | 61 | @Override 62 | public int hashCode() { 63 | return Objects.hash(id, name, valueAddedTax, price); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/ProductId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 5 | 6 | public class ProductId extends DomainObjectId { 7 | @JsonCreator 8 | public ProductId(String uuid) { 9 | super(uuid); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/RecipientAddress.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.geo.Address; 5 | import net.pkhapps.ddd.shared.domain.geo.CityName; 6 | import net.pkhapps.ddd.shared.domain.geo.Country; 7 | import net.pkhapps.ddd.shared.domain.geo.PostalCode; 8 | import org.springframework.lang.NonNull; 9 | import org.springframework.lang.Nullable; 10 | 11 | import javax.persistence.Column; 12 | import javax.persistence.Embeddable; 13 | import java.util.Objects; 14 | 15 | @Embeddable 16 | public class RecipientAddress extends Address { 17 | 18 | @Column(name = "recipient_name") 19 | private String name; 20 | 21 | @SuppressWarnings("unused") // Used by JPA only. 22 | protected RecipientAddress() { 23 | } 24 | 25 | public RecipientAddress(@NonNull String name, @NonNull String addressLine1, @Nullable String addressLine2, 26 | @NonNull CityName city, @NonNull PostalCode postalCode, @NonNull Country country) { 27 | super(addressLine1, addressLine2, city, postalCode, country); 28 | this.name = Objects.requireNonNull(name, "name must not be null"); 29 | } 30 | 31 | @NonNull 32 | @JsonProperty("name") 33 | public String name() { 34 | return name; 35 | } 36 | 37 | @Override 38 | public boolean equals(Object o) { 39 | if (this == o) return true; 40 | if (o == null || getClass() != o.getClass()) return false; 41 | if (!super.equals(o)) return false; 42 | RecipientAddress that = (RecipientAddress) o; 43 | return Objects.equals(name, that.name); 44 | } 45 | 46 | @Override 47 | public int hashCode() { 48 | return Objects.hash(super.hashCode(), name); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/converter/ProductIdConverter.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model.converter; 2 | 3 | import net.pkhapps.ddd.orders.domain.model.ProductId; 4 | 5 | import javax.persistence.AttributeConverter; 6 | import javax.persistence.Converter; 7 | 8 | @Converter(autoApply = true) 9 | public class ProductIdConverter implements AttributeConverter { 10 | @Override 11 | public String convertToDatabaseColumn(ProductId attribute) { 12 | return attribute == null ? null : attribute.toUUID(); 13 | } 14 | 15 | @Override 16 | public ProductId convertToEntityAttribute(String dbData) { 17 | return dbData == null ? null : new ProductId(dbData); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/event/OrderCreated.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model.event; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import net.pkhapps.ddd.orders.domain.model.OrderId; 6 | import net.pkhapps.ddd.shared.domain.base.DomainEvent; 7 | import org.springframework.lang.NonNull; 8 | 9 | import java.time.Instant; 10 | import java.util.Objects; 11 | 12 | public class OrderCreated implements DomainEvent { 13 | 14 | @JsonProperty("orderId") 15 | private final OrderId orderId; 16 | @JsonProperty("occurredOn") 17 | private final Instant occurredOn; 18 | 19 | @JsonCreator 20 | public OrderCreated(@JsonProperty("orderId") @NonNull OrderId orderId, 21 | @JsonProperty("occurredOn") @NonNull Instant occurredOn) { 22 | this.orderId = Objects.requireNonNull(orderId, "orderId must not be null"); 23 | this.occurredOn = Objects.requireNonNull(occurredOn, "occurredOn must not be null"); 24 | } 25 | 26 | @NonNull 27 | public OrderId orderId() { 28 | return orderId; 29 | } 30 | 31 | @Override 32 | @NonNull 33 | public Instant occurredOn() { 34 | return occurredOn; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/domain/model/event/OrderStateChanged.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.domain.model.event; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import net.pkhapps.ddd.orders.domain.model.OrderId; 6 | import net.pkhapps.ddd.orders.domain.model.OrderState; 7 | import net.pkhapps.ddd.shared.domain.base.DomainEvent; 8 | import org.springframework.lang.NonNull; 9 | 10 | import java.time.Instant; 11 | import java.util.Objects; 12 | 13 | public class OrderStateChanged implements DomainEvent { 14 | 15 | @JsonProperty("orderId") 16 | private final OrderId orderId; 17 | @JsonProperty("state") 18 | private final OrderState state; 19 | @JsonProperty("occurredOn") 20 | private final Instant occurredOn; 21 | 22 | @JsonCreator 23 | public OrderStateChanged(@JsonProperty("orderId") @NonNull OrderId orderId, 24 | @JsonProperty("state") @NonNull OrderState state, 25 | @JsonProperty("occurredOn") @NonNull Instant occurredOn) { 26 | this.orderId = Objects.requireNonNull(orderId, "orderId must not be null"); 27 | this.state = Objects.requireNonNull(state, "state must not be null"); 28 | this.occurredOn = Objects.requireNonNull(occurredOn, "occurredOn must not be null"); 29 | } 30 | 31 | @NonNull 32 | public OrderId orderId() { 33 | return orderId; 34 | } 35 | 36 | @NonNull 37 | public OrderState state() { 38 | return state; 39 | } 40 | 41 | @Override 42 | @NonNull 43 | public Instant occurredOn() { 44 | return occurredOn; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/infra/hibernate/OrderIdType.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.infra.hibernate; 2 | 3 | import net.pkhapps.ddd.orders.domain.model.OrderId; 4 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdCustomType; 5 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdTypeDescriptor; 6 | 7 | public class OrderIdType extends DomainObjectIdCustomType { 8 | private static final DomainObjectIdTypeDescriptor TYPE_DESCRIPTOR = new DomainObjectIdTypeDescriptor<>( 9 | OrderId.class, OrderId::new); 10 | 11 | public OrderIdType() { 12 | super(TYPE_DESCRIPTOR); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/infra/hibernate/OrderItemIdType.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.infra.hibernate; 2 | 3 | import net.pkhapps.ddd.orders.domain.model.OrderItemId; 4 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdCustomType; 5 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdTypeDescriptor; 6 | 7 | public class OrderItemIdType extends DomainObjectIdCustomType { 8 | private static final DomainObjectIdTypeDescriptor TYPE_DESCRIPTOR = new DomainObjectIdTypeDescriptor<>( 9 | OrderItemId.class, OrderItemId::new); 10 | 11 | public OrderItemIdType() { 12 | super(TYPE_DESCRIPTOR); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/infra/hibernate/package-info.java: -------------------------------------------------------------------------------- 1 | @TypeDefs({ 2 | @TypeDef(defaultForType = OrderId.class, typeClass = OrderIdType.class), 3 | @TypeDef(defaultForType = OrderItemId.class, typeClass = OrderItemIdType.class) 4 | }) 5 | package net.pkhapps.ddd.orders.infra.hibernate; 6 | 7 | import net.pkhapps.ddd.orders.domain.model.OrderId; 8 | import net.pkhapps.ddd.orders.domain.model.OrderItemId; 9 | import org.hibernate.annotations.TypeDef; 10 | import org.hibernate.annotations.TypeDefs; -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/rest/client/ProductCatalogClient.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.rest.client; 2 | 3 | import net.pkhapps.ddd.orders.application.ProductCatalog; 4 | import net.pkhapps.ddd.orders.domain.model.Product; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.beans.factory.annotation.Value; 8 | import org.springframework.core.ParameterizedTypeReference; 9 | import org.springframework.http.HttpMethod; 10 | import org.springframework.http.client.SimpleClientHttpRequestFactory; 11 | import org.springframework.stereotype.Service; 12 | import org.springframework.web.client.RestTemplate; 13 | import org.springframework.web.util.UriComponentsBuilder; 14 | 15 | import java.util.Collections; 16 | import java.util.List; 17 | 18 | @Service 19 | class ProductCatalogClient implements ProductCatalog { 20 | 21 | private static final Logger LOGGER = LoggerFactory.getLogger(ProductCatalogClient.class); 22 | 23 | private final RestTemplate restTemplate; 24 | private final String serverUrl; 25 | 26 | ProductCatalogClient(@Value("${app.product-catalog.url}") String serverUrl, 27 | @Value("${app.product-catalog.connect-timeout-ms}") int connectTimeout, 28 | @Value("${app.product-catalog.read-timeout-ms}") int readTimeout) { 29 | this.serverUrl = serverUrl; 30 | restTemplate = new RestTemplate(); 31 | var requestFactory = new SimpleClientHttpRequestFactory(); 32 | // Never ever do a remote call without a finite timeout! 33 | requestFactory.setConnectTimeout(connectTimeout); 34 | requestFactory.setReadTimeout(readTimeout); 35 | restTemplate.setRequestFactory(requestFactory); 36 | } 37 | 38 | private UriComponentsBuilder uri() { 39 | return UriComponentsBuilder.fromUriString(serverUrl); 40 | } 41 | 42 | @Override 43 | public List findAll() { 44 | try { 45 | return restTemplate.exchange(uri().path("/api/products").build().toUri(), HttpMethod.GET, null, 46 | new ParameterizedTypeReference>() { 47 | }).getBody(); 48 | } catch (Exception ex) { 49 | LOGGER.error("Error retrieving products", ex); 50 | return Collections.emptyList(); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/rest/controller/OrderCatalogController.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.rest.controller; 2 | 3 | import net.pkhapps.ddd.orders.application.OrderCatalog; 4 | import net.pkhapps.ddd.orders.domain.model.Order; 5 | import net.pkhapps.ddd.orders.domain.model.OrderId; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.web.bind.annotation.*; 8 | 9 | import java.util.List; 10 | 11 | @RestController 12 | @RequestMapping("/api/orders") 13 | class OrderCatalogController { 14 | 15 | private final OrderCatalog orderCatalog; 16 | 17 | OrderCatalogController(OrderCatalog orderCatalog) { 18 | this.orderCatalog = orderCatalog; 19 | } 20 | 21 | @GetMapping("/{id}") 22 | public ResponseEntity findById(@PathVariable("id") String orderId) { 23 | return orderCatalog.findById(new OrderId(orderId)) 24 | .map(ResponseEntity::ok) 25 | .orElse(ResponseEntity.notFound().build()); 26 | } 27 | 28 | @PutMapping("/{id}/startProcessing") 29 | public void startProcessing(@PathVariable("id") String orderId) { 30 | orderCatalog.startProcessing(new OrderId(orderId)); 31 | } 32 | 33 | @PutMapping("/{id}/finishProcessing") 34 | public void finishProcessing(@PathVariable("id") String orderId) { 35 | orderCatalog.finishProcessing(new OrderId(orderId)); 36 | } 37 | 38 | @GetMapping 39 | public List findAll() { 40 | return orderCatalog.findAll(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/ui/OrderBrowserView.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.ui; 2 | 3 | import com.vaadin.flow.component.Html; 4 | import com.vaadin.flow.component.button.Button; 5 | import com.vaadin.flow.component.grid.Grid; 6 | import com.vaadin.flow.component.orderedlayout.FlexLayout; 7 | import com.vaadin.flow.component.orderedlayout.VerticalLayout; 8 | import com.vaadin.flow.data.renderer.ComponentRenderer; 9 | import com.vaadin.flow.router.PageTitle; 10 | import com.vaadin.flow.router.Route; 11 | import net.pkhapps.ddd.orders.application.OrderCatalog; 12 | import net.pkhapps.ddd.orders.domain.model.Order; 13 | import net.pkhapps.ddd.orders.domain.model.OrderId; 14 | 15 | @Route("") 16 | @PageTitle("Order Browser") 17 | public class OrderBrowserView extends VerticalLayout { 18 | 19 | private final OrderCatalog orderCatalog; 20 | private final Grid ordersGrid; 21 | 22 | public OrderBrowserView(OrderCatalog orderCatalog) { 23 | this.orderCatalog = orderCatalog; 24 | 25 | setSizeFull(); 26 | 27 | var title = new Html("

Order Browser

"); 28 | add(title); 29 | 30 | ordersGrid = new Grid<>(); 31 | ordersGrid.addColumn(Order::orderedOn).setHeader("Date and Time"); 32 | ordersGrid.addColumn(Order::state).setHeader("State"); 33 | ordersGrid.addColumn(Order::currency).setHeader("Currency"); 34 | ordersGrid.addColumn(Order::totalExcludingTax).setHeader("Total (excl.VAT)"); 35 | ordersGrid.addColumn(Order::totalTax).setHeader("VAT"); 36 | ordersGrid.addColumn(Order::totalIncludingTax).setHeader("Total (incl.VAT)"); 37 | ordersGrid.addColumn(new ComponentRenderer<>(order -> new Button("Details", evt -> showOrder(order.id())))); 38 | add(ordersGrid); 39 | 40 | var createOrder = new Button("Create Order", et -> createOrder()); 41 | createOrder.getElement().getThemeList().set("primary", true); 42 | var refresh = new Button("Refresh", evt -> refreshOrders()); 43 | 44 | var buttons = new FlexLayout(refresh, createOrder); 45 | buttons.setJustifyContentMode(JustifyContentMode.BETWEEN); 46 | buttons.setWidth("100%"); 47 | add(buttons); 48 | 49 | refreshOrders(); 50 | } 51 | 52 | private void refreshOrders() { 53 | ordersGrid.setItems(orderCatalog.findAll()); 54 | } 55 | 56 | private void createOrder() { 57 | getUI().ifPresent(ui -> ui.navigate(CreateOrderView.class)); 58 | } 59 | 60 | private void showOrder(OrderId orderId) { 61 | getUI().ifPresent(ui -> ui.navigate(OrderDetailsView.class, orderId.toUUID())); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/ui/OrderDetailsView.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.ui; 2 | 3 | import com.vaadin.flow.component.Html; 4 | import com.vaadin.flow.component.Text; 5 | import com.vaadin.flow.component.formlayout.FormLayout; 6 | import com.vaadin.flow.component.grid.Grid; 7 | import com.vaadin.flow.component.orderedlayout.VerticalLayout; 8 | import com.vaadin.flow.component.textfield.TextArea; 9 | import com.vaadin.flow.component.textfield.TextField; 10 | import com.vaadin.flow.router.*; 11 | import net.pkhapps.ddd.orders.application.OrderCatalog; 12 | import net.pkhapps.ddd.orders.domain.model.Order; 13 | import net.pkhapps.ddd.orders.domain.model.OrderId; 14 | import net.pkhapps.ddd.orders.domain.model.OrderItem; 15 | import net.pkhapps.ddd.orders.domain.model.RecipientAddress; 16 | 17 | import java.util.Optional; 18 | 19 | @Route("order") 20 | @PageTitle("Show Order") 21 | public class OrderDetailsView extends VerticalLayout implements HasUrlParameter { 22 | 23 | private final OrderCatalog orderCatalog; 24 | 25 | public OrderDetailsView(OrderCatalog orderCatalog) { 26 | this.orderCatalog = orderCatalog; 27 | setSizeFull(); 28 | } 29 | 30 | @Override 31 | public void setParameter(BeforeEvent event, @OptionalParameter String parameter) { 32 | Optional order = Optional.ofNullable(parameter).map(OrderId::new).flatMap(orderCatalog::findById); 33 | if (order.isPresent()) { 34 | showOrder(order.get()); 35 | } else { 36 | showNoSuchOrder(); 37 | } 38 | } 39 | 40 | private void showOrder(Order order) { 41 | var title = new Html("

Order Details

"); 42 | add(title); 43 | 44 | var header = new FormLayout(); 45 | header.addFormItem(createReadOnlyTextField(order.orderedOn().toString()), "Ordered on"); 46 | header.addFormItem(createReadOnlyTextField(order.state().name()), "State"); 47 | header.addFormItem(createReadOnlyAddressArea(order.billingAddress()), "Billing Address"); 48 | header.addFormItem(createReadOnlyAddressArea(order.shippingAddress()), "Shipping Address"); 49 | add(header); 50 | 51 | var items = new Grid(); 52 | items.addColumn(OrderItem::itemDescription).setHeader("Description"); 53 | items.addColumn(OrderItem::quantity).setHeader("Qty"); 54 | items.addColumn(OrderItem::itemPrice).setHeader("Price"); 55 | items.addColumn(OrderItem::valueAddedTax).setHeader("VAT"); 56 | var subtotalExcludingTax = items.addColumn(OrderItem::subtotalExcludingTax).setHeader("Subtotal excl.VAT"); 57 | var subtotalTax = items.addColumn(OrderItem::subtotalTax).setHeader("Subtotal VAT"); 58 | var subtotalIncludingTax = items.addColumn(OrderItem::subtotalIncludingTax).setHeader("Subtotal incl.VAT"); 59 | items.setItems(order.items()); 60 | var footerRow = items.appendFooterRow(); 61 | footerRow.getCell(subtotalExcludingTax).setText(order.totalExcludingTax().toString()); 62 | footerRow.getCell(subtotalTax).setText(order.totalTax().toString()); 63 | footerRow.getCell(subtotalIncludingTax).setText(order.totalIncludingTax().toString()); 64 | add(items); 65 | } 66 | 67 | private TextField createReadOnlyTextField(String value) { 68 | var textField = new TextField(); 69 | textField.setReadOnly(true); 70 | textField.setValue(value); 71 | return textField; 72 | } 73 | 74 | private TextArea createReadOnlyAddressArea(RecipientAddress address) { 75 | var textArea = new TextArea(); 76 | textArea.setHeight("140px"); 77 | textArea.setValue(formatAddress(address)); 78 | textArea.setReadOnly(true); 79 | return textArea; 80 | } 81 | 82 | private String formatAddress(RecipientAddress address) { 83 | var sb = new StringBuilder(); 84 | sb.append(address.name()).append("\n"); 85 | sb.append(address.addressLine1()).append("\n"); 86 | sb.append(Optional.ofNullable(address.addressLine2()).orElse("")).append("\n"); 87 | sb.append(address.postalCode()).append(" ").append(address.city()).append("\n"); 88 | sb.append(address.country()); 89 | return sb.toString(); 90 | } 91 | 92 | private void showNoSuchOrder() { 93 | add(new Text("The order does not exist.")); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /orders/src/main/java/net/pkhapps/ddd/orders/ui/TabContainer.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.orders.ui; 2 | 3 | import com.vaadin.flow.component.Component; 4 | import com.vaadin.flow.component.html.Div; 5 | import com.vaadin.flow.component.tabs.Tab; 6 | import com.vaadin.flow.component.tabs.Tabs; 7 | 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | 11 | public class TabContainer extends Div { 12 | 13 | private Map contentMap = new HashMap<>(); 14 | private Tabs tabs; 15 | 16 | public TabContainer(Tabs tabs) { 17 | this.tabs = tabs; 18 | tabs.addSelectedChangeListener(event -> showTabContent(tabs.getSelectedTab())); 19 | } 20 | 21 | public void addTab(Tab tab, Component content) { 22 | contentMap.put(tab, content); 23 | } 24 | 25 | public void addTab(String label, Component content) { 26 | var tab = new Tab(label); 27 | tabs.add(tab); 28 | addTab(tab, content); 29 | if (tabs.getSelectedTab() == tab) { 30 | showTabContent(tab); 31 | } 32 | } 33 | 34 | private void showTabContent(Tab tab) { 35 | removeAll(); 36 | var content = contentMap.get(tab); 37 | if (content != null) { 38 | add(content); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /orders/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.h2.console.enabled=true 2 | spring.h2.console.path=/h2-console 3 | spring.datasource.url=jdbc:h2:mem:orders;DB_CLOSE_DELAY=-1. 4 | server.port=9002 5 | 6 | app.product-catalog.url=http://localhost:9001 7 | app.product-catalog.connect-timeout-ms=1000 8 | app.product-catalog.read-timeout-ms=5000 9 | app.currency-conversion.enabled=true 10 | logging.level.net.pkhapps.ddd=debug 11 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | net.pkhapps.ddd 8 | master-pom 9 | 1.0-SNAPSHOT 10 | pom 11 | 12 | 13 | product-catalog 14 | shipping 15 | invoicing 16 | shared-kernel 17 | orders 18 | 19 | 20 | 21 | 22 | org.springframework.boot 23 | spring-boot-starter-parent 24 | 2.1.0.RELEASE 25 | 26 | 27 | 28 | 11 29 | 11.0.2 30 | 11.0.2 31 | 32 | 33 | 34 | 35 | 36 | com.vaadin 37 | vaadin-bom 38 | ${vaadin.version} 39 | import 40 | pom 41 | 42 | 43 | com.vaadin 44 | vaadin-spring-boot-starter 45 | ${vaadin-spring.version} 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /product-catalog/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | master-pom 7 | net.pkhapps.ddd 8 | 1.0-SNAPSHOT 9 | 10 | 4.0.0 11 | 12 | product-catalog 13 | 14 | 15 | 16 | net.pkhapps.ddd 17 | shared-kernel 18 | ${project.version} 19 | 20 | 21 | org.springframework.boot 22 | spring-boot-starter-web 23 | 24 | 25 | com.h2database 26 | h2 27 | 28 | 29 | 30 | 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-maven-plugin 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /product-catalog/src/main/java/net/pkhapps/ddd/productcatalog/DataGenerator.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.productcatalog; 2 | 3 | import net.pkhapps.ddd.productcatalog.domain.model.Product; 4 | import net.pkhapps.ddd.productcatalog.domain.model.ProductRepository; 5 | import net.pkhapps.ddd.shared.domain.financial.Currency; 6 | import net.pkhapps.ddd.shared.domain.financial.Money; 7 | import net.pkhapps.ddd.shared.domain.financial.VAT; 8 | import org.springframework.stereotype.Component; 9 | import org.springframework.transaction.annotation.Transactional; 10 | 11 | import javax.annotation.PostConstruct; 12 | import java.util.ArrayList; 13 | 14 | @Component 15 | class DataGenerator { 16 | 17 | private final ProductRepository productRepository; 18 | 19 | DataGenerator(ProductRepository productRepository) { 20 | this.productRepository = productRepository; 21 | } 22 | 23 | @PostConstruct 24 | @Transactional 25 | public void generateData() { 26 | var products = new ArrayList(); 27 | products.add(createProduct("Flashlight L", "A large flashlight", new VAT(24), new Money(Currency.EUR, 5642))); 28 | products.add(createProduct("Flashlight M", "A medium flashlight", new VAT(24), new Money(Currency.EUR, 4029))); 29 | products.add(createProduct("Flashlight S", "A small flashlight", new VAT(24), new Money(Currency.EUR, 2416))); 30 | productRepository.saveAll(products); 31 | } 32 | 33 | private Product createProduct(String name, String description, VAT vat, Money price) { 34 | var product = new Product(name, price, vat); 35 | product.setDescription(description); 36 | return product; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /product-catalog/src/main/java/net/pkhapps/ddd/productcatalog/ProductCatalogApp.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.productcatalog; 2 | 3 | import net.pkhapps.ddd.shared.SharedConfiguration; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.boot.autoconfigure.domain.EntityScan; 7 | import org.springframework.context.annotation.Import; 8 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 9 | 10 | @SpringBootApplication 11 | @EnableJpaRepositories 12 | @EntityScan 13 | @Import(SharedConfiguration.class) 14 | public class ProductCatalogApp { 15 | 16 | public static void main(String[] args) { 17 | SpringApplication.run(ProductCatalogApp.class, args); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /product-catalog/src/main/java/net/pkhapps/ddd/productcatalog/application/ProductCatalog.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.productcatalog.application; 2 | 3 | import net.pkhapps.ddd.productcatalog.domain.model.Product; 4 | import net.pkhapps.ddd.productcatalog.domain.model.ProductId; 5 | import net.pkhapps.ddd.productcatalog.domain.model.ProductRepository; 6 | import org.springframework.lang.NonNull; 7 | import org.springframework.stereotype.Service; 8 | import org.springframework.transaction.annotation.Propagation; 9 | import org.springframework.transaction.annotation.Transactional; 10 | 11 | import java.util.List; 12 | import java.util.Objects; 13 | import java.util.Optional; 14 | 15 | /** 16 | * Application service for browsing the product catalog. 17 | */ 18 | @Service 19 | @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) 20 | public class ProductCatalog { 21 | 22 | private final ProductRepository productRepository; 23 | 24 | ProductCatalog(ProductRepository productRepository) { 25 | this.productRepository = productRepository; 26 | } 27 | 28 | @NonNull 29 | public Optional findById(@NonNull ProductId productId) { 30 | Objects.requireNonNull(productId, "productId must not be null"); 31 | return productRepository.findById(productId); 32 | } 33 | 34 | // Please note: in a real-world application you would use pagination for all results that don't have an upper-bound. 35 | // However, to save time, we're just returning everything in a single list in this example. 36 | 37 | @NonNull 38 | public List findAll() { 39 | return productRepository.findActive(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /product-catalog/src/main/java/net/pkhapps/ddd/productcatalog/domain/model/Product.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.productcatalog.domain.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.base.AbstractAggregateRoot; 5 | import net.pkhapps.ddd.shared.domain.base.ConcurrencySafeDomainObject; 6 | import net.pkhapps.ddd.shared.domain.base.DeletableDomainObject; 7 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 8 | import net.pkhapps.ddd.shared.domain.financial.Currency; 9 | import net.pkhapps.ddd.shared.domain.financial.Money; 10 | import net.pkhapps.ddd.shared.domain.financial.VAT; 11 | import org.springframework.lang.NonNull; 12 | import org.springframework.lang.Nullable; 13 | 14 | import javax.persistence.*; 15 | import java.util.Objects; 16 | 17 | /** 18 | * Aggregate root representing a product in the product catalog. 19 | */ 20 | @Entity 21 | @Table(name = "products") 22 | public class Product extends AbstractAggregateRoot implements DeletableDomainObject, 23 | ConcurrencySafeDomainObject { 24 | 25 | @Version 26 | private Long version; 27 | 28 | @Column(name = "name", nullable = false) 29 | private String name; 30 | 31 | @Column(name = "description") 32 | private String description; 33 | 34 | @Column(name = "price_currency", nullable = false) 35 | @Enumerated(EnumType.STRING) 36 | private Currency priceCurrency; 37 | 38 | @Column(name = "price_value", nullable = false) 39 | private Integer priceValue; 40 | 41 | @Column(name = "vat", nullable = false) 42 | private VAT vat; 43 | 44 | @Column(name = "deleted", nullable = false) 45 | private boolean deleted = false; 46 | 47 | @SuppressWarnings("unused") // Used by JPA only 48 | private Product() { 49 | } 50 | 51 | public Product(@NonNull String name, @NonNull Money price, @NonNull VAT vat) { 52 | super(DomainObjectId.randomId(ProductId.class)); 53 | setName(name); 54 | setPrice(price); 55 | setVAT(vat); 56 | } 57 | 58 | @NonNull 59 | @JsonProperty("name") 60 | public String name() { 61 | return name; 62 | } 63 | 64 | private void setName(@NonNull String name) { 65 | this.name = Objects.requireNonNull(name, "name must not be null"); 66 | } 67 | 68 | @Nullable 69 | @JsonProperty("description") 70 | public String description() { 71 | return description; 72 | } 73 | 74 | public void setDescription(@Nullable String description) { 75 | this.description = description; 76 | } 77 | 78 | @NonNull 79 | @JsonProperty("priceExcludingVAT") 80 | public Money price() { 81 | return Money.valueOf(priceCurrency, priceValue); 82 | } 83 | 84 | @NonNull 85 | @JsonProperty("priceIncludingVAT") 86 | public Money priceIncludingVAT() { 87 | return vat().addTax(price()); 88 | } 89 | 90 | private void setPrice(@NonNull Money price) { 91 | Objects.requireNonNull(price, "price must not be null"); 92 | priceCurrency = price.currency(); 93 | priceValue = price.fixedPointAmount(); 94 | } 95 | 96 | @NonNull 97 | @JsonProperty("valueAddedTax") 98 | public VAT vat() { 99 | return vat; 100 | } 101 | 102 | private void setVAT(@NonNull VAT vat) { 103 | this.vat = Objects.requireNonNull(vat); 104 | } 105 | 106 | @Override 107 | @Nullable 108 | public Long version() { 109 | return version; 110 | } 111 | 112 | @Override 113 | @JsonProperty("isDeleted") 114 | public boolean isDeleted() { 115 | return deleted; 116 | } 117 | 118 | @Override 119 | public void delete() { 120 | this.deleted = true; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /product-catalog/src/main/java/net/pkhapps/ddd/productcatalog/domain/model/ProductId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.productcatalog.domain.model; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 4 | 5 | /** 6 | * Value object representing a {@link Product} ID. 7 | */ 8 | public class ProductId extends DomainObjectId { 9 | public ProductId(String uuid) { 10 | super(uuid); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /product-catalog/src/main/java/net/pkhapps/ddd/productcatalog/domain/model/ProductRepository.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.productcatalog.domain.model; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.data.jpa.repository.Query; 5 | 6 | import java.util.List; 7 | 8 | /** 9 | * Repository of {@link Product}s. 10 | */ 11 | public interface ProductRepository extends JpaRepository { 12 | 13 | @Query("select p from Product p where p.deleted = false order by p.name") 14 | List findActive(); 15 | } 16 | -------------------------------------------------------------------------------- /product-catalog/src/main/java/net/pkhapps/ddd/productcatalog/infra/hibernate/ProductIdType.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.productcatalog.infra.hibernate; 2 | 3 | import net.pkhapps.ddd.productcatalog.domain.model.ProductId; 4 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdCustomType; 5 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdTypeDescriptor; 6 | 7 | /** 8 | * Hibernate custom type for {@link ProductId}. 9 | */ 10 | public class ProductIdType extends DomainObjectIdCustomType { 11 | 12 | private static final DomainObjectIdTypeDescriptor TYPE_DESCRIPTOR = new DomainObjectIdTypeDescriptor<>( 13 | ProductId.class, ProductId::new); 14 | 15 | public ProductIdType() { 16 | super(TYPE_DESCRIPTOR); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /product-catalog/src/main/java/net/pkhapps/ddd/productcatalog/infra/hibernate/package-info.java: -------------------------------------------------------------------------------- 1 | @TypeDef(defaultForType = ProductId.class, typeClass = ProductIdType.class) 2 | package net.pkhapps.ddd.productcatalog.infra.hibernate; 3 | 4 | import net.pkhapps.ddd.productcatalog.domain.model.ProductId; 5 | import org.hibernate.annotations.TypeDef; -------------------------------------------------------------------------------- /product-catalog/src/main/java/net/pkhapps/ddd/productcatalog/rest/ProductCatalogController.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.productcatalog.rest; 2 | 3 | import net.pkhapps.ddd.productcatalog.application.ProductCatalog; 4 | import net.pkhapps.ddd.productcatalog.domain.model.Product; 5 | import net.pkhapps.ddd.productcatalog.domain.model.ProductId; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.PathVariable; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | import java.util.List; 13 | 14 | /** 15 | * Controller for making {@link ProductCatalog} available through REST. 16 | */ 17 | @RestController 18 | @RequestMapping("/api/products") 19 | class ProductCatalogController { 20 | 21 | private final ProductCatalog productCatalog; 22 | 23 | ProductCatalogController(ProductCatalog productCatalog) { 24 | this.productCatalog = productCatalog; 25 | } 26 | 27 | // Please note: in a real-world application it would be better to have separate DTO classes that are serialized 28 | // to JSON. However, to save time, we're using the entity classes directly in this example. 29 | 30 | @GetMapping("/{id}") 31 | public ResponseEntity findById(@PathVariable("id") String productId) { 32 | return productCatalog.findById(new ProductId(productId)) 33 | .map(ResponseEntity::ok) 34 | .orElse(ResponseEntity.notFound().build()); 35 | } 36 | 37 | @GetMapping 38 | public List findAll() { 39 | return productCatalog.findAll(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /product-catalog/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.h2.console.enabled=true 2 | spring.h2.console.path=/h2-console 3 | spring.datasource.url=jdbc:h2:mem:product-catalog;DB_CLOSE_DELAY=-1. 4 | server.port=9001 5 | logging.level.net.pkhapps.ddd=debug 6 | -------------------------------------------------------------------------------- /shared-kernel/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | master-pom 7 | net.pkhapps.ddd 8 | 1.0-SNAPSHOT 9 | 10 | 4.0.0 11 | 12 | shared-kernel 13 | 14 | 15 | 16 | org.springframework.boot 17 | spring-boot-starter-data-jpa 18 | 19 | 20 | org.springframework.boot 21 | spring-boot-starter-web 22 | 23 | 24 | com.fasterxml.jackson.core 25 | jackson-databind 26 | 27 | 28 | com.vaadin 29 | vaadin-core 30 | provided 31 | true 32 | 33 | 34 | 35 | junit 36 | junit 37 | test 38 | 39 | 40 | org.assertj 41 | assertj-core 42 | test 43 | 44 | 45 | org.mockito 46 | mockito-core 47 | test 48 | 49 | 50 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/SharedConfiguration.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared; 2 | 3 | import org.springframework.boot.autoconfigure.domain.EntityScan; 4 | import org.springframework.context.annotation.ComponentScan; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 7 | import org.springframework.scheduling.annotation.EnableScheduling; 8 | 9 | @Configuration 10 | @ComponentScan 11 | @EntityScan 12 | @EnableJpaRepositories 13 | @EnableScheduling 14 | public class SharedConfiguration { 15 | } 16 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/base/AbstractAggregateRoot.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.base; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import org.springframework.data.domain.AfterDomainEventPublication; 5 | import org.springframework.data.domain.DomainEvents; 6 | import org.springframework.lang.NonNull; 7 | 8 | import javax.persistence.MappedSuperclass; 9 | import javax.persistence.Transient; 10 | import java.util.*; 11 | 12 | /** 13 | * Base class for aggregate roots. 14 | * 15 | * @param the aggregate root ID type. 16 | */ 17 | @MappedSuperclass 18 | public abstract class AbstractAggregateRoot extends AbstractEntity { 19 | 20 | @Transient 21 | @JsonIgnore 22 | private List domainEvents = new ArrayList<>(); 23 | 24 | /** 25 | * Default constructor 26 | */ 27 | protected AbstractAggregateRoot() { 28 | } 29 | 30 | /** 31 | * Copy constructor. Please note that any registered domain events are not copied. 32 | * 33 | * @param source the aggregate root to copy from. 34 | */ 35 | protected AbstractAggregateRoot(@NonNull AbstractAggregateRoot source) { 36 | super(source); 37 | } 38 | 39 | /** 40 | * Constructor for creating new aggregate roots. 41 | * 42 | * @param id the ID to assign to the aggregate root. 43 | */ 44 | protected AbstractAggregateRoot(ID id) { 45 | super(id); 46 | } 47 | 48 | /** 49 | * Registers the given domain event to be published when the aggregate root is persisted. 50 | * 51 | * @param event the event to register. 52 | */ 53 | @NonNull 54 | protected void registerEvent(@NonNull DomainEvent event) { 55 | Objects.requireNonNull(event, "event must not be null"); 56 | this.domainEvents.add(event); 57 | } 58 | 59 | /** 60 | * Called by the persistence framework to clear all registered domain events once they have been published. 61 | */ 62 | @AfterDomainEventPublication 63 | protected void clearDomainEvents() { 64 | this.domainEvents.clear(); 65 | } 66 | 67 | /** 68 | * Returns all domain events that have been registered for publication. Intended to be used by the persistence 69 | * framework only. 70 | */ 71 | @DomainEvents 72 | protected Collection domainEvents() { 73 | return Collections.unmodifiableList(domainEvents); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/base/AbstractEntity.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.base; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import org.springframework.data.util.ProxyUtils; 5 | import org.springframework.lang.NonNull; 6 | 7 | import javax.persistence.Id; 8 | import javax.persistence.MappedSuperclass; 9 | import java.util.Objects; 10 | 11 | /** 12 | * Base class for entities. 13 | * 14 | * @param the entity ID type. 15 | */ 16 | @MappedSuperclass 17 | public abstract class AbstractEntity implements IdentifiableDomainObject { 18 | 19 | @Id 20 | @JsonProperty("id") 21 | private ID id; 22 | 23 | /** 24 | * Default constructor 25 | */ 26 | protected AbstractEntity() { 27 | } 28 | 29 | /** 30 | * Copy constructor 31 | * 32 | * @param source the entity to copy from. 33 | */ 34 | protected AbstractEntity(@NonNull AbstractEntity source) { 35 | Objects.requireNonNull(source, "source must not be null"); 36 | this.id = source.id; 37 | } 38 | 39 | /** 40 | * Constructor for creating new entities. 41 | * 42 | * @param id the ID to assign to the entity. 43 | */ 44 | protected AbstractEntity(@NonNull ID id) { 45 | this.id = Objects.requireNonNull(id, "id must not be null"); 46 | } 47 | 48 | @Override 49 | @NonNull 50 | public ID id() { 51 | return id; 52 | } 53 | 54 | @SuppressWarnings("EqualsWhichDoesntCheckParameterClass") // We do this with a Spring function 55 | @Override 56 | public boolean equals(Object obj) { 57 | if (obj == this) { 58 | return true; 59 | } 60 | if (obj == null || !getClass().equals(ProxyUtils.getUserClass(obj))) { 61 | return false; 62 | } 63 | 64 | var other = (AbstractEntity) obj; 65 | return id != null && id.equals(other.id); 66 | } 67 | 68 | @Override 69 | public int hashCode() { 70 | return id == null ? super.hashCode() : id.hashCode(); 71 | } 72 | 73 | @Override 74 | public String toString() { 75 | return String.format("%s[%s]", getClass().getSimpleName(), id); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/base/ConcurrencySafeDomainObject.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.base; 2 | 3 | import org.springframework.lang.Nullable; 4 | 5 | /** 6 | * Interface for domain objects that use optimistic locking to prevent multiple concurrent sessions from updating the 7 | * object at the same time. 8 | */ 9 | public interface ConcurrencySafeDomainObject extends DomainObject { 10 | 11 | /** 12 | * Returns the optimistic locking version of this domain object. 13 | * 14 | * @return the version or {@code null} if no version has been assigned yet. 15 | */ 16 | @Nullable 17 | Long version(); 18 | } 19 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/base/DeletableDomainObject.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.base; 2 | 3 | /** 4 | * Interface for domain objects that can be softly deleted, meaning the domain object is not physically removed from 5 | * anywhere but only marked as deleted. 6 | */ 7 | public interface DeletableDomainObject extends DomainObject { 8 | 9 | /** 10 | * Returns whether this domain object has been marked as deleted. 11 | */ 12 | boolean isDeleted(); 13 | 14 | /** 15 | * Marks this domain object as deleted. 16 | */ 17 | void delete(); 18 | } 19 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/base/DomainEvent.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.base; 2 | 3 | import org.springframework.lang.NonNull; 4 | 5 | import java.time.Instant; 6 | 7 | /** 8 | * Interface for domain events. 9 | */ 10 | public interface DomainEvent extends DomainObject { 11 | 12 | /** 13 | * Returns the time and date on which the event occurred. 14 | */ 15 | @NonNull 16 | Instant occurredOn(); 17 | } 18 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/base/DomainObject.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.base; 2 | 3 | import java.io.Serializable; 4 | 5 | /** 6 | * Marker interface for domain objects. 7 | */ 8 | public interface DomainObject extends Serializable { 9 | } 10 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/base/DomainObjectId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.base; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonValue; 5 | import org.springframework.lang.NonNull; 6 | 7 | import java.util.Objects; 8 | import java.util.UUID; 9 | 10 | /** 11 | * Base class for value objects that are used as identifiers for {@link IdentifiableDomainObject}s. These are 12 | * essentially UUID-wrappers. 13 | */ 14 | public abstract class DomainObjectId implements ValueObject { 15 | 16 | private final String uuid; 17 | 18 | @JsonCreator 19 | protected DomainObjectId(@NonNull String uuid) { 20 | this.uuid = Objects.requireNonNull(uuid, "uuid must not be null"); 21 | } 22 | 23 | /** 24 | * Creates a new, random instance of the given {@code idClass}. 25 | */ 26 | @NonNull 27 | public static ID randomId(@NonNull Class idClass) { 28 | Objects.requireNonNull(idClass, "idClass must not be null"); 29 | try { 30 | return idClass.getConstructor(String.class).newInstance(UUID.randomUUID().toString()); 31 | } catch (Exception ex) { 32 | throw new RuntimeException("Could not create new instance of " + idClass, ex); 33 | } 34 | } 35 | 36 | /** 37 | * Returns the ID as a UUID string. 38 | */ 39 | @JsonValue 40 | @NonNull 41 | public String toUUID() { 42 | return uuid; 43 | } 44 | 45 | @Override 46 | public boolean equals(Object o) { 47 | if (this == o) return true; 48 | if (o == null || getClass() != o.getClass()) return false; 49 | DomainObjectId that = (DomainObjectId) o; 50 | return Objects.equals(uuid, that.uuid); 51 | } 52 | 53 | @Override 54 | public int hashCode() { 55 | return Objects.hash(uuid); 56 | } 57 | 58 | @Override 59 | public String toString() { 60 | return String.format("%s[%s]", getClass().getSimpleName(), uuid); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/base/IdentifiableDomainObject.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.base; 2 | 3 | import org.springframework.lang.Nullable; 4 | 5 | import java.io.Serializable; 6 | 7 | /** 8 | * Interface for domain objects that can be uniquely identified. 9 | * 10 | * @param the ID type. 11 | */ 12 | public interface IdentifiableDomainObject extends DomainObject { 13 | 14 | /** 15 | * Returns the ID of this domain object. 16 | * 17 | * @return the ID or {@code null} if an ID has not been assigned yet. 18 | */ 19 | @Nullable 20 | ID id(); 21 | } 22 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/base/ValueObject.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.base; 2 | 3 | /** 4 | * Marker interface for value objects. 5 | */ 6 | public interface ValueObject extends DomainObject { 7 | } 8 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/financial/Currency.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.financial; 2 | 3 | /** 4 | * Enumeration of currencies. 5 | */ 6 | public enum Currency { 7 | EUR, SEK, NOK, DKK; 8 | } 9 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/financial/CurrencyConverter.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.financial; 2 | 3 | import org.springframework.lang.NonNull; 4 | 5 | /** 6 | * Domain service interface for converting between currencies. 7 | */ 8 | public interface CurrencyConverter { 9 | 10 | /** 11 | * Converts the {@code amount} to the {@code newCurrency}. 12 | * 13 | * @param amount the amount to convert. 14 | * @param newCurrency the currency to convert to. 15 | * @return the converted amount in the new currency. 16 | */ 17 | @NonNull 18 | Money convert(@NonNull Money amount, @NonNull Currency newCurrency); 19 | } 20 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/financial/Money.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.financial; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import net.pkhapps.ddd.shared.domain.base.ValueObject; 6 | import org.springframework.lang.NonNull; 7 | 8 | import java.util.Objects; 9 | 10 | /** 11 | * Value object representing an amount of money. The amount is stored as a fixed-point integer where the last two digits 12 | * represent the decimals. 13 | */ 14 | public class Money implements ValueObject { 15 | 16 | @JsonProperty("currency") 17 | private final Currency currency; 18 | @JsonProperty("amount") 19 | private final int amount; 20 | 21 | /** 22 | * Creates a new {@code Money} object. 23 | * 24 | * @param currency the currency. 25 | * @param amount fixed-point integer where the last two digits represent decimals. 26 | */ 27 | @JsonCreator 28 | public Money(@NonNull @JsonProperty("currency") Currency currency, @JsonProperty("amount") int amount) { 29 | this.currency = Objects.requireNonNull(currency, "currency must not be null"); 30 | this.amount = amount; 31 | } 32 | 33 | /** 34 | * Creates a new {@code Money} object. 35 | * 36 | * @param currency the currency. 37 | * @param amount the amount as a double. 38 | */ 39 | public Money(@NonNull Currency currency, double amount) { 40 | this(currency, (int) (amount * 100)); 41 | } 42 | 43 | /** 44 | * Creates a new {@code Money} object if both of the parameters are non-{@code null}. 45 | * 46 | * @param currency the currency. 47 | * @param value fixed-point integer where the last two digits represent decimals. 48 | * @return a new {@code Money} object or {@code null} if any of the parameters are {@code null}. 49 | */ 50 | public static Money valueOf(Currency currency, Integer value) { 51 | if (currency == null || value == null) { 52 | return null; 53 | } else { 54 | return new Money(currency, value); 55 | } 56 | } 57 | 58 | /** 59 | * Returns a new {@code Money} object whose amount is the sum of this amount and {@code augend}'s amount. 60 | * 61 | * @param augend the {@code Money} object to add to this object. 62 | * @return {@code this} + {@code augend} 63 | * @throws IllegalArgumentException if this object and {@code augend} have different currencies. 64 | */ 65 | @NonNull 66 | public Money add(@NonNull Money augend) { 67 | Objects.requireNonNull(augend, "augend must not be null"); 68 | if (currency != augend.currency) { 69 | throw new IllegalArgumentException("Cannot add two Money objects with different currencies"); 70 | } 71 | return new Money(currency, amount + augend.amount); 72 | } 73 | 74 | /** 75 | * Returns a new {@code Money} object whose amount is the difference between this amount and {@code subtrahend}'s amount. 76 | * 77 | * @param subtrahend the {@code Money} object to remove from this object. 78 | * @return {@code this} - {@code augend} 79 | * @throws IllegalArgumentException if this object and {@code subtrahend} have different currencies. 80 | */ 81 | @NonNull 82 | public Money subtract(@NonNull Money subtrahend) { 83 | Objects.requireNonNull(subtrahend, "subtrahend must not be null"); 84 | if (currency != subtrahend.currency) { 85 | throw new IllegalArgumentException("Cannot subtract two Money objects with different currencies"); 86 | } 87 | return new Money(currency, amount - subtrahend.amount); 88 | } 89 | 90 | /** 91 | * Returns a new {@code Money} object whose amount is this amount multiplied by {@code multiplicand}. 92 | * 93 | * @param multiplicand the value to multiply the amount by. 94 | * @return {@code this} * {@code multiplicand} 95 | */ 96 | @NonNull 97 | public Money multiply(int multiplicand) { 98 | return new Money(currency, amount * multiplicand); 99 | } 100 | 101 | /** 102 | * Returns the currency. 103 | */ 104 | @NonNull 105 | public Currency currency() { 106 | return currency; 107 | } 108 | 109 | /** 110 | * Returns the amount as a fixed-point integer where the last two digits represent decimals. 111 | */ 112 | public int fixedPointAmount() { 113 | return amount; 114 | } 115 | 116 | /** 117 | * Returns the amount as a double. 118 | */ 119 | public double doubleAmount() { 120 | return amount / 100d; 121 | } 122 | 123 | @Override 124 | public boolean equals(Object o) { 125 | if (this == o) return true; 126 | if (o == null || getClass() != o.getClass()) return false; 127 | Money money = (Money) o; 128 | return amount == money.amount && 129 | currency == money.currency; 130 | } 131 | 132 | @Override 133 | public int hashCode() { 134 | return Objects.hash(currency, amount); 135 | } 136 | 137 | @Override 138 | public String toString() { 139 | String amountString; 140 | if (amount == 0) { 141 | amountString = "000"; 142 | } else { 143 | amountString = Integer.toString(amount); 144 | } 145 | return String.format("%s %s.%s", currency, amountString.substring(0, amountString.length() - 2), 146 | amountString.substring(amountString.length() - 2)); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/financial/VAT.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.financial; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonValue; 5 | import net.pkhapps.ddd.shared.domain.base.ValueObject; 6 | import org.springframework.lang.NonNull; 7 | 8 | import java.util.Objects; 9 | 10 | /** 11 | * Value object representing a VAT (Value Added Tax) percentage. 12 | */ 13 | public class VAT implements ValueObject { 14 | 15 | private final int percentage; 16 | 17 | /** 18 | * Creates a new {@code VAT} object. 19 | * 20 | * @param percentage the percentage as an integer where e.g. 24 means 24 %. 21 | */ 22 | @JsonCreator 23 | public VAT(int percentage) { 24 | if (percentage < 0) { 25 | throw new IllegalArgumentException("VAT cannot be negative"); 26 | } 27 | this.percentage = percentage; 28 | } 29 | 30 | /** 31 | * Creates a new {@code VAT} object of {@code percentage} is not null. 32 | * 33 | * @param percentage the percentage as an integer where e.g. 24 means 24 %. 34 | * @return the new {@code VAT} object or {@code null} if {@code percentage} is null. 35 | */ 36 | public static VAT valueOf(Integer percentage) { 37 | return percentage == null ? null : new VAT(percentage); 38 | } 39 | 40 | /** 41 | * Returns the VAT percentage, e.g. 24 % returns 24. 42 | */ 43 | @JsonValue 44 | public int toInteger() { 45 | return percentage; 46 | } 47 | 48 | /** 49 | * Returns the VAT percentage as a fraction, e.g. 24 % returns 0.24. 50 | */ 51 | public double toDouble() { 52 | return percentage / 100d; 53 | } 54 | 55 | /** 56 | * Adds tax to the given amount. 57 | * 58 | * @param amount the amount to add tax to. 59 | * @return the amount including tax. 60 | */ 61 | @NonNull 62 | public Money addTax(@NonNull Money amount) { 63 | Objects.requireNonNull(amount, "amount must not be null"); 64 | return amount.add(calculateTax(amount)); 65 | } 66 | 67 | /** 68 | * Subtracts tax from the given amount. 69 | * 70 | * @param amount the amount to subtract tax from. 71 | * @return the amount excluding tax. 72 | */ 73 | @NonNull 74 | public Money subtractTax(@NonNull Money amount) { 75 | Objects.requireNonNull(amount, "amount must not be null"); 76 | var withoutTax = (amount.fixedPointAmount() * 100) / (percentage + 100); 77 | return new Money(amount.currency(), withoutTax); 78 | } 79 | 80 | /** 81 | * Calculates the tax for the given amount. 82 | * 83 | * @param amount the amount to calculate the tax for. 84 | * @return the amount of tax. 85 | */ 86 | @NonNull 87 | public Money calculateTax(@NonNull Money amount) { 88 | Objects.requireNonNull(amount, "amount must not be null"); 89 | var tax = (amount.fixedPointAmount() * percentage) / 100; 90 | return new Money(amount.currency(), tax); 91 | } 92 | 93 | @Override 94 | public boolean equals(Object o) { 95 | if (this == o) return true; 96 | if (o == null || getClass() != o.getClass()) return false; 97 | VAT vat = (VAT) o; 98 | return percentage == vat.percentage; 99 | } 100 | 101 | @Override 102 | public int hashCode() { 103 | return Objects.hash(percentage); 104 | } 105 | 106 | @Override 107 | public String toString() { 108 | return String.format("%d %%", percentage); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/financial/converter/VATAttributeConverter.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.financial.converter; 2 | 3 | import net.pkhapps.ddd.shared.domain.financial.VAT; 4 | 5 | import javax.persistence.AttributeConverter; 6 | import javax.persistence.Converter; 7 | 8 | /** 9 | * JPA attribute converter for {@link VAT}. 10 | */ 11 | @Converter(autoApply = true) 12 | public class VATAttributeConverter implements AttributeConverter { 13 | 14 | @Override 15 | public Integer convertToDatabaseColumn(VAT attribute) { 16 | return attribute == null ? null : attribute.toInteger(); 17 | } 18 | 19 | @Override 20 | public VAT convertToEntityAttribute(Integer dbData) { 21 | return VAT.valueOf(dbData); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/geo/Address.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.geo; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.base.ValueObject; 5 | import org.springframework.lang.NonNull; 6 | import org.springframework.lang.Nullable; 7 | 8 | import javax.persistence.*; 9 | import java.util.Objects; 10 | 11 | @Embeddable 12 | @MappedSuperclass 13 | public class Address implements ValueObject { 14 | 15 | @Column(name = "address_line1") 16 | private String addressLine1; 17 | @Column(name = "address_line2") 18 | private String addressLine2; 19 | @Column(name = "city") 20 | private CityName city; 21 | @Column(name = "postal_code") 22 | private PostalCode postalCode; 23 | @Column(name = "country") 24 | @Enumerated(EnumType.STRING) 25 | private Country country; 26 | 27 | @SuppressWarnings("unused") // Used by JPA only. 28 | protected Address() { 29 | } 30 | 31 | public Address(@NonNull String addressLine1, @Nullable String addressLine2, @NonNull CityName city, 32 | @NonNull PostalCode postalCode, @NonNull Country country) { 33 | this.addressLine1 = addressLine1; 34 | this.addressLine2 = addressLine2; 35 | this.city = city; 36 | this.postalCode = postalCode; 37 | this.country = country; 38 | } 39 | 40 | @NonNull 41 | @JsonProperty("address1") 42 | public String addressLine1() { 43 | return addressLine1; 44 | } 45 | 46 | @Nullable 47 | @JsonProperty("address2") 48 | public String addressLine2() { 49 | return addressLine2; 50 | } 51 | 52 | @NonNull 53 | @JsonProperty("city") 54 | public CityName city() { 55 | return city; 56 | } 57 | 58 | @NonNull 59 | @JsonProperty("postalCode") 60 | public PostalCode postalCode() { 61 | return postalCode; 62 | } 63 | 64 | @NonNull 65 | @JsonProperty("country") 66 | public Country country() { 67 | return country; 68 | } 69 | 70 | @Override 71 | public boolean equals(Object o) { 72 | if (this == o) return true; 73 | if (o == null || getClass() != o.getClass()) return false; 74 | Address address = (Address) o; 75 | return Objects.equals(addressLine1, address.addressLine1) && 76 | Objects.equals(addressLine2, address.addressLine2) && 77 | Objects.equals(city, address.city) && 78 | Objects.equals(postalCode, address.postalCode) && 79 | country == address.country; 80 | } 81 | 82 | @Override 83 | public int hashCode() { 84 | return Objects.hash(addressLine1, addressLine2, city, postalCode, country); 85 | } 86 | 87 | @Override 88 | public String toString() { 89 | StringBuilder sb = new StringBuilder(); 90 | sb.append(addressLine1); 91 | if (addressLine2 != null) { 92 | sb.append(", "); 93 | sb.append(addressLine2); 94 | } 95 | sb.append(", "); 96 | sb.append(postalCode).append(" ").append(city); 97 | sb.append(", "); 98 | sb.append(country); 99 | return sb.toString(); 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/geo/CityName.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.geo; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonValue; 5 | import net.pkhapps.ddd.shared.domain.base.ValueObject; 6 | import org.springframework.lang.NonNull; 7 | 8 | import java.util.Objects; 9 | 10 | /** 11 | * Value object representing the name of a city. 12 | */ 13 | public class CityName implements ValueObject { 14 | 15 | private final String name; 16 | 17 | @JsonCreator 18 | public CityName(@NonNull String name) { 19 | this.name = Objects.requireNonNull(name, "name must not be null"); 20 | } 21 | 22 | @Override 23 | public boolean equals(Object o) { 24 | if (this == o) return true; 25 | if (o == null || getClass() != o.getClass()) return false; 26 | CityName cityName = (CityName) o; 27 | return Objects.equals(name, cityName.name); 28 | } 29 | 30 | @Override 31 | public int hashCode() { 32 | return Objects.hash(name); 33 | } 34 | 35 | @Override 36 | @JsonValue 37 | public String toString() { 38 | return name; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/geo/Country.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.geo; 2 | 3 | /** 4 | * Enumeration of countries that we ship to. 5 | */ 6 | public enum Country { 7 | FINLAND, 8 | SWEDEN, 9 | NORWAY, 10 | DENMARK 11 | } 12 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/geo/PostalCode.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.geo; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonValue; 5 | import net.pkhapps.ddd.shared.domain.base.ValueObject; 6 | import org.springframework.lang.NonNull; 7 | 8 | import java.util.Objects; 9 | 10 | /** 11 | * Value object representing a postal code. 12 | */ 13 | public class PostalCode implements ValueObject { 14 | 15 | private final String postalCode; 16 | 17 | @JsonCreator 18 | public PostalCode(@NonNull String postalCode) { 19 | this.postalCode = Objects.requireNonNull(postalCode, "postalCode must not be null"); 20 | } 21 | 22 | @Override 23 | public boolean equals(Object o) { 24 | if (this == o) return true; 25 | if (o == null || getClass() != o.getClass()) return false; 26 | PostalCode that = (PostalCode) o; 27 | return Objects.equals(postalCode, that.postalCode); 28 | } 29 | 30 | @Override 31 | public int hashCode() { 32 | return Objects.hash(postalCode); 33 | } 34 | 35 | @Override 36 | @JsonValue 37 | public String toString() { 38 | return postalCode; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/geo/converter/CityNameConverter.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.geo.converter; 2 | 3 | import net.pkhapps.ddd.shared.domain.geo.CityName; 4 | 5 | import javax.persistence.AttributeConverter; 6 | import javax.persistence.Converter; 7 | 8 | /** 9 | * JPA attribute converter for {@link CityName}. 10 | */ 11 | @Converter(autoApply = true) 12 | public class CityNameConverter implements AttributeConverter { 13 | 14 | @Override 15 | public String convertToDatabaseColumn(CityName attribute) { 16 | return attribute == null ? null : attribute.toString(); 17 | } 18 | 19 | @Override 20 | public CityName convertToEntityAttribute(String dbData) { 21 | return dbData == null ? null : new CityName(dbData); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/domain/geo/converter/PostalCodeConverter.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.geo.converter; 2 | 3 | import net.pkhapps.ddd.shared.domain.geo.PostalCode; 4 | 5 | import javax.persistence.AttributeConverter; 6 | import javax.persistence.Converter; 7 | 8 | /** 9 | * JPA attribute converter for {@link PostalCode}. 10 | */ 11 | @Converter(autoApply = true) 12 | public class PostalCodeConverter implements AttributeConverter { 13 | 14 | @Override 15 | public String convertToDatabaseColumn(PostalCode attribute) { 16 | return attribute == null ? null : attribute.toString(); 17 | } 18 | 19 | @Override 20 | public PostalCode convertToEntityAttribute(String dbData) { 21 | return dbData == null ? null : new PostalCode(dbData); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/DomainEventLog.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.IdentifiableDomainObject; 4 | import org.springframework.lang.NonNull; 5 | import org.springframework.lang.Nullable; 6 | 7 | import java.util.List; 8 | import java.util.Optional; 9 | 10 | /** 11 | * A domain event log is an identifiable list of {@link StoredDomainEvent}s. A log can be appended to until it is full, 12 | * after which it never changes. 13 | * 14 | * @see DomainEventLogService 15 | */ 16 | public class DomainEventLog implements IdentifiableDomainObject { 17 | 18 | private final DomainEventLogId id; 19 | private final DomainEventLogId previous; 20 | private final DomainEventLogId next; 21 | private final List events; 22 | 23 | DomainEventLog(@NonNull DomainEventLogId id, @Nullable DomainEventLogId previous, 24 | @Nullable DomainEventLogId next, @NonNull List events) { 25 | this.id = id; 26 | this.previous = previous; 27 | this.next = next; 28 | this.events = List.copyOf(events); 29 | } 30 | 31 | @Override 32 | @NonNull 33 | public DomainEventLogId id() { 34 | return id; 35 | } 36 | 37 | /** 38 | * Returns the ID of the previous domain event log if one exists. 39 | */ 40 | @NonNull 41 | public Optional previousId() { 42 | return Optional.ofNullable(previous); 43 | } 44 | 45 | /** 46 | * Returns the ID of the next domain event log if one exists. 47 | */ 48 | @NonNull 49 | public Optional nextId() { 50 | return Optional.ofNullable(next); 51 | } 52 | 53 | /** 54 | * Returns the events contained in this domain event log. 55 | */ 56 | @NonNull 57 | public List events() { 58 | return events; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/DomainEventLogAppender.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainEvent; 4 | import org.springframework.lang.NonNull; 5 | import org.springframework.stereotype.Service; 6 | import org.springframework.transaction.event.TransactionPhase; 7 | import org.springframework.transaction.event.TransactionalEventListener; 8 | 9 | /** 10 | * The domain event log appender listens for all {@link DomainEvent}s that are published and 11 | * {@link DomainEventLogService#append(DomainEvent) appends} them to the domain event log. The domain event is stored 12 | * inside the same transaction that published the event so if the transaction fails, no event is stored. 13 | */ 14 | @Service 15 | class DomainEventLogAppender { 16 | 17 | private final DomainEventLogService domainEventLogService; 18 | 19 | DomainEventLogAppender(DomainEventLogService domainEventLogService) { 20 | this.domainEventLogService = domainEventLogService; 21 | } 22 | 23 | @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 24 | public void onDomainEvent(@NonNull DomainEvent domainEvent) { 25 | domainEventLogService.append(domainEvent); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/DomainEventLogId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.ValueObject; 4 | 5 | import java.util.Objects; 6 | 7 | /** 8 | * Value object representing the identifier of a {@link DomainEventLog}. In practice, the identifier consists of the 9 | * lowest and highest {@link StoredDomainEvent#id() ID} to include in the log. 10 | */ 11 | public class DomainEventLogId implements ValueObject { 12 | 13 | private final long low; 14 | private final long high; 15 | 16 | public DomainEventLogId(long low, long high) { 17 | if (low > high) { 18 | throw new IllegalArgumentException("low cannot be higher than high"); 19 | } 20 | this.low = low; 21 | this.high = high; 22 | } 23 | 24 | /** 25 | * Returns the lowest {@link StoredDomainEvent#id()} ID to include in the log. 26 | */ 27 | public long low() { 28 | return low; 29 | } 30 | 31 | /** 32 | * Returns the highest {@link StoredDomainEvent#id()} ID to include in the log. 33 | */ 34 | public long high() { 35 | return high; 36 | } 37 | 38 | /** 39 | * Returns whether this domain event log ID refers to the first domain event log. 40 | */ 41 | boolean isFirst() { 42 | return low == 1; 43 | } 44 | 45 | @Override 46 | public boolean equals(Object o) { 47 | if (this == o) return true; 48 | if (o == null || getClass() != o.getClass()) return false; 49 | var that = (DomainEventLogId) o; 50 | return Objects.equals(low, that.low) && 51 | Objects.equals(high, that.high); 52 | } 53 | 54 | @Override 55 | public int hashCode() { 56 | return Objects.hash(low, high); 57 | } 58 | 59 | @Override 60 | public String toString() { 61 | return String.format("%s[%d,%d]", getClass().getSimpleName(), low, high); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/DomainEventLogService.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import net.pkhapps.ddd.shared.domain.base.DomainEvent; 5 | import org.springframework.lang.NonNull; 6 | import org.springframework.stereotype.Service; 7 | import org.springframework.transaction.annotation.Propagation; 8 | import org.springframework.transaction.annotation.Transactional; 9 | 10 | import java.util.List; 11 | import java.util.Objects; 12 | import java.util.Optional; 13 | import java.util.stream.Collectors; 14 | 15 | /** 16 | * The domain event log service is responsible for storing and retrieving {@link DomainEvent}s. These can be used for 17 | * auditing or for integration with other systems / bounded contexts. 18 | * 19 | * @see StoredDomainEvent 20 | * @see DomainEventLog 21 | * @see DomainEventLogAppender 22 | */ 23 | @Service 24 | public class DomainEventLogService { 25 | 26 | private static final int LOG_SIZE = 20; 27 | 28 | private final StoredDomainEventRepository storedDomainEventRepository; 29 | private final ObjectMapper objectMapper; 30 | 31 | DomainEventLogService(StoredDomainEventRepository storedDomainEventRepository, 32 | ObjectMapper objectMapper) { 33 | this.storedDomainEventRepository = storedDomainEventRepository; 34 | this.objectMapper = objectMapper; 35 | } 36 | 37 | private static long calculateHighFromLow(long low) { 38 | return low + LOG_SIZE - 1; 39 | } 40 | 41 | private static boolean isValidId(@NonNull DomainEventLogId id) { 42 | if (id.high() - id.low() + 1 != LOG_SIZE) { 43 | return false; 44 | } 45 | return (id.low() - 1) % LOG_SIZE == 0; 46 | } 47 | 48 | /** 49 | * Returns the domain event log with the given ID. 50 | * 51 | * @param logId the ID of the log to retrieve. 52 | * @return the log or an empty {@code Optional} if the log does not exist. 53 | */ 54 | @NonNull 55 | @Transactional(propagation = Propagation.REQUIRED, readOnly = true) 56 | public Optional retrieveLog(@NonNull DomainEventLogId logId) { 57 | Objects.requireNonNull(logId, "logId must not be null"); 58 | if (!isValidId(logId)) { 59 | return Optional.empty(); 60 | } 61 | var currentId = currentLogId(); 62 | var events = storedDomainEventRepository.findEventsBetween(logId.low(), logId.high()).collect(Collectors.toList()); 63 | if (events.isEmpty() && !logId.equals(currentId)) { 64 | return Optional.empty(); 65 | } 66 | var previousId = previousLogId(logId).orElse(null); 67 | var nextId = logId.equals(currentId) ? null : nextLogId(logId); 68 | return Optional.of(new DomainEventLog(logId, previousId, nextId, events)); 69 | } 70 | 71 | /** 72 | * Returns the current log. The current log can never be completely full since it becomes an archived 73 | * log immediately when the {@value #LOG_SIZE}:th event is {@link #append(DomainEvent) appended}. 74 | * 75 | * @return the current log. 76 | */ 77 | @NonNull 78 | @Transactional(propagation = Propagation.REQUIRED, readOnly = true) 79 | public DomainEventLog currentLog() { 80 | return retrieveLog(currentLogId()).orElseThrow(); 81 | } 82 | 83 | @NonNull 84 | private List findEvents(@NonNull DomainEventLogId logId) { 85 | return storedDomainEventRepository.findEventsBetween(logId.low(), logId.high()).collect(Collectors.toList()); 86 | } 87 | 88 | @NonNull 89 | private DomainEventLogId currentLogId() { 90 | var max = storedDomainEventRepository.findHighestDomainEventId(); 91 | if (max == null) { 92 | max = 0L; 93 | } 94 | var remainder = max % LOG_SIZE; 95 | if (remainder == 0 && max == 0) { 96 | remainder = LOG_SIZE; 97 | } 98 | 99 | var low = max - remainder + 1; 100 | if (low < 1) { 101 | low = 1; 102 | } 103 | var high = calculateHighFromLow(low); 104 | 105 | return new DomainEventLogId(low, high); 106 | } 107 | 108 | @NonNull 109 | private Optional previousLogId(@NonNull DomainEventLogId logId) { 110 | Objects.requireNonNull(logId, "logId must not be null"); 111 | 112 | if (logId.isFirst()) { 113 | return Optional.empty(); 114 | } 115 | 116 | var low = logId.low() - LOG_SIZE; 117 | if (low < 1) { 118 | low = 1; 119 | } 120 | var high = calculateHighFromLow(low); 121 | 122 | return Optional.of(new DomainEventLogId(low, high)); 123 | } 124 | 125 | @NonNull 126 | private DomainEventLogId nextLogId(@NonNull DomainEventLogId logId) { 127 | var low = logId.high() + 1; 128 | var high = calculateHighFromLow(low); 129 | 130 | return new DomainEventLogId(low, high); 131 | } 132 | 133 | /** 134 | * Appends the given domain event to the event log. 135 | * 136 | * @param domainEvent the domain event to append. 137 | */ 138 | @Transactional(propagation = Propagation.MANDATORY) 139 | public void append(@NonNull DomainEvent domainEvent) { 140 | var storedEvent = new StoredDomainEvent(domainEvent, objectMapper); 141 | storedDomainEventRepository.saveAndFlush(storedEvent); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/ProcessedRemoteEvent.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.IdentifiableDomainObject; 4 | import org.springframework.lang.NonNull; 5 | 6 | import javax.persistence.Column; 7 | import javax.persistence.Entity; 8 | import javax.persistence.Id; 9 | import javax.persistence.Table; 10 | import java.util.Objects; 11 | 12 | /** 13 | * Internal database entity used by {@link RemoteEventProcessor} to keep track of which remote events have already been 14 | * processed. 15 | */ 16 | @Entity 17 | @Table(name = "processed_remote_events") 18 | class ProcessedRemoteEvent implements IdentifiableDomainObject { 19 | 20 | @Id 21 | @Column(name = "source", nullable = false) 22 | private String source; 23 | 24 | @Column(name = "last_processed_event_d", nullable = false) 25 | private long lastProcessedEventId; 26 | 27 | @SuppressWarnings("unused") // Used by JPA only 28 | private ProcessedRemoteEvent() { 29 | } 30 | 31 | ProcessedRemoteEvent(String source, long lastProcessedEventId) { 32 | this.source = Objects.requireNonNull(source); 33 | this.lastProcessedEventId = lastProcessedEventId; 34 | } 35 | 36 | @NonNull 37 | public String source() { 38 | return source; 39 | } 40 | 41 | @NonNull 42 | public long lastProcessedEventId() { 43 | return lastProcessedEventId; 44 | } 45 | 46 | @Override 47 | public String id() { 48 | return source(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/ProcessedRemoteEventRepository.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | /** 6 | * Repository interface for {@link ProcessedRemoteEvent}. 7 | */ 8 | interface ProcessedRemoteEventRepository extends JpaRepository { 9 | } 10 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/RemoteEventLog.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import org.springframework.lang.NonNull; 4 | 5 | import java.util.List; 6 | import java.util.Optional; 7 | 8 | /** 9 | * Interface for a domain event log that exists on a remote machine. 10 | * 11 | * @see RemoteEventLogService 12 | */ 13 | public interface RemoteEventLog { 14 | 15 | /** 16 | * Returns whether this log is the current log (the one that new events are being appended to). 17 | */ 18 | boolean isCurrent(); 19 | 20 | /** 21 | * Returns the previous log if available. 22 | */ 23 | @NonNull 24 | Optional previous(); 25 | 26 | /** 27 | * Returns the next log if available. 28 | */ 29 | @NonNull 30 | Optional next(); 31 | 32 | /** 33 | * Checks if this event log contains a {@link StoredDomainEvent} with the given {@link StoredDomainEvent#id() ID}. 34 | */ 35 | default boolean containsEvent(@NonNull Long eventId) { 36 | return events().stream().anyMatch(event -> eventId.equals(event.id())); 37 | } 38 | 39 | /** 40 | * Returns all events in the log. 41 | */ 42 | @NonNull 43 | List events(); 44 | } 45 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/RemoteEventLogService.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import org.springframework.lang.NonNull; 4 | 5 | /** 6 | * Service interface for retrieving {@link RemoteEventLog}s from a remote event source. An instance of this interface 7 | * for each event source to retrieve logs from should exist in the application context. 8 | */ 9 | public interface RemoteEventLogService { 10 | 11 | /** 12 | * Returns the name of the remote event source (such as the URL of the server). 13 | */ 14 | @NonNull 15 | String source(); 16 | 17 | /** 18 | * Returns the current event log. Use {@link RemoteEventLog#previous()} to go back. 19 | */ 20 | @NonNull 21 | RemoteEventLog currentLog(); 22 | } 23 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/RemoteEventProcessor.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.context.ApplicationEventPublisher; 6 | import org.springframework.lang.NonNull; 7 | import org.springframework.scheduling.annotation.Scheduled; 8 | import org.springframework.stereotype.Service; 9 | import org.springframework.transaction.PlatformTransactionManager; 10 | import org.springframework.transaction.TransactionDefinition; 11 | import org.springframework.transaction.TransactionStatus; 12 | import org.springframework.transaction.support.DefaultTransactionDefinition; 13 | import org.springframework.transaction.support.TransactionCallbackWithoutResult; 14 | import org.springframework.transaction.support.TransactionTemplate; 15 | 16 | import java.util.List; 17 | import java.util.Map; 18 | 19 | /** 20 | * Internal service that regularly retrieve and process {@link StoredDomainEvent}s from all 21 | * {@link RemoteEventLogService}s that exist in the application context. {@link RemoteEventTranslator}s are used 22 | * to translate the events into {@link net.pkhapps.ddd.shared.domain.base.DomainEvent}s, which are then published on 23 | * the local {@link ApplicationEventPublisher application event bus}. 24 | */ 25 | @Service 26 | class RemoteEventProcessor { 27 | 28 | private static final Logger LOGGER = LoggerFactory.getLogger(RemoteEventProcessor.class); 29 | 30 | private final ProcessedRemoteEventRepository processedRemoteEventRepository; 31 | private final Map remoteEventLogs; 32 | private final Map remoteEventTranslators; 33 | private final ApplicationEventPublisher applicationEventPublisher; 34 | private final TransactionTemplate transactionTemplate; 35 | 36 | RemoteEventProcessor(@NonNull ProcessedRemoteEventRepository processedRemoteEventRepository, 37 | @NonNull Map remoteEventLogs, 38 | @NonNull Map remoteEventTranslators, 39 | @NonNull ApplicationEventPublisher applicationEventPublisher, 40 | @NonNull PlatformTransactionManager platformTransactionManager) { 41 | this.processedRemoteEventRepository = processedRemoteEventRepository; 42 | this.remoteEventLogs = remoteEventLogs; 43 | this.remoteEventTranslators = remoteEventTranslators; 44 | this.applicationEventPublisher = applicationEventPublisher; 45 | this.transactionTemplate = new TransactionTemplate(platformTransactionManager, 46 | new DefaultTransactionDefinition(TransactionDefinition.PROPAGATION_REQUIRES_NEW)); 47 | } 48 | 49 | /** 50 | * Retrieves and processes remote events. 51 | */ 52 | @Scheduled(fixedDelay = 20000) 53 | public void processEvents() { 54 | remoteEventLogs.values().forEach(this::processEvents); 55 | } 56 | 57 | private void processEvents(@NonNull RemoteEventLogService remoteEventLogService) { 58 | LOGGER.info("Processing remote events from {}", remoteEventLogService.source()); 59 | var lastProcessedId = getLastProcessedId(remoteEventLogService); 60 | 61 | var log = remoteEventLogService.currentLog(); 62 | LOGGER.debug("Starting with current log {}", log); 63 | // Find the first log 64 | while (!log.containsEvent(lastProcessedId)) { 65 | var previous = log.previous(); 66 | if (previous.isPresent()) { 67 | log = previous.get(); 68 | LOGGER.debug("Checking previous log {}", log); 69 | } else { 70 | break; 71 | } 72 | } 73 | 74 | // Then, start processing events 75 | do { 76 | LOGGER.debug("Processing events in log {}", log); 77 | processEvents(remoteEventLogService, lastProcessedId, log.events()); 78 | var next = log.next(); 79 | if (next.isPresent()) { 80 | log = next.get(); 81 | } 82 | } while (!log.isCurrent()); 83 | 84 | LOGGER.info("Finished processing remote events from {}", remoteEventLogService.source()); 85 | } 86 | 87 | private void processEvents(@NonNull RemoteEventLogService remoteEventLogService, long lastProcessedId, 88 | @NonNull List events) { 89 | events.forEach(event -> { 90 | if (event.id() > lastProcessedId) { 91 | transactionTemplate.execute(new TransactionCallbackWithoutResult() { 92 | @Override 93 | protected void doInTransactionWithoutResult(TransactionStatus status) { 94 | LOGGER.debug("Processing remote event {} from {}", event, remoteEventLogService.source()); 95 | publishEvent(event); 96 | setLastProcessedId(remoteEventLogService, event.id()); 97 | } 98 | }); 99 | } 100 | }); 101 | } 102 | 103 | private long getLastProcessedId(@NonNull RemoteEventLogService remoteEventLogService) { 104 | return processedRemoteEventRepository.findById(remoteEventLogService.source()) 105 | .map(ProcessedRemoteEvent::lastProcessedEventId) 106 | .orElse(0L); 107 | } 108 | 109 | private void setLastProcessedId(@NonNull RemoteEventLogService remoteEventLogService, long lastProcessedId) { 110 | processedRemoteEventRepository.saveAndFlush(new ProcessedRemoteEvent(remoteEventLogService.source(), lastProcessedId)); 111 | } 112 | 113 | private void publishEvent(@NonNull StoredDomainEvent event) { 114 | remoteEventTranslators.values().stream() 115 | .filter(translator -> translator.supports(event)) 116 | .findFirst() 117 | .flatMap(translator -> translator.translate(event)) 118 | .ifPresent(applicationEventPublisher::publishEvent); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/RemoteEventTranslator.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainEvent; 4 | import org.springframework.lang.NonNull; 5 | 6 | import java.util.Optional; 7 | 8 | /** 9 | * Interface for a remote event translator that translates a remote {@link StoredDomainEvent} into a local 10 | * {@link DomainEvent}, taking into account that the original domain event class used on the remote side may not 11 | * exist in the class path on the local side. 12 | */ 13 | public interface RemoteEventTranslator { 14 | 15 | /** 16 | * Returns whether this translator supports the given remote event (i.e. knows how to translate it). 17 | */ 18 | boolean supports(@NonNull StoredDomainEvent remoteEvent); 19 | 20 | /** 21 | * Translates the {@link StoredDomainEvent#toJsonString() JSON} of the given remote event into a local 22 | * {@link DomainEvent}. 23 | */ 24 | @NonNull 25 | Optional translate(@NonNull StoredDomainEvent remoteEvent); 26 | } 27 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/eventlog/StoredDomainEventRepository.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.eventlog; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.data.jpa.repository.Query; 5 | import org.springframework.data.repository.query.Param; 6 | 7 | import java.util.stream.Stream; 8 | 9 | /** 10 | * Repository of {@link StoredDomainEvent}s. 11 | */ 12 | interface StoredDomainEventRepository extends JpaRepository { 13 | 14 | @Query("select max(se.id) from StoredDomainEvent se") 15 | Long findHighestDomainEventId(); 16 | 17 | @Query("select se from StoredDomainEvent se where se.id >= :low and se.id <= :high order by se.id") 18 | Stream findEventsBetween(@Param("low") Long low, @Param("high") Long high); 19 | } 20 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/hibernate/DomainObjectIdCustomType.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.hibernate; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 4 | import org.hibernate.id.ResultSetIdentifierConsumer; 5 | import org.hibernate.type.AbstractSingleColumnStandardBasicType; 6 | import org.hibernate.type.descriptor.sql.VarcharTypeDescriptor; 7 | import org.springframework.lang.NonNull; 8 | 9 | import java.io.Serializable; 10 | import java.sql.ResultSet; 11 | import java.sql.SQLException; 12 | 13 | /** 14 | * Hibernate custom type for a {@link DomainObjectId} subtype. You need this to be able to use {@link DomainObjectId}s 15 | * as primary keys. You have to create one subclass per {@link DomainObjectId} subtype. 16 | * 17 | * @param the ID type. 18 | * @see DomainObjectIdTypeDescriptor 19 | */ 20 | public abstract class DomainObjectIdCustomType extends AbstractSingleColumnStandardBasicType 21 | implements ResultSetIdentifierConsumer { 22 | 23 | /** 24 | * Creates a new {@code DomainObjectIdCustomType}. In your subclass, you should create a default constructor and 25 | * invoke this constructor from there. 26 | * 27 | * @param domainObjectIdTypeDescriptor the {@link DomainObjectIdTypeDescriptor} for the ID type. 28 | */ 29 | public DomainObjectIdCustomType(@NonNull DomainObjectIdTypeDescriptor domainObjectIdTypeDescriptor) { 30 | super(VarcharTypeDescriptor.INSTANCE, domainObjectIdTypeDescriptor); 31 | } 32 | 33 | @Override 34 | public Serializable consumeIdentifier(ResultSet resultSet) { 35 | try { 36 | var id = resultSet.getString(1); 37 | return getJavaTypeDescriptor().fromString(id); 38 | } catch (SQLException ex) { 39 | throw new IllegalStateException("Could not extract ID from ResultSet", ex); 40 | } 41 | } 42 | 43 | @Override 44 | public String getName() { 45 | return getJavaTypeDescriptor().getJavaType().getSimpleName(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/hibernate/DomainObjectIdTypeDescriptor.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.hibernate; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 4 | import org.hibernate.type.descriptor.WrapperOptions; 5 | import org.hibernate.type.descriptor.java.AbstractTypeDescriptor; 6 | import org.springframework.lang.NonNull; 7 | 8 | import java.util.Objects; 9 | import java.util.function.Function; 10 | 11 | /** 12 | * Hibernate type descriptor for a {@link DomainObjectId} subtype. You typically don't need to subclass this. 13 | * 14 | * @param the ID type. 15 | * @see DomainObjectIdCustomType 16 | */ 17 | public class DomainObjectIdTypeDescriptor extends AbstractTypeDescriptor { 18 | 19 | private final Function factory; 20 | 21 | /** 22 | * Creates a new {@code DomainObjectIdTypeDescriptor}. 23 | * 24 | * @param type the ID type. 25 | * @param factory a factory for creating new ID instances. 26 | */ 27 | public DomainObjectIdTypeDescriptor(@NonNull Class type, @NonNull Function factory) { 28 | super(type); 29 | this.factory = Objects.requireNonNull(factory, "factory must not be null"); 30 | } 31 | 32 | @Override 33 | public String toString(ID value) { 34 | return value.toUUID(); 35 | } 36 | 37 | @Override 38 | public ID fromString(String string) { 39 | return factory.apply(string); 40 | } 41 | 42 | @Override 43 | public X unwrap(ID value, Class type, WrapperOptions options) { 44 | if (value == null) { 45 | return null; 46 | } 47 | if (type.isAssignableFrom(getJavaType())) { 48 | return type.cast(value); 49 | } 50 | if (type.isAssignableFrom(String.class)) { 51 | return type.cast(toString(value)); 52 | } 53 | throw unknownUnwrap(type); 54 | } 55 | 56 | @Override 57 | public ID wrap(X value, WrapperOptions options) { 58 | if (value == null) { 59 | return null; 60 | } 61 | if (getJavaType().isInstance(value)) { 62 | return getJavaType().cast(value); 63 | } 64 | if (value instanceof String) { 65 | return fromString((String) value); 66 | } 67 | throw unknownWrap(value.getClass()); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/infra/jackson/RawJsonDeserializer.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.infra.jackson; 2 | 3 | import com.fasterxml.jackson.core.JsonParser; 4 | import com.fasterxml.jackson.core.JsonProcessingException; 5 | import com.fasterxml.jackson.databind.DeserializationContext; 6 | import com.fasterxml.jackson.databind.JsonDeserializer; 7 | import com.fasterxml.jackson.databind.JsonNode; 8 | import com.fasterxml.jackson.databind.ObjectMapper; 9 | 10 | import java.io.IOException; 11 | 12 | /** 13 | * {@link JsonDeserializer} that deserializes the parsed JSON into a JSON string. To be used for the deserialization 14 | * of {@link com.fasterxml.jackson.annotation.JsonRawValue} fields. 15 | */ 16 | public class RawJsonDeserializer extends JsonDeserializer { 17 | 18 | @Override 19 | public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException { 20 | ObjectMapper mapper = (ObjectMapper) p.getCodec(); 21 | JsonNode node = mapper.readTree(p); 22 | return mapper.writeValueAsString(node); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/rest/client/CurrencyConverterClient.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.rest.client; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.financial.Currency; 5 | import net.pkhapps.ddd.shared.domain.financial.CurrencyConverter; 6 | import net.pkhapps.ddd.shared.domain.financial.Money; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.beans.factory.annotation.Value; 10 | import org.springframework.http.client.SimpleClientHttpRequestFactory; 11 | import org.springframework.lang.NonNull; 12 | import org.springframework.scheduling.annotation.Scheduled; 13 | import org.springframework.stereotype.Service; 14 | import org.springframework.web.client.RestTemplate; 15 | import org.springframework.web.util.UriComponentsBuilder; 16 | 17 | import java.time.LocalDate; 18 | import java.util.Map; 19 | import java.util.concurrent.ConcurrentHashMap; 20 | import java.util.stream.Collectors; 21 | import java.util.stream.Stream; 22 | 23 | /** 24 | * Implementation of {@link CurrencyConverter} that uses https://exchangeratesapi.io/ to fetch the latest rates. 25 | */ 26 | @Service 27 | class CurrencyConverterClient implements CurrencyConverter { 28 | 29 | private static final Logger LOGGER = LoggerFactory.getLogger(CurrencyConverterClient.class); 30 | private final RestTemplate restTemplate; 31 | private final Map conversionRates = new ConcurrentHashMap<>(); 32 | private final boolean enabled; 33 | 34 | public CurrencyConverterClient(@Value("${app.currency-conversion.enabled:false}") boolean enabled) { 35 | this.enabled = enabled; 36 | restTemplate = new RestTemplate(); 37 | var requestFactory = new SimpleClientHttpRequestFactory(); 38 | // Never ever do a remote call without a finite timeout! 39 | requestFactory.setConnectTimeout(10000); 40 | requestFactory.setReadTimeout(10000); 41 | restTemplate.setRequestFactory(requestFactory); 42 | } 43 | 44 | @Override 45 | @NonNull 46 | public Money convert(@NonNull Money amount, @NonNull Currency newCurrency) { 47 | if (amount.currency() == newCurrency) { 48 | return amount; 49 | } else { 50 | var euro = convertToEuro(amount); 51 | return convertEuroTo(euro, newCurrency); 52 | } 53 | } 54 | 55 | private Money convertToEuro(@NonNull Money amount) { 56 | if (amount.currency() == Currency.EUR) { 57 | return amount; 58 | } else { 59 | return new Money(Currency.EUR, amount.doubleAmount() / getConversionRateFor(amount.currency())); 60 | } 61 | } 62 | 63 | private Money convertEuroTo(@NonNull Money amount, @NonNull Currency newCurrency) { 64 | return new Money(newCurrency, amount.doubleAmount() * getConversionRateFor(newCurrency)); 65 | } 66 | 67 | private double getConversionRateFor(@NonNull Currency currency) { 68 | var rate = conversionRates.get(currency); 69 | if (rate == null) { 70 | throw new IllegalStateException("Missing conversion rate for " + currency); 71 | } 72 | return rate; 73 | } 74 | 75 | @Scheduled(fixedRate = 6 * 60 * 60 * 1000) // Refresh every 6 hours 76 | public void fetchConversionRates() { 77 | if (!enabled) { 78 | LOGGER.info("Currency conversion is disabled. No rates are fetched."); 79 | return; 80 | } 81 | LOGGER.info("Fetching conversion rates from web service"); 82 | var uri = UriComponentsBuilder.fromUriString("https://api.exchangeratesapi.io/latest") 83 | .queryParam("symbols", Stream.of(Currency.values()) 84 | .filter(c -> c != Currency.EUR) 85 | .map(Enum::name).collect(Collectors.joining(","))).build().toUri(); 86 | LOGGER.debug("Using URI {}", uri); 87 | var rates = restTemplate.getForEntity(uri, RatesDTO.class).getBody(); 88 | if (rates != null) { 89 | conversionRates.putAll(rates.rates); 90 | LOGGER.info("Received {} rates", rates.rates.size()); 91 | } 92 | } 93 | 94 | static class RatesDTO { 95 | 96 | @JsonProperty("date") 97 | LocalDate date; 98 | 99 | @JsonProperty("base") 100 | Currency base; 101 | 102 | @JsonProperty("rates") 103 | Map rates; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/rest/client/RemoteEventLogServiceClient.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.rest.client; 2 | 3 | import net.pkhapps.ddd.shared.infra.eventlog.RemoteEventLog; 4 | import net.pkhapps.ddd.shared.infra.eventlog.RemoteEventLogService; 5 | import net.pkhapps.ddd.shared.infra.eventlog.StoredDomainEvent; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.core.ParameterizedTypeReference; 9 | import org.springframework.http.HttpHeaders; 10 | import org.springframework.http.HttpMethod; 11 | import org.springframework.http.HttpStatus; 12 | import org.springframework.http.ResponseEntity; 13 | import org.springframework.http.client.SimpleClientHttpRequestFactory; 14 | import org.springframework.lang.NonNull; 15 | import org.springframework.lang.Nullable; 16 | import org.springframework.web.client.RestTemplate; 17 | import org.springframework.web.util.UriComponentsBuilder; 18 | 19 | import java.net.URI; 20 | import java.util.List; 21 | import java.util.Objects; 22 | import java.util.Optional; 23 | 24 | /** 25 | * Implementation of {@link RemoteEventLogService} that reads events from a REST-service backed by 26 | * {@link net.pkhapps.ddd.shared.rest.controller.EventLogController}. Clients should create as many instances of this 27 | * class as there are servers to read events from. 28 | */ 29 | public class RemoteEventLogServiceClient implements RemoteEventLogService { 30 | 31 | private static final Logger LOGGER = LoggerFactory.getLogger(RemoteEventLogService.class); 32 | 33 | private final String source; 34 | private final URI currentLogUri; 35 | private final RestTemplate restTemplate; 36 | 37 | /** 38 | * Creates a new {@code RemoteEventLogServiceClient}. 39 | * 40 | * @param serverUrl the base URL of the server. The correct path for the event log will be appended automatically. 41 | * @param connectTimeout the connection timeout in milliseconds. 42 | * @param readTimeout the read timeout in milliseconds. 43 | */ 44 | public RemoteEventLogServiceClient(@NonNull String serverUrl, int connectTimeout, int readTimeout) { 45 | this.source = Objects.requireNonNull(serverUrl, "serverUrl must not be null"); 46 | currentLogUri = UriComponentsBuilder.fromUriString(serverUrl).path("/api/event-log").build().toUri(); 47 | restTemplate = new RestTemplate(); 48 | var requestFactory = new SimpleClientHttpRequestFactory(); 49 | // Never ever do a remote call without a finite timeout! 50 | requestFactory.setConnectTimeout(connectTimeout); 51 | requestFactory.setReadTimeout(readTimeout); 52 | restTemplate.setRequestFactory(requestFactory); 53 | LOGGER.info("Reading remote domain events from {}", serverUrl); 54 | } 55 | 56 | @Override 57 | @NonNull 58 | public String source() { 59 | return source; 60 | } 61 | 62 | @Override 63 | @NonNull 64 | public RemoteEventLog currentLog() { 65 | return retrieveLog(currentLogUri); 66 | } 67 | 68 | @NonNull 69 | private RemoteEventLog retrieveLog(@NonNull URI uri) { 70 | LOGGER.debug("Retrieving remote log from {}", uri); 71 | ResponseEntity> response = restTemplate.exchange(uri, HttpMethod.GET, 72 | null, new ParameterizedTypeReference>() { 73 | }); 74 | if (response.getStatusCode() != HttpStatus.OK) { 75 | throw new IllegalArgumentException("Could not retrieve log from URI " + uri); 76 | } 77 | return new RemoteEventLogImpl(response); 78 | } 79 | 80 | private class RemoteEventLogImpl implements RemoteEventLog { 81 | 82 | private final List events; 83 | private final URI previousLogUri; 84 | private final URI nextLogUri; 85 | private final URI thisUri; 86 | 87 | private RemoteEventLogImpl(@NonNull ResponseEntity> responseEntity) { 88 | events = List.copyOf(Objects.requireNonNull(responseEntity.getBody())); 89 | previousLogUri = extractLink(responseEntity.getHeaders(), "previous"); 90 | nextLogUri = extractLink(responseEntity.getHeaders(), "next"); 91 | thisUri = extractLink(responseEntity.getHeaders(), "self"); 92 | } 93 | 94 | @Override 95 | public boolean isCurrent() { 96 | return nextLogUri == null; 97 | } 98 | 99 | @Override 100 | public Optional previous() { 101 | if (previousLogUri != null) { 102 | return Optional.of(retrieveLog(previousLogUri)); 103 | } else { 104 | return Optional.empty(); 105 | } 106 | } 107 | 108 | @Override 109 | public Optional next() { 110 | if (nextLogUri != null) { 111 | return Optional.of(retrieveLog(nextLogUri)); 112 | } else { 113 | return Optional.empty(); 114 | } 115 | } 116 | 117 | @Override 118 | public List events() { 119 | return events; 120 | } 121 | 122 | @Nullable 123 | private URI extractLink(@NonNull HttpHeaders headers, @NonNull String rel) { 124 | return headers.get("Link").stream() 125 | .filter(link -> link.endsWith("rel=\"" + rel + "\"")) 126 | .findFirst() 127 | .map(link -> link.substring(1, link.indexOf('>'))) 128 | .map(URI::create) 129 | .orElse(null); 130 | } 131 | 132 | @Override 133 | public String toString() { 134 | return String.format("%s[self=%s, previous=%s, next=%s]", RemoteEventLog.class.getSimpleName(), thisUri, previousLogUri, nextLogUri); 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/rest/controller/EventLogController.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.rest.controller; 2 | 3 | import net.pkhapps.ddd.shared.infra.eventlog.DomainEventLog; 4 | import net.pkhapps.ddd.shared.infra.eventlog.DomainEventLogId; 5 | import net.pkhapps.ddd.shared.infra.eventlog.DomainEventLogService; 6 | import net.pkhapps.ddd.shared.infra.eventlog.StoredDomainEvent; 7 | import org.springframework.http.ResponseEntity; 8 | import org.springframework.lang.NonNull; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.PathVariable; 11 | import org.springframework.web.bind.annotation.RequestMapping; 12 | import org.springframework.web.bind.annotation.RestController; 13 | import org.springframework.web.util.UriComponentsBuilder; 14 | 15 | import java.net.URI; 16 | import java.util.List; 17 | 18 | /** 19 | * REST controller that exposes the domain event log as a REST service. 20 | * 21 | * @see net.pkhapps.ddd.shared.rest.client.RemoteEventLogServiceClient 22 | */ 23 | @RestController 24 | @RequestMapping(path = EventLogController.ROOT) 25 | class EventLogController { 26 | 27 | static final String ROOT = "/api/event-log"; 28 | 29 | private final DomainEventLogService domainEventLogService; 30 | 31 | EventLogController(DomainEventLogService domainEventLogService) { 32 | this.domainEventLogService = domainEventLogService; 33 | } 34 | 35 | @GetMapping(path = "/{low},{high}") 36 | public ResponseEntity> domainEvents(@PathVariable("low") long low, 37 | @PathVariable("high") long high, 38 | UriComponentsBuilder uriBuilder) { 39 | var logId = new DomainEventLogId(low, high); 40 | return domainEventLogService.retrieveLog(logId) 41 | .map(log -> createResponse(log, uriBuilder)) 42 | .orElse(ResponseEntity.notFound().build()); 43 | } 44 | 45 | @GetMapping 46 | public ResponseEntity> currentLog(UriComponentsBuilder uriBuilder) { 47 | return createResponse(domainEventLogService.currentLog(), uriBuilder); 48 | } 49 | 50 | @NonNull 51 | private ResponseEntity> createResponse(@NonNull DomainEventLog log, 52 | @NonNull UriComponentsBuilder uriBuilder) { 53 | var responseBuilder = ResponseEntity.ok(); 54 | log.previousId().ifPresent(previous -> addLink(responseBuilder, buildURI(uriBuilder, previous), "previous")); 55 | log.nextId().ifPresent(next -> addLink(responseBuilder, buildURI(uriBuilder, next), "next")); 56 | addLink(responseBuilder, buildURI(uriBuilder, log.id()), "self"); 57 | return responseBuilder.body(log.events()); 58 | } 59 | 60 | private void addLink(@NonNull ResponseEntity.BodyBuilder builder, @NonNull URI uri, @NonNull String rel) { 61 | builder.header("Link", String.format("<%s>; rel=\"%s\"", uri, rel)); 62 | } 63 | 64 | @NonNull 65 | private URI buildURI(UriComponentsBuilder uriBuilder, @NonNull DomainEventLogId logId) { 66 | return uriBuilder.cloneBuilder().path(ROOT).path("/{low},{high}").build(logId.low(), logId.high()); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/ui/converter/StringToCityNameConverter.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.ui.converter; 2 | 3 | import com.vaadin.flow.data.binder.Result; 4 | import com.vaadin.flow.data.binder.ValueContext; 5 | import com.vaadin.flow.data.converter.Converter; 6 | import net.pkhapps.ddd.shared.domain.geo.CityName; 7 | 8 | public class StringToCityNameConverter implements Converter { 9 | 10 | @Override 11 | public Result convertToModel(String value, ValueContext context) { 12 | return value == null ? Result.ok(null) : Result.ok(new CityName(value)); 13 | } 14 | 15 | @Override 16 | public String convertToPresentation(CityName value, ValueContext context) { 17 | return value == null ? "" : value.toString(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared-kernel/src/main/java/net/pkhapps/ddd/shared/ui/converter/StringToPostalCodeConverter.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.ui.converter; 2 | 3 | import com.vaadin.flow.data.binder.Result; 4 | import com.vaadin.flow.data.binder.ValueContext; 5 | import com.vaadin.flow.data.converter.Converter; 6 | import net.pkhapps.ddd.shared.domain.geo.PostalCode; 7 | 8 | public class StringToPostalCodeConverter implements Converter { 9 | 10 | @Override 11 | public Result convertToModel(String s, ValueContext valueContext) { 12 | return s == null ? Result.ok(null) : Result.ok(new PostalCode(s)); 13 | } 14 | 15 | @Override 16 | public String convertToPresentation(PostalCode postalCode, ValueContext valueContext) { 17 | return postalCode == null ? "" : postalCode.toString(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared-kernel/src/test/java/net/pkhapps/ddd/shared/domain/financial/MoneyTest.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.financial; 2 | 3 | import org.junit.Test; 4 | 5 | import static org.assertj.core.api.Assertions.assertThat; 6 | 7 | /** 8 | * Unit test for {@link Money}. 9 | */ 10 | public class MoneyTest { 11 | 12 | @Test 13 | public void createFromInteger() { 14 | var money = new Money(Currency.EUR, 12500); 15 | assertThat(money.currency()).isEqualTo(Currency.EUR); 16 | assertThat(money.fixedPointAmount()).isEqualTo(12500); 17 | assertThat(money.doubleAmount()).isEqualTo(125.00); 18 | } 19 | 20 | @Test 21 | public void createFromDouble() { 22 | var money = new Money(Currency.SEK, 130.50); 23 | assertThat(money.currency()).isEqualTo(Currency.SEK); 24 | assertThat(money.fixedPointAmount()).isEqualTo(13050); 25 | assertThat(money.doubleAmount()).isEqualTo(130.50); 26 | } 27 | 28 | @Test 29 | public void valueOf_nullCurrency_nullReturned() { 30 | assertThat(Money.valueOf(null, 123)).isNull(); 31 | } 32 | 33 | @Test 34 | public void valueOf_nullValue_nullReturned() { 35 | assertThat(Money.valueOf(Currency.SEK, null)).isNull(); 36 | } 37 | 38 | @Test 39 | public void valueOf_nonNull_moneyCreated() { 40 | assertThat(Money.valueOf(Currency.SEK, 12300)).isEqualTo(new Money(Currency.SEK, 123.00)); 41 | } 42 | 43 | @Test 44 | public void add() { 45 | var m1 = new Money(Currency.SEK, 150.00); 46 | var m2 = new Money(Currency.SEK, 225.00); 47 | assertThat(m1.add(m2)).isEqualTo(new Money(Currency.SEK, 375.00)); 48 | } 49 | 50 | @Test(expected = IllegalArgumentException.class) 51 | public void add_differentCurrencies_exceptionThrown() { 52 | var m1 = new Money(Currency.SEK, 150.00); 53 | var m2 = new Money(Currency.EUR, 225.00); 54 | m1.add(m2); 55 | } 56 | 57 | @Test 58 | public void subtract() { 59 | var m1 = new Money(Currency.SEK, 300.50); 60 | var m2 = new Money(Currency.SEK, 200.50); 61 | assertThat(m1.subtract(m2)).isEqualTo(new Money(Currency.SEK, 100.00)); 62 | } 63 | 64 | @Test(expected = IllegalArgumentException.class) 65 | public void subtract_differentCurrencies_exceptionThrown() { 66 | var m1 = new Money(Currency.SEK, 300.50); 67 | var m2 = new Money(Currency.EUR, 200.50); 68 | m1.subtract(m2); 69 | } 70 | 71 | @Test 72 | public void toString_zero() { 73 | assertThat(new Money(Currency.SEK, 0).toString()).isEqualTo("SEK 0.00"); 74 | } 75 | 76 | @Test 77 | public void toString_noZero() { 78 | assertThat(new Money(Currency.EUR, 212550).toString()).isEqualTo("EUR 2125.50"); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /shared-kernel/src/test/java/net/pkhapps/ddd/shared/domain/financial/VATTest.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shared.domain.financial; 2 | 3 | import org.junit.Test; 4 | 5 | import static org.assertj.core.api.Assertions.assertThat; 6 | 7 | /** 8 | * Unit test for {@link VAT}. 9 | */ 10 | public class VATTest { 11 | 12 | @Test 13 | public void valueOf_nullPercentage_nullReturned() { 14 | assertThat(VAT.valueOf(null)).isNull(); 15 | } 16 | 17 | @Test 18 | public void valueOf_nonNullPercentage_vatCreated() { 19 | assertThat(VAT.valueOf(24)).isNotNull(); 20 | } 21 | 22 | @Test 23 | public void addTax_noDecimals() { 24 | var withTax = new VAT(24).addTax(new Money(Currency.EUR, 100.00)); 25 | assertThat(withTax).isEqualTo(new Money(Currency.EUR, 124.00)); 26 | } 27 | 28 | @Test 29 | public void addTax_withDecimals() { 30 | var withTax = new VAT(24).addTax(new Money(Currency.EUR, 1.00)); 31 | assertThat(withTax).isEqualTo(new Money(Currency.EUR, 1.24)); 32 | } 33 | 34 | @Test 35 | public void subtractTax_noDecimals() { 36 | var withoutTax = new VAT(24).subtractTax(new Money(Currency.EUR, 124.00)); 37 | assertThat(withoutTax).isEqualTo(new Money(Currency.EUR, 100.00)); 38 | } 39 | 40 | @Test 41 | public void subtractTax_withDecimals() { 42 | var withoutTax = new VAT(24).subtractTax(new Money(Currency.EUR, 1.24)); 43 | assertThat(withoutTax).isEqualTo(new Money(Currency.EUR, 1.00)); 44 | } 45 | 46 | @Test 47 | public void toDouble_returnedAsFraction() { 48 | assertThat(new VAT(24).toDouble()).isEqualTo(0.24); 49 | } 50 | 51 | @Test 52 | public void toInteger_returnedAsPercent() { 53 | assertThat(new VAT(24).toInteger()).isEqualTo(24); 54 | } 55 | 56 | @Test 57 | public void toString_returnedAsFormattedString() { 58 | assertThat(new VAT(24).toString()).isEqualTo("24 %"); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /shipping/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | master-pom 7 | net.pkhapps.ddd 8 | 1.0-SNAPSHOT 9 | 10 | 4.0.0 11 | 12 | shipping 13 | 14 | 15 | 16 | net.pkhapps.ddd 17 | shared-kernel 18 | ${project.version} 19 | 20 | 21 | org.springframework.boot 22 | spring-boot-starter-web 23 | 24 | 25 | com.vaadin 26 | vaadin-spring-boot-starter 27 | 28 | 29 | com.h2database 30 | h2 31 | 32 | 33 | 34 | 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-maven-plugin 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/ShippingApp.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping; 2 | 3 | import net.pkhapps.ddd.shared.SharedConfiguration; 4 | import net.pkhapps.ddd.shared.infra.eventlog.RemoteEventLogService; 5 | import net.pkhapps.ddd.shared.rest.client.RemoteEventLogServiceClient; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.boot.SpringApplication; 8 | import org.springframework.boot.autoconfigure.SpringBootApplication; 9 | import org.springframework.boot.autoconfigure.domain.EntityScan; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.Import; 12 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 13 | 14 | import java.time.Clock; 15 | 16 | @SpringBootApplication 17 | @EnableJpaRepositories 18 | @EntityScan 19 | @Import(SharedConfiguration.class) 20 | public class ShippingApp { 21 | 22 | public static void main(String[] args) { 23 | SpringApplication.run(ShippingApp.class, args); 24 | } 25 | 26 | @Bean 27 | public Clock clock() { 28 | return Clock.systemUTC(); 29 | } 30 | 31 | @Bean 32 | public RemoteEventLogService orderEvents(@Value("${app.orders.url}") String serverUrl, 33 | @Value("${app.orders.connect-timeout-ms}") int connectTimeout, 34 | @Value("${app.orders.read-timeout-ms}") int readTimeout) { 35 | return new RemoteEventLogServiceClient(serverUrl, connectTimeout, readTimeout); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/application/ShippingListCreator.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.application; 2 | 3 | import net.pkhapps.ddd.shared.domain.geo.Address; 4 | import net.pkhapps.ddd.shipping.domain.PickingList; 5 | import net.pkhapps.ddd.shipping.domain.PickingListRepository; 6 | import net.pkhapps.ddd.shipping.integration.OrderCreatedEvent; 7 | import net.pkhapps.ddd.shipping.rest.client.Order; 8 | import net.pkhapps.ddd.shipping.rest.client.OrderCatalogClient; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import org.springframework.stereotype.Service; 12 | import org.springframework.transaction.event.TransactionPhase; 13 | import org.springframework.transaction.event.TransactionalEventListener; 14 | 15 | import javax.annotation.Nonnull; 16 | import java.time.Clock; 17 | 18 | @Service 19 | class ShippingListCreator { 20 | 21 | private static final Logger LOGGER = LoggerFactory.getLogger(ShippingListCreator.class); 22 | 23 | private final OrderCatalogClient orderCatalogClient; 24 | private final PickingListRepository pickingListRepository; 25 | private final Clock clock; 26 | 27 | ShippingListCreator(OrderCatalogClient orderCatalogClient, 28 | PickingListRepository pickingListRepository, 29 | Clock clock) { 30 | this.orderCatalogClient = orderCatalogClient; 31 | this.pickingListRepository = pickingListRepository; 32 | this.clock = clock; 33 | } 34 | 35 | @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 36 | public void onOrderCreatedEvent(OrderCreatedEvent event) { 37 | orderCatalogClient 38 | .findById(event.orderId()) 39 | .map(this::createPickingList) 40 | .ifPresent(pickingListRepository::save); 41 | } 42 | 43 | @Nonnull 44 | private PickingList createPickingList(@Nonnull Order order) { 45 | LOGGER.info("Creating picking list for order {}", order.orderId()); 46 | var pickingList = new PickingList(clock.instant(), order.orderId(), order.shippingAddress().name(), 47 | new Address(order.shippingAddress().address1(), 48 | order.shippingAddress().address2(), 49 | order.shippingAddress().cityName(), 50 | order.shippingAddress().postalCode(), 51 | order.shippingAddress().country())); 52 | order.items().forEach(item -> pickingList.addItem(item.productId(), item.description(), item.quantity())); 53 | return pickingList; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/application/ShippingService.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.application; 2 | 3 | import net.pkhapps.ddd.shipping.domain.PickingList; 4 | import net.pkhapps.ddd.shipping.domain.PickingListId; 5 | import net.pkhapps.ddd.shipping.domain.PickingListRepository; 6 | import net.pkhapps.ddd.shipping.rest.client.OrderCatalogClient; 7 | import org.springframework.lang.NonNull; 8 | import org.springframework.stereotype.Service; 9 | import org.springframework.transaction.annotation.Propagation; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | import javax.annotation.Nonnull; 13 | import java.time.Clock; 14 | import java.util.List; 15 | import java.util.Optional; 16 | 17 | @Service 18 | @Transactional(propagation = Propagation.REQUIRES_NEW) 19 | public class ShippingService { 20 | 21 | private final PickingListRepository pickingListRepository; 22 | private final OrderCatalogClient orderCatalogClient; 23 | private final Clock clock; 24 | 25 | ShippingService(PickingListRepository pickingListRepository, OrderCatalogClient orderCatalogClient, Clock clock) { 26 | this.pickingListRepository = pickingListRepository; 27 | this.orderCatalogClient = orderCatalogClient; 28 | this.clock = clock; 29 | } 30 | 31 | @Nonnull 32 | public List findPickingLists() { 33 | return pickingListRepository.findAll(); 34 | } 35 | 36 | @NonNull 37 | public Optional findById(@NonNull PickingListId pickingListId) { 38 | return pickingListRepository.findById(pickingListId); 39 | } 40 | 41 | public void startAssembly(@Nonnull PickingListId pickingListId) { 42 | pickingListRepository.findById(pickingListId).ifPresent(pickingList -> { 43 | pickingList.startAssembly(); 44 | orderCatalogClient.startProcessing(pickingList.orderId()); 45 | pickingListRepository.save(pickingList); 46 | }); 47 | } 48 | 49 | public void ship(@Nonnull PickingListId pickingListId) { 50 | pickingListRepository.findById(pickingListId).ifPresent(pickingList -> { 51 | pickingList.ship(clock); 52 | orderCatalogClient.finishProcessing(pickingList.orderId()); 53 | pickingListRepository.save(pickingList); 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/domain/OrderId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.domain; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 5 | 6 | public class OrderId extends DomainObjectId { 7 | @JsonCreator 8 | public OrderId(String uuid) { 9 | super(uuid); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/domain/PickingList.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.domain; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.base.AbstractAggregateRoot; 5 | import net.pkhapps.ddd.shared.domain.base.ConcurrencySafeDomainObject; 6 | import net.pkhapps.ddd.shared.domain.geo.Address; 7 | import org.springframework.lang.NonNull; 8 | import org.springframework.lang.Nullable; 9 | 10 | import javax.annotation.Nonnull; 11 | import javax.persistence.*; 12 | import java.time.Clock; 13 | import java.time.Instant; 14 | import java.util.HashSet; 15 | import java.util.Objects; 16 | import java.util.Set; 17 | import java.util.stream.Stream; 18 | 19 | @Entity 20 | @Table(name = "picking_lists") 21 | public class PickingList extends AbstractAggregateRoot implements ConcurrencySafeDomainObject { 22 | 23 | @Version 24 | private Long version; 25 | @Column(name = "created_on", nullable = false) 26 | private Instant createdOn; 27 | @Column(name = "shipped_on") 28 | private Instant shippedOn; 29 | @Column(name = "order_id", nullable = false, unique = true) 30 | private OrderId orderId; 31 | @Column(name = "recipient_name", nullable = false) 32 | private String recipientName; 33 | @Embedded 34 | private Address recipientAddress; 35 | @Column(name = "picking_list_state", nullable = false) 36 | @Enumerated(EnumType.STRING) 37 | private PickingListState state; 38 | @ElementCollection(fetch = FetchType.EAGER) 39 | @CollectionTable(name = "picking_list_items") 40 | private Set items; 41 | 42 | @SuppressWarnings("unused") // Used by JPA only 43 | private PickingList() { 44 | } 45 | 46 | public PickingList(@NonNull Instant createdOn, @Nonnull OrderId orderId, @Nonnull String recipientName, @Nonnull Address recipientAddress) { 47 | super(PickingListId.randomId(PickingListId.class)); 48 | setCreatedOn(createdOn); 49 | setOrderId(orderId); 50 | setRecipientName(recipientName); 51 | setRecipientAddress(recipientAddress); 52 | setState(PickingListState.WAITING); 53 | items = new HashSet<>(); 54 | } 55 | 56 | @NonNull 57 | @JsonProperty("createdOn") 58 | public Instant createdOn() { 59 | return createdOn; 60 | } 61 | 62 | private void setCreatedOn(@NonNull Instant createdOn) { 63 | this.createdOn = Objects.requireNonNull(createdOn, "createdOn must not be null"); 64 | } 65 | 66 | @Nullable 67 | @JsonProperty("shippedOn") 68 | public Instant shippedOn() { 69 | return shippedOn; 70 | } 71 | 72 | private void setShippedOn(Instant shippedOn) { 73 | this.shippedOn = shippedOn; 74 | } 75 | 76 | @Nonnull 77 | @JsonProperty("orderId") 78 | public OrderId orderId() { 79 | return orderId; 80 | } 81 | 82 | private void setOrderId(@Nonnull OrderId orderId) { 83 | this.orderId = Objects.requireNonNull(orderId, "orderId must not be null"); 84 | } 85 | 86 | @Nonnull 87 | @JsonProperty("recipientName") 88 | public String recipientName() { 89 | return recipientName; 90 | } 91 | 92 | private void setRecipientName(String recipientName) { 93 | this.recipientName = Objects.requireNonNull(recipientName, "recipientName must not be null"); 94 | } 95 | 96 | @Nonnull 97 | @JsonProperty("recipientAddress") 98 | public Address recipientAddress() { 99 | return recipientAddress; 100 | } 101 | 102 | private void setRecipientAddress(@Nonnull Address recipientAddress) { 103 | this.recipientAddress = Objects.requireNonNull(recipientAddress, "recipientAddress must not be null"); 104 | } 105 | 106 | @Nonnull 107 | @JsonProperty("state") 108 | public PickingListState state() { 109 | return state; 110 | } 111 | 112 | private void setState(@Nonnull PickingListState state) { 113 | this.state = Objects.requireNonNull(state, "state must not be null"); 114 | } 115 | 116 | public void startAssembly() { 117 | if (state != PickingListState.WAITING) { 118 | throw new IllegalStateException("Cannot start assembly when state is " + state); 119 | } 120 | setState(PickingListState.ASSEMBLY); 121 | } 122 | 123 | public void ship(Clock clock) { 124 | if (state != PickingListState.ASSEMBLY) { 125 | throw new IllegalStateException("Cannot ship when state is " + state); 126 | } 127 | setState(PickingListState.SHIPPED); 128 | setShippedOn(clock.instant()); 129 | } 130 | 131 | @NonNull 132 | public PickingListItem addItem(@NonNull ProductId productId, String description, int qty) { 133 | var item = new PickingListItem(productId, description, qty); 134 | items.add(item); 135 | return item; 136 | } 137 | 138 | @NonNull 139 | @JsonProperty("items") 140 | public Stream items() { 141 | return items.stream(); 142 | } 143 | 144 | @Override 145 | public Long version() { 146 | return version; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/domain/PickingListId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.domain; 2 | 3 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 4 | 5 | public class PickingListId extends DomainObjectId { 6 | public PickingListId(String uuid) { 7 | super(uuid); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/domain/PickingListItem.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.domain; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.base.ValueObject; 5 | 6 | import javax.annotation.Nonnull; 7 | import javax.persistence.Column; 8 | import javax.persistence.Embeddable; 9 | import java.util.Objects; 10 | 11 | @Embeddable 12 | public class PickingListItem implements ValueObject { 13 | 14 | @Column(name = "product_id", nullable = false) 15 | private ProductId productId; 16 | @Column(name = "description", nullable = false) 17 | private String description; 18 | @Column(name = "qty", nullable = false) 19 | private int quantity; 20 | 21 | @SuppressWarnings("unused") // Used by JPA only 22 | private PickingListItem() { 23 | } 24 | 25 | PickingListItem(@Nonnull ProductId productId, @Nonnull String description, int quantity) { 26 | this.productId = Objects.requireNonNull(productId, "productId must not be null"); 27 | this.description = Objects.requireNonNull(description, "description must not be null"); 28 | this.quantity = quantity; 29 | } 30 | 31 | @Nonnull 32 | @JsonProperty("productId") 33 | public ProductId productId() { 34 | return productId; 35 | } 36 | 37 | @Nonnull 38 | @JsonProperty("description") 39 | public String description() { 40 | return description; 41 | } 42 | 43 | @JsonProperty("qty") 44 | public int quantity() { 45 | return quantity; 46 | } 47 | 48 | @Override 49 | public boolean equals(Object o) { 50 | if (this == o) return true; 51 | if (o == null || getClass() != o.getClass()) return false; 52 | PickingListItem that = (PickingListItem) o; 53 | return quantity == that.quantity && 54 | Objects.equals(productId, that.productId) && 55 | Objects.equals(description, that.description); 56 | } 57 | 58 | @Override 59 | public int hashCode() { 60 | return Objects.hash(productId, description, quantity); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/domain/PickingListRepository.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.domain; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | public interface PickingListRepository extends JpaRepository { 6 | } 7 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/domain/PickingListState.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.domain; 2 | 3 | public enum PickingListState { 4 | WAITING, ASSEMBLY, SHIPPED 5 | } 6 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/domain/ProductId.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.domain; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import net.pkhapps.ddd.shared.domain.base.DomainObjectId; 5 | 6 | public class ProductId extends DomainObjectId { 7 | @JsonCreator 8 | public ProductId(String uuid) { 9 | super(uuid); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/domain/converter/OrderIdConverter.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.domain.converter; 2 | 3 | import net.pkhapps.ddd.shipping.domain.OrderId; 4 | 5 | import javax.persistence.AttributeConverter; 6 | import javax.persistence.Converter; 7 | 8 | @Converter(autoApply = true) 9 | public class OrderIdConverter implements AttributeConverter { 10 | 11 | @Override 12 | public String convertToDatabaseColumn(OrderId attribute) { 13 | return attribute == null ? null : attribute.toUUID(); 14 | } 15 | 16 | @Override 17 | public OrderId convertToEntityAttribute(String dbData) { 18 | return dbData == null ? null : new OrderId(dbData); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/domain/converter/ProductIdConverter.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.domain.converter; 2 | 3 | import net.pkhapps.ddd.shipping.domain.ProductId; 4 | 5 | import javax.persistence.AttributeConverter; 6 | import javax.persistence.Converter; 7 | 8 | @Converter(autoApply = true) 9 | public class ProductIdConverter implements AttributeConverter { 10 | @Override 11 | public String convertToDatabaseColumn(ProductId attribute) { 12 | return attribute == null ? null : attribute.toUUID(); 13 | } 14 | 15 | @Override 16 | public ProductId convertToEntityAttribute(String dbData) { 17 | return dbData == null ? null : new ProductId(dbData); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/infra/hibernate/PickingListIdType.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.infra.hibernate; 2 | 3 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdCustomType; 4 | import net.pkhapps.ddd.shared.infra.hibernate.DomainObjectIdTypeDescriptor; 5 | import net.pkhapps.ddd.shipping.domain.PickingListId; 6 | 7 | public class PickingListIdType extends DomainObjectIdCustomType { 8 | private static final DomainObjectIdTypeDescriptor TYPE_DESCRIPTOR = new DomainObjectIdTypeDescriptor<>( 9 | PickingListId.class, PickingListId::new); 10 | 11 | public PickingListIdType() { 12 | super(TYPE_DESCRIPTOR); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/infra/hibernate/package-info.java: -------------------------------------------------------------------------------- 1 | @TypeDef(defaultForType = PickingListId.class, typeClass = PickingListIdType.class) 2 | package net.pkhapps.ddd.shipping.infra.hibernate; 3 | 4 | import net.pkhapps.ddd.shipping.domain.PickingListId; 5 | import org.hibernate.annotations.TypeDef; -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/integration/OrderCreatedEvent.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.integration; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import net.pkhapps.ddd.shared.domain.base.DomainEvent; 6 | import net.pkhapps.ddd.shipping.domain.OrderId; 7 | import org.springframework.lang.NonNull; 8 | 9 | import java.time.Instant; 10 | import java.util.Objects; 11 | 12 | public class OrderCreatedEvent implements DomainEvent { 13 | 14 | @JsonProperty("orderId") 15 | private final OrderId orderId; 16 | @JsonProperty("occurredOn") 17 | private final Instant occurredOn; 18 | 19 | @JsonCreator 20 | public OrderCreatedEvent(@JsonProperty("orderId") @NonNull OrderId orderId, 21 | @JsonProperty("occurredOn") @NonNull Instant occurredOn) { 22 | this.orderId = Objects.requireNonNull(orderId, "orderId must not be null"); 23 | this.occurredOn = Objects.requireNonNull(occurredOn, "occurredOn must not be null"); 24 | } 25 | 26 | @NonNull 27 | public OrderId orderId() { 28 | return orderId; 29 | } 30 | 31 | @Override 32 | @NonNull 33 | public Instant occurredOn() { 34 | return occurredOn; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/integration/OrderCreatedEventTranslator.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.integration; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import net.pkhapps.ddd.shared.domain.base.DomainEvent; 5 | import net.pkhapps.ddd.shared.infra.eventlog.RemoteEventTranslator; 6 | import net.pkhapps.ddd.shared.infra.eventlog.StoredDomainEvent; 7 | import org.springframework.stereotype.Service; 8 | 9 | import javax.annotation.Nonnull; 10 | import java.util.Optional; 11 | 12 | @Service 13 | class OrderCreatedEventTranslator implements RemoteEventTranslator { 14 | 15 | private final ObjectMapper objectMapper; 16 | 17 | OrderCreatedEventTranslator(ObjectMapper objectMapper) { 18 | this.objectMapper = objectMapper; 19 | } 20 | 21 | @Override 22 | public boolean supports(@Nonnull StoredDomainEvent remoteEvent) { 23 | return remoteEvent.domainEventClassName().equals("net.pkhapps.ddd.orders.domain.model.event.OrderCreated"); 24 | } 25 | 26 | @Override 27 | @Nonnull 28 | public Optional translate(@Nonnull StoredDomainEvent remoteEvent) { 29 | return Optional.of(remoteEvent.toDomainEvent(objectMapper, OrderCreatedEvent.class)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/rest/client/Order.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.rest.client; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shipping.domain.OrderId; 5 | 6 | import java.util.List; 7 | import java.util.stream.Stream; 8 | 9 | public class Order { 10 | 11 | @JsonProperty("id") 12 | private OrderId orderId; 13 | @JsonProperty("state") 14 | private OrderState state; 15 | @JsonProperty("shippingAddress") 16 | private RecipientAddress shippingAddress; 17 | @JsonProperty("items") 18 | private List items; 19 | 20 | Order() { 21 | } 22 | 23 | public OrderId orderId() { 24 | return orderId; 25 | } 26 | 27 | public OrderState state() { 28 | return state; 29 | } 30 | 31 | public RecipientAddress shippingAddress() { 32 | return shippingAddress; 33 | } 34 | 35 | public Stream items() { 36 | return items.stream(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/rest/client/OrderCatalogClient.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.rest.client; 2 | 3 | import net.pkhapps.ddd.shipping.domain.OrderId; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.http.ResponseEntity; 8 | import org.springframework.http.client.SimpleClientHttpRequestFactory; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.web.client.RestTemplate; 11 | import org.springframework.web.util.UriComponentsBuilder; 12 | 13 | import javax.annotation.Nonnull; 14 | import java.util.Optional; 15 | 16 | @Service 17 | public class OrderCatalogClient { 18 | 19 | private static final Logger LOGGER = LoggerFactory.getLogger(OrderCatalogClient.class); 20 | 21 | private final RestTemplate restTemplate; 22 | private final String serverUrl; 23 | 24 | OrderCatalogClient(@Value("${app.orders.url}") String serverUrl, 25 | @Value("${app.orders.connect-timeout-ms}") int connectTimeout, 26 | @Value("${app.orders.read-timeout-ms}") int readTimeout) { 27 | this.serverUrl = serverUrl; 28 | restTemplate = new RestTemplate(); 29 | var requestFactory = new SimpleClientHttpRequestFactory(); 30 | // Never ever do a remote call without a finite timeout! 31 | requestFactory.setConnectTimeout(connectTimeout); 32 | requestFactory.setReadTimeout(readTimeout); 33 | restTemplate.setRequestFactory(requestFactory); 34 | } 35 | 36 | private UriComponentsBuilder uri() { 37 | return UriComponentsBuilder.fromUriString(serverUrl); 38 | } 39 | 40 | @Nonnull 41 | public Optional findById(@Nonnull OrderId orderId) { 42 | try { 43 | ResponseEntity response = restTemplate.getForEntity(uri().path("/api/orders/{id}").build(orderId.toUUID()), Order.class); 44 | return Optional.ofNullable(response.getBody()); 45 | } catch (Exception ex) { 46 | LOGGER.error("Error retrieving order " + orderId, ex); 47 | return Optional.empty(); 48 | } 49 | } 50 | 51 | public void startProcessing(@Nonnull OrderId orderId) { 52 | restTemplate.put(uri().path("/api/orders/{id}/startProcessing").build(orderId.toUUID()), null); 53 | } 54 | 55 | public void finishProcessing(@Nonnull OrderId orderId) { 56 | restTemplate.put(uri().path("/api/orders/{id}/finishProcessing").build(orderId.toUUID()), null); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/rest/client/OrderItem.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.rest.client; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shipping.domain.ProductId; 5 | 6 | public class OrderItem { 7 | 8 | @JsonProperty("productId") 9 | private ProductId productId; 10 | @JsonProperty("description") 11 | private String description; 12 | @JsonProperty("qty") 13 | private int quantity; 14 | 15 | OrderItem() { 16 | } 17 | 18 | public ProductId productId() { 19 | return productId; 20 | } 21 | 22 | public String description() { 23 | return description; 24 | } 25 | 26 | public int quantity() { 27 | return quantity; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/rest/client/OrderState.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.rest.client; 2 | 3 | public enum OrderState { 4 | RECEIVED, PROCESSING, CANCELLED, PROCESSED 5 | } 6 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/rest/client/RecipientAddress.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.rest.client; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import net.pkhapps.ddd.shared.domain.geo.CityName; 5 | import net.pkhapps.ddd.shared.domain.geo.Country; 6 | import net.pkhapps.ddd.shared.domain.geo.PostalCode; 7 | 8 | public class RecipientAddress { 9 | 10 | @JsonProperty("name") 11 | private String name; 12 | @JsonProperty("address1") 13 | private String address1; 14 | @JsonProperty("address2") 15 | private String address2; 16 | @JsonProperty("postalCode") 17 | private PostalCode postalCode; 18 | @JsonProperty("city") 19 | private CityName cityName; 20 | @JsonProperty("country") 21 | private Country country; 22 | 23 | RecipientAddress() { 24 | } 25 | 26 | public String name() { 27 | return name; 28 | } 29 | 30 | public String address1() { 31 | return address1; 32 | } 33 | 34 | public String address2() { 35 | return address2; 36 | } 37 | 38 | public PostalCode postalCode() { 39 | return postalCode; 40 | } 41 | 42 | public CityName cityName() { 43 | return cityName; 44 | } 45 | 46 | public Country country() { 47 | return country; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/rest/controller/ShippingServiceController.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.rest.controller; 2 | 3 | import net.pkhapps.ddd.shipping.application.ShippingService; 4 | import net.pkhapps.ddd.shipping.domain.PickingList; 5 | import net.pkhapps.ddd.shipping.domain.PickingListId; 6 | import org.springframework.web.bind.annotation.*; 7 | 8 | import java.util.List; 9 | 10 | @RestController 11 | @RequestMapping("/api/shipping") 12 | class ShippingServiceController { 13 | 14 | private final ShippingService shippingService; 15 | 16 | ShippingServiceController(ShippingService shippingService) { 17 | this.shippingService = shippingService; 18 | } 19 | 20 | @GetMapping("/pickingLists") 21 | public List findAllPickingLists() { 22 | return shippingService.findPickingLists(); 23 | } 24 | 25 | @PutMapping("/pickingLists/{id}/startAssembly") 26 | public void startAssembly(@PathVariable("id") String pickingListId) { 27 | shippingService.startAssembly(new PickingListId(pickingListId)); 28 | } 29 | 30 | @PutMapping("/pickingLists/{id}/ship") 31 | public void ship(@PathVariable("id") String pickingListId) { 32 | shippingService.ship(new PickingListId(pickingListId)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/ui/PickingListBrowserView.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.ui; 2 | 3 | import com.vaadin.flow.component.Html; 4 | import com.vaadin.flow.component.button.Button; 5 | import com.vaadin.flow.component.grid.Grid; 6 | import com.vaadin.flow.component.orderedlayout.VerticalLayout; 7 | import com.vaadin.flow.data.renderer.ComponentRenderer; 8 | import com.vaadin.flow.router.PageTitle; 9 | import com.vaadin.flow.router.Route; 10 | import net.pkhapps.ddd.shipping.application.ShippingService; 11 | import net.pkhapps.ddd.shipping.domain.PickingList; 12 | import net.pkhapps.ddd.shipping.domain.PickingListId; 13 | 14 | @Route("") 15 | @PageTitle("Picking List Browser") 16 | public class PickingListBrowserView extends VerticalLayout { 17 | 18 | private final ShippingService shippingService; 19 | private final Grid pickingListGrid; 20 | 21 | public PickingListBrowserView(ShippingService shippingService) { 22 | this.shippingService = shippingService; 23 | 24 | setSizeFull(); 25 | var title = new Html("

Picking Lists

"); 26 | add(title); 27 | 28 | pickingListGrid = new Grid<>(); 29 | pickingListGrid.addColumn(PickingList::createdOn).setHeader("Created on"); 30 | pickingListGrid.addColumn(PickingList::state).setHeader("State"); 31 | pickingListGrid.addColumn(PickingList::shippedOn).setHeader("Shipped on"); 32 | pickingListGrid.addColumn(new ComponentRenderer<>(item -> new Button("Details", evt -> showPickingList(item.id())))); 33 | add(pickingListGrid); 34 | 35 | var refresh = new Button("Refresh", evt -> refresh()); 36 | add(refresh); 37 | 38 | refresh(); 39 | } 40 | 41 | private void refresh() { 42 | pickingListGrid.setItems(shippingService.findPickingLists()); 43 | } 44 | 45 | private void showPickingList(PickingListId pickingListId) { 46 | getUI().ifPresent(ui -> ui.navigate(PickingListDetailsView.class, pickingListId.toUUID())); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /shipping/src/main/java/net/pkhapps/ddd/shipping/ui/PickingListDetailsView.java: -------------------------------------------------------------------------------- 1 | package net.pkhapps.ddd.shipping.ui; 2 | 3 | import com.vaadin.flow.component.Html; 4 | import com.vaadin.flow.component.Text; 5 | import com.vaadin.flow.component.button.Button; 6 | import com.vaadin.flow.component.formlayout.FormLayout; 7 | import com.vaadin.flow.component.grid.Grid; 8 | import com.vaadin.flow.component.orderedlayout.HorizontalLayout; 9 | import com.vaadin.flow.component.orderedlayout.VerticalLayout; 10 | import com.vaadin.flow.component.textfield.TextField; 11 | import com.vaadin.flow.router.*; 12 | import net.pkhapps.ddd.shipping.application.ShippingService; 13 | import net.pkhapps.ddd.shipping.domain.PickingList; 14 | import net.pkhapps.ddd.shipping.domain.PickingListId; 15 | import net.pkhapps.ddd.shipping.domain.PickingListItem; 16 | import net.pkhapps.ddd.shipping.domain.PickingListState; 17 | 18 | import java.util.Optional; 19 | 20 | @Route("details") 21 | @PageTitle("Picking List Details") 22 | public class PickingListDetailsView extends VerticalLayout implements HasUrlParameter { 23 | 24 | private final ShippingService shippingService; 25 | 26 | public PickingListDetailsView(ShippingService shippingService) { 27 | this.shippingService = shippingService; 28 | setSizeFull(); 29 | } 30 | 31 | @Override 32 | public void setParameter(BeforeEvent event, @OptionalParameter String parameter) { 33 | Optional pickingList = Optional.ofNullable(parameter).map(PickingListId::new).flatMap(shippingService::findById); 34 | if (pickingList.isPresent()) { 35 | showPickingList(pickingList.get()); 36 | } else { 37 | showNoSuchPickingList(); 38 | } 39 | } 40 | 41 | private void showPickingList(PickingList pickingList) { 42 | var title = new Html("

Picking List Details

"); 43 | add(title); 44 | var header = new FormLayout(); 45 | header.addFormItem(createReadOnlyTextField(pickingList.createdOn().toString()), "Created on"); 46 | header.addFormItem(createReadOnlyTextField(pickingList.state().name()), "State"); 47 | header.addFormItem(createReadOnlyTextField(pickingList.recipientName()), "Recipient"); 48 | header.addFormItem(createReadOnlyTextField(pickingList.recipientAddress().toString()), "Address"); 49 | add(header); 50 | 51 | var items = new Grid(); 52 | items.addColumn(PickingListItem::description).setHeader("Description"); 53 | items.addColumn(PickingListItem::quantity).setHeader("Qty"); 54 | items.setItems(pickingList.items()); 55 | add(items); 56 | 57 | var assemble = new Button("Assemble", evt -> assemble(pickingList.id())); 58 | assemble.setEnabled(pickingList.state() == PickingListState.WAITING); 59 | var ship = new Button("Ship", evt -> ship(pickingList.id())); 60 | ship.setEnabled(pickingList.state() == PickingListState.ASSEMBLY); 61 | 62 | add(new HorizontalLayout(assemble, ship)); 63 | } 64 | 65 | private void assemble(PickingListId id) { 66 | shippingService.startAssembly(id); 67 | getUI().ifPresent(ui -> ui.getPage().reload()); 68 | } 69 | 70 | private void ship(PickingListId id) { 71 | shippingService.ship(id); 72 | getUI().ifPresent(ui -> ui.getPage().reload()); 73 | } 74 | 75 | private TextField createReadOnlyTextField(String value) { 76 | var textField = new TextField(); 77 | textField.setReadOnly(true); 78 | textField.setValue(value); 79 | textField.setWidth("350px"); 80 | return textField; 81 | } 82 | 83 | private void showNoSuchPickingList() { 84 | add(new Text("The picking list does not exist.")); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /shipping/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.h2.console.enabled=true 2 | spring.h2.console.path=/h2-console 3 | spring.datasource.url=jdbc:h2:mem:shipping;DB_CLOSE_DELAY=-1. 4 | server.port=9003 5 | 6 | app.orders.url=http://localhost:9002 7 | app.orders.connect-timeout-ms=1000 8 | app.orders.read-timeout-ms=5000 9 | logging.level.net.pkhapps.ddd=debug 10 | --------------------------------------------------------------------------------