Benjamin Nothdurft
Software Craftsman, DevOps, @jenadevs @jugthde Founder/Speaker, Traveller & Cyclist – focusing on Continuous Delivery Pipelines, Automation, Docker, Selenium, Java
Benjamin Nothdurft
@DataDuke
A lot of the concepts and ideas are given by the books of Eric Evans and Vaughn Vernon hence I have to thank them very much for all the inspiration!
Top-Down View
Event Storming
System Architecture
Context Map Patterns
CQRS Deep-Dive Example
More CQRS Hands-On
Questions
Exe / Jar / Zip
Objects
Classes
OOP
Design Patterns
Modules
Layers
Project
Service
Exe / Jar / Zip
Objects
Classes
OOP
Design Patterns
Modules
Layers
Project
Service
Service
Service
Sub-Domain
Sub-Domain
Domain
Domain-Driven-Design
Exe / Jar / Zip
Objects
Classes
OOP
Design Patterns
Modules
Layers
Project
issues/hot spots, chances
external systems, read models, policies
users/actors, timers, loops
product added to cart
domain event (past tense)
orange sticky note
relevant for domain experts
command (present tense)
blue sticky note
triggers a domain event
product add to cart
aggregate/entity
yellow sticky note
data that is interacted with
product
find borders where a domain model has a different meaning
domain events flow between different models
bounded context/sub-domain
red sticky note
core name that is relevant/valid
checkout
narrative/stories/user journey
line(s)
multiple storytellings
common language for everyone
event
command
external system
policy
user
read model
user
command
add new colours/sticky notes: e.g. policies
challenging value (discover new opportunities, inconsistencies)
reveal pain points
enable cross-perspective conversation
provider-ui
storefront-ui
merchant-ui
Frontend
api proxy
tenant
auth
Core
business unit
site
shop
Shop Admin
Product
product
. . .
. . .
. . .
. . .
Each sub-domain is implemented as vertical:
Ubiquitous Language
Bounded Context
Domain Model
A domain model is to a bounded context
what classes are to objects
Context Map
Call Flow
Model Flow
Call Flow
Model Flow
Downstream System
Upstream System
Model Flow
Upstream Patterns
Open Host Service
Event Publisher
Downstream Patterns
Customer / Supplier
Conformist
Anticorruption Layer
In-Between Patterns
Shared Kernel
Published Language
Seperate Ways
Upstream System
Down-stream System
Model Flow
Upstream Patterns
Open Host Service
Event Publisher
Downstream Patterns
Customer / Supplier
Conformist
Anticorruption Layer
In-Between Patterns
Shared Kernel
Published Language
Seperate Ways
Upstream System
Down-stream System
Bounded Context offers defined set of services that expose functionality
Any downstream system can implement their own integration
Especially useful with many systems that want to integrate
SOAP/REST
Upstream System
Down-stream System
@RepositoryRestController
@RequiredArgsConstructor
public class SiteCustomController {
@NonNull
private final SiteService siteService;
@NonNull
private final EntityLinks entityLinks;
@PostMapping("sites")
public ResponseEntity<Resource<Site>> create(@Valid @RequestBody SiteWithAdminUser body) {
SiteCreateResource siteBody = body.getSite();
Site transientSite = Site.builder()
.active(siteBody.isActive())
.hostname(siteBody.getHostname())
.locale(siteBody.getLocale())
.build();
Site site = siteService.create(transientSite, body.getAdminUser());
return ResponseEntity
.created(entityLinks.linkForSingleResource(Site.class, site.getId()).toUri())
.body(new Resource<>(site));
}
@DeleteMapping("sites/{siteId}")
public ResponseEntity<Void> delete(@PathVariable("siteId") Optional<Site> site) {
site.ifPresent(siteService::delete);
return ResponseEntity.noContent().build();
}
}
private const val SHOP_IMAGE_LOCATION_MAPPING = "/{imageId}"
@RepositoryRestController
@RequiredArgsConstructor
@RequestMapping(value = ["/shop/images"], produces = [HAL_JSON_VALUE, APPLICATION_JSON_VALUE])
class ImageController(
private val tenantAccessor: TenantAccessor,
private val imageMessagePublisher: ImageMessagePublisher,
private val imageRepository: ImageRepository,
private val imageService: ImageService,
private val pagedResourcesAssembler: PagedResourcesAssembler<Image>
) {
@PostMapping
fun createImage(@Valid @RequestBody imageBody: Image): ResponseEntity<Void> {
imageBody.tenantId = tenantAccessor.accessTenant().id
val image = imageService.addImage(imageBody)
return withMdc(image).executeAndReturn {
imageMessagePublisher.publishCreatedEvent(image)
val location = UriComponentsBuilder
.fromUriString(linkTo(methodOn(ImageController::class.java)
.createImage(imageBody)).toString() + SHOP_IMAGE_LOCATION_MAPPING)
.buildAndExpand(image.id)
.toUri()
ResponseEntity.created(location).build()
}
}
@DeleteMapping(/{imageId})
fun deleteImage(@PathVariable imageId: UUID): ResponseEntity<Void> = imageRepository.findOne(imageId)?.let {
withMdc(it).executeAndReturn {
imageService.delete(it)
ResponseEntity<Void>(NO_CONTENT)
}
} ?: ResponseEntity(NOT_FOUND)
}
Bounded Context published Domain Events
Example: Messaging or Feeds
Other bounded context may subscribe to those events to react upon them.
Message
Upstream System
Down-stream System
@Component
@Slf4j
@RequiredArgsConstructor
public class MailMessagePublisher {
private final DomainMessagePublisher domainMessagePublisher;
private final DomainMessageFactory domainMessageFactory;
public void publishMailSentEvent(ClientResponse response) {
publishMessage("mailgateway.mail.sent.event", response);
}
private void publishMessage(String messageType, ClientResponse response) {
MailSendEventMessage mail = MailSendEventMessage.builder()
.messageId(response.getId())
.status(QUEUED)
.build();
DomainMessage<MailSendEventMessage> message = domainMessageFactory.create(messageType, mail);
domainMessagePublisher.publish(message);
log.info("Published message of type {}", message.getMessageType());
}
@AllArgsConstructor
@Builder
@Getter
@JsonTypeName("mail-sent-event")
static class MailSendEventMessage implements DomainMessagePayload {
private String messageId;
private MailStatus status;
}
}
@Slf4j
@MessagePublisher
@RequiredArgsConstructor
class ImageMessagePublisher(
private val domainMessageFactory: EntityDomainMessageFactory,
private val domainMessagePublisher: DomainMessagePublisher,
private val storageApiUriBuilder: StorageApiUriBuilder
) {
val log: Logger = LoggerFactory.getLogger(javaClass)
fun publishCreatedEvent(image: Image) {
publishMessageWithoutChangeSet(image, "shop.image.created.event")
}
fun publishDeletedEvent(image: Image) {
publishMessageWithoutChangeSet(image, "shop.image.deleted.event")
}
private fun publishMessageWithoutChangeSet(image: Image, messageType: String) {
val message = domainMessageFactory
.prepareDomainMessageForType(messageType)
.withEntity(image)
.withPayloadType("image")
.withCurrentTenant()
.withoutChangeSet()
.withAdditionalProperty("dataUri", toRelativeUri(image))
.build()
log.info("publishing message {}", message)
domainMessagePublisher.publish(message)
}
private fun toRelativeUri(image: Image?): String? =
image?.let { storageApiUriBuilder.getRelativeImageDownloadUri(it.dataUri).expand().toString() }
}
Model Flow
Upstream Patterns
Open Host Service
Event Publisher
Downstream Patterns
Customer / Supplier
Conformist
Anticorruption Layer
In-Between Patterns
Shared Kernel
Published Language
Seperate Ways
Upstream System
Down-stream System
Two teams share a customer / supplier relation
The downstream team is considered to be the customer
Veto rights may be applicable
Veto
Upstream System
Down-stream System
@Test
@PactVerification()
public void givenGet_whenSendRequest_shouldReturn200WithProperHeaderAndBody() {
// when
ResponseEntity<String> response = new RestTemplate()
.getForEntity(mockProvider.getUrl() + "/pact", String.class);
// then
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getHeaders().get("Content-Type").contains("application/json")).isTrue();
assertThat(response.getBody()).contains("condition", "true", "name", "tom");
}
Consumer-Driven Contracts with Pact
The downstream conforms to the model of the upstream team
No translation of models
No veto rights
Upstream System
Down-stream System
@RunWith(SpringRunner.class)
@ShopApplicationTest
public class SiteEventContractTest {
@Autowired
private MessageStubTrigger messageStubTrigger;
@MockBean
private SiteEventSubscriber siteEventSubscriber;
@Captor
private ArgumentCaptor<DomainMessage<SitePayload>> siteEventCaptor;
@Captor
private ArgumentCaptor<DomainMessage<SiteFeaturesPayload>> siteFeaturesEventCaptor;
@Test
public void should_process_site_created_event() {
messageStubTrigger.trigger("site.site.created.event");
thenVerify(siteEventSubscriber).should().consumeSiteCreatedEvent(siteEventCaptor.capture());
then(siteEventCaptor.getValue().getTenantId()).isNotNull();
then(siteEventCaptor.getValue().getPayload().getLocale()).isNotNull();
}
@Test
public void should_process_site_deleted_event() {
messageStubTrigger.trigger("site.site.deleted.event");
thenVerify(siteEventSubscriber).should().consumeSiteDeletedEvent(siteEventCaptor.capture());
then(siteEventCaptor.getValue().getTenantId()).isNotNull();
}
@Test
public void should_process_site_features_updated_event() {
messageStubTrigger.trigger("site.site-features.updated.event");
thenVerify(siteEventSubscriber).should().consumeSiteFeaturesUpdatedEvent(siteFeaturesEventCaptor.capture());
then(siteFeaturesEventCaptor.getValue().getTenantId()).isNotNull();
then(siteFeaturesEventCaptor.getValue().getPayload().getFeatures()).isNotEmpty();
}
}
A layer that isolates a client model from the provided model by translation
external vs. internal model
Upstream System
Down-stream System
public enum GoogleAvailability {
@JsonProperty(value = "in stock")
IN_STOCK("in stock"),
@JsonProperty(value = "out of stock")
OUT_OF_STOCK("out of stock"),
@JsonProperty(value = "preorder")
PRE_ORDER("preorder");
private String name;
GoogleAvailability(String name) {
this.name = name;
}
public static GoogleAvailability fromAvailabilityState(AvailabilityState availability) {
if (availability == null
|| availability.equals(AvailabilityState.IN_STOCK)
|| availability.equals(AvailabilityState.LOW_STOCK)) {
return IN_STOCK;
} else if (availability.equals(AvailabilityState.OUT_OF_STOCK)
|| availability.equals(AvailabilityState.NOT_AVAILABLE)) {
return OUT_OF_STOCK;
} else {
return PRE_ORDER;
}
}
}
Model Flow
Upstream Patterns
Open Host Service
Event Publisher
Downstream Patterns
Customer / Supplier
Conformist
Anticorruption Layer
In-Between Patterns
Shared Kernel
Published Language
Seperate Ways
Upstream System
Down-stream System
Two team share a (subset of the) domain model
can be shared lib or a database
Upstream System
Down-stream System
public class DomainMessage<T extends DomainMessagePayload> {
private String messageType;
private LocalDateTime timestamp;
private Integer tenantId;
@JsonInclude(NON_EMPTY)
private Collection<DiffItem> changeSet = emptyList();
private T payload;
private DomainMessage() {
// needed by Jackson
}
@NotNull
public LocalDateTime getTimestamp() {
return timestamp;
}
@Nullable
public Integer getTenantId() {
return tenantId;
}
@NotNull
public String getMessageType() {
return messageType;
}
@NotNull
public Collection<DiffItem> getChangeSet() {
return changeSet;
}
@NotNull
public T getPayload() {
return payload;
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this) //
.add("messageType", messageType) //
.add("timestamp", timestamp) //
.add("tenantId", tenantId) //
.add("changeSet", changeSet) //
.add("payload", payload) //
.toString();
}
@NotNull
public static <T extends DomainMessagePayload> DomainMessageBuilder<T> builder(@NotNull String messageType) {
return new DomainMessageBuilder<>(messageType);
}
@JsonIgnore
public boolean isChangeSetRelevantForPayload() {
Set<String> possiblePathNames = Arrays.stream(BeanUtils.getPropertyDescriptors(payload.getClass()))
.map(PropertyDescriptor::getName)
.map(property -> "/" + property)
.collect(toSet());
return changeSet.stream()
.map(DiffItem::getPath)
.anyMatch(possiblePathNames::contains);
}
public static class DomainMessageBuilder<T extends DomainMessagePayload> {
private final String messageType;
private Integer tenantId;
private LocalDateTime timestamp = LocalDateTime.now();
private Collection<DiffItem> changeSet = emptyList();
@NotNull
private DomainMessageBuilder(@NotNull String messageType) {
this.messageType = checkNotNull(messageType);
}
@NotNull
public DomainMessageBuilder<T> timestamp(@NotNull LocalDateTime timestamp) {
this.timestamp = checkNotNull(timestamp);
return this;
}
@NotNull
public DomainMessageBuilder<T> tenantId(@Nullable Integer tenantId) {
this.tenantId = tenantId;
return this;
}
@NotNull
public DomainMessageBuilder<T> changeSet(@Nullable Collection<DiffItem> changeSet) {
this.changeSet = changeSet;
return this;
}
@NotNull
public DomainMessage<T> build(@NotNull T payload) {
requireJsonTypeInformation(checkNotNull(payload).getClass());
final DomainMessage<T> domainMessage = new DomainMessage<>();
domainMessage.messageType = this.messageType;
domainMessage.timestamp = this.timestamp;
domainMessage.tenantId = this.tenantId;
domainMessage.payload = payload;
domainMessage.changeSet = changeSet;
return domainMessage;
}
}
}
dependencyManagement {
imports {
mavenBom "com.example.demo:shared-parent:1.2.3"
}
}
dependencies {
compile ("com.example.demo:shared-message")
}
Similar to Open Host Service
A common language between bounded context is modeled
Upstream System
Down-stream System
@NoArgsConstructor(access = PRIVATE)
@AllArgsConstructor(access = PRIVATE)
@EqualsAndHashCode(callSuper = false)
@Getter
@Builder
@Embeddable
public class Identifier implements Serializable {
private static final long serialVersionUID = -123242542L;
@Enumerated(STRING)
@NotNull
@Column(name = "TYPE", length = 10, nullable = false)
private IdentifierType type;
@NotNull
@Column(name = "VALUE", length = 255, nullable = false)
private String value;
}
public enum IdentifierType {
EAN,
UPC,
ISBN,
MPN
}
No connection between bounded contexts exists
Teams can find their own solution for their domain
Upstream System
Down-stream System
Model Flow
Upstream Patterns
Open Host Service
Event Publisher
Downstream Patterns
Customer / Supplier
Conformist
Anticorruption Layer
In-Between Patterns
Shared Kernel
Published Language
Seperate Ways
Upstream System
Down-stream System
provider-ui
storefront-ui
merchant-ui
Frontend
api proxy
tenant
auth
business unit
site
shop
Shop Admin
Product
product
. . .
. . .
. . .
. . .
Core
Product
Frontend
merchant-ui
storage
merchant makes changes in the frontend
service forward change to the model
model executes validation and consequential logic
model updates database
product
service
model
model is read from database
service provides information for the frontend clients
changes are reflected in the UI
Product
Frontend
merchant-ui
storage
product
service
model
no distinction between (only) reading data and writing data
service can only be scaled as a whole unit
bound to one database technology
Limitations
Opportunities
create several service interfaces to access the domain model service in a different fashion
storefront-ui
Product
Frontend
merchant-ui
storage
product
service
command
bound to one database technology
Drawbacks
storefront-ui
query
service
How it works
Product
Frontend
merchant-ui
storage
p-management
service
command
decoupled deployment
leverage on purpose-specific db technology for each req.
Benefits
storefront-ui
query
service
How it works additonally
storage
product-view
bus
Building Blocks in DDD
Building Blocks in DDD
product
product-image
product-attribute
created updated deleted
created updated deleted
created updated deleted
product
product-image
product-attribute
@Slf4j
@RepositoryRestController
@RequiredArgsConstructor
public class ProductCreationController implements ApplicationEventPublisherAware {
private final ProductService productService;
private final SkuGenerator skuGenerator;
private final ProductValidatorChain productValidatorChain;
@Setter
private ApplicationEventPublisher applicationEventPublisher;
@RequestMapping(method = POST, value = "/products")
@ResponseBody
public ResponseEntity<Resource<Product>> createNewProduct(@RequestBody Product product,
PersistentEntityResourceAssembler persistentEntityResourceAssembler
) {
Product createdProduct = createWithSkuHandling(product);
Resource<Product> productResource = new Resource<>(createdProduct);
productResource.add(persistentEntityResourceAssembler.getSelfLinkFor(createdProduct));
URI location = linkTo(ProductCreationController.class)
.slash("/products")
.slash(createdProduct.getId())
.toUri();
return ResponseEntity.created(location).body(productResource);
}
// ...
}
@Entity
@Table(name = "PRODUCT", uniqueConstraints = {@UniqueConstraint(name = "U_PRODUCT_TENANT_ID_SKU", columnNames = {"TENANT_ID", "SKU"})})
@Multitenant
@TenantDiscriminatorColumn(discriminatorType = INTEGER, columnDefinition = "INT NOT NULL")
@Getter
@Setter
@NoArgsConstructor(access = PRIVATE) // for JPA/Jackson
@AllowedMarkdownForProduct
public class Product extends AbstractEntity implements WithAttributes, WithCurrentLocale {
@NotEmpty
@Basic
@Column(name = "SKU", nullable = false, length = 255)
private String sku;
@Getter(NONE)
@Setter(NONE)
@ElementCollection(fetch = LAZY)
@CollectionTable(name = "PRODUCT_ATTRIBUTE", joinColumns = @JoinColumn(name = "PRODUCT_ID", nullable = false))
private Map<AttributeKey, Attribute> attrs = newHashMap();
@InlinedAttribute
public String getName() {
return getAttributes().withCurrentLocale(NAME, STRING);
}
@InlinedAttribute
public void setName(String value) {
getAttributes().withCurrentLocale(NAME, STRING, value);
}
}
@Access(FIELD)
@Embeddable
@Getter
@ToString
@AllArgsConstructor(access = PRIVATE)
@NoArgsConstructor(access = PRIVATE) // JPA
public class Price implements Serializable {
private static final long serialVersionUID = -1554812008438549239L;
public static final int DB_PRECISION = 17;
public static final int DB_SCALE = 5;
@NotNull @NonNull
@Enumerated(STRING)
private TaxModel taxModel;
@NotNull @NonNull
@Enumerated(STRING)
private CurrencyCode currency;
@NotNull @NonNull
@Range
@Basic
@JsonSerialize(using = BigDecimalStripTrailingZerosSerializer.class)
private BigDecimal amount;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private DerivedPrice derivedPrice;
@JsonCreator
public static Price of(TaxModel taxModel, String currency, BigDecimal amount) {
return new Price(taxModel, CurrencyCode.valueOf(currency), amount, null);
}
public static Price of(TaxModel taxModel, CurrencyCode currency, BigDecimal amount) {
return new Price(taxModel, currency, amount, null);
}
public static Price of(TaxModel taxModel, MonetaryAmount amount) {
CurrencyCode currency = extractCurrency(amount);
BigDecimal value = extractValue(amount);
return of(taxModel, currency, value);
}
public Price withDerivedPrice(MonetaryAmount amount, double taxRate) {
return new Price(this.taxModel, this.currency, this.amount,
new DerivedPrice(taxModel.opposite(), extractCurrency(amount), extractValue(amount), taxRate));
}
public MonetaryAmount toMoney() {
return Money.of(amount, currency.getCurrency().getCurrencyCode());
}
private static CurrencyCode extractCurrency(MonetaryAmount amount) {
return CurrencyCode.valueOf(amount.getCurrency().getCurrencyCode());
}
private static BigDecimal extractValue(MonetaryAmount amount) {
return amount.getNumber().numberValue(BigDecimal.class);
}
//help data rest in patch scenarios
@SuppressWarnings("squid:UnusedPrivateMethod")
private void setCurrency(String currency) {
this.currency = CurrencyCode.valueOf(currency);
}
}
@RepositoryRestResource
public interface ProductRepository extends EntityRepository<Product> {
@RestResource(exported = false)
@Query("select p from Product p")
Stream<Product> streamAll();
@RestResource(exported = false)
List<Product> findByRefPriceNull();
@RestResource(exported = false)
List<Product> findByLastModifiedAtBefore(@Param("lastModifiedAt") LocalDateTime lastModifiedAt);
@RestResource(rel = "find-by-sku", path = "find-by-sku")
Optional<Product> findBySku(@Param("sku") String sku);
@RestResource(exported = false)
@Query("Select distinct value(a) from Product p join p.attrs a " +
"where key(a).namespace = :namespace " +
"and key(a).name = :attributeName " +
"and value(a).attributeType = com.epages.entity.attributes.AttributeType.STRING " +
"and value(a).stringValue like concat(:query, '%')")
List<Attribute> findStringAttributeValuesByNameAndSearchQuery(@Param("namespace") String namespace,
@Param("attributeName") String attributeName,
@Param("query") String query, Pageable pageable);
@RestResource(exported = false)
@Query("Select count(p) from Product p join p.attrs a " +
"where key(a).namespace = :namespace " +
"and key(a).name = :name ")
int countByAttributeNamespaceAndAttributeName(@Param("namespace") String namespace,
@Param("name") String attributeName);
@RestResource(exported = false)
@Query("Select count(p) from Product p where p.sku = :sku")
int countBySku(@Param("sku") String sku);
}
@Component
@RepositoryEventHandler
@RequiredArgsConstructor
public class ProductRepositoryEventHandler {
@NonNull
private ResourceProcessor<Resource<Product>> productResourceProcessor;
@NonNull
private final ProductMessagePublisher productMessagePublisher;
@NonNull
private final ProductService productService;
@HandleAfterCreate
public void sendCreatedEvent(Product product) {
productMessagePublisher.publishProductCreatedEvent(product);
}
@HandleAfterSave
public void sendUpdatedEvent(Product product) {
productMessagePublisher.publishProductUpdatedEvent(product);
}
@HandleAfterLinkSave
public void onDefaultImageSavedEvent(Product product, Object image) {
productMessagePublisher.publishProductUpdatedEvent(product);
}
@HandleAfterLinkDelete
public void onChildEntityDeleted(Product product, Object child) {
if (child instanceof Image) {
// for DELETE on default-image
productMessagePublisher.publishProductUpdatedEvent(product);
}
}
@HandleBeforeDelete
public void deleteImagesAndAttachmentsBeforeProductDeletion(Product product){
productService.deleteAllImagesAndAttachments(product);
}
@HandleAfterDelete
public void sendDeletedEvent(Product product) {
productMessagePublisher.publishProductDeletedEvent(product);
}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductMessagePublisher {
private static final String DEFAULT_EXCHANGE = "";
private static final String PRODUCT_CREATED_EVENT_TYPE_NAME = "product.product.created.event";
private static final String PRODUCT_UPDATED_EVENT_TYPE_NAME = "product.product.updated.event";
private static final String PRODUCT_DELETED_EVENT_TYPE_NAME = "product.product.deleted.event";
@NonNull
private final EntityDomainMessageFactory entityMessageFactory;
@NonNull
private final DomainMessagePublisher domainMessagePublisher;
@NonNull
private final AmqpTemplate amqpTemplate;
@NonNull
private final StorageApiUriBuilder storageApiUriBuilder;
@NonNull
private ShopRefRepository shopRefRepository;
// ...
}
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductMessagePublisher {
// ...
public void publishProductCreatedEvent(Product product) {
DomainMessage<?> message = buildMessage(PRODUCT_CREATED_EVENT_TYPE_NAME, product, false);
publishMessage(message);
}
public void publishProductUpdatedEvent(Product product) {
boolean temporarilyExcludeChangeSetAsHotFix = false;
DomainMessage<?> message = buildMessage(PRODUCT_UPDATED_EVENT_TYPE_NAME, product, temporarilyExcludeChangeSetAsHotFix);
publishMessage(message);
}
public void publishProductDeletedEvent(Product product) {
DomainMessage<?> message = buildMessage(PRODUCT_DELETED_EVENT_TYPE_NAME, product, false);
publishMessage(message);
}
// ...
}
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductMessagePublisher {
// ...
private DomainMessage<?> buildMessage(String messageType, Product product, boolean includeChangeSet) {
Map<String, Object> additionalProperties = newLinkedHashMap();
if (product.getDefaultImage() != null) {
additionalProperties.put("defaultImageDataUri", toRelativeUri(product.getDefaultImage()));
}
ChangeSetStep changeSetStep = entityMessageFactory
.prepareDomainMessageForType(messageType)
.withEntityAndProjection(product, ProductMessagingProjection.class)
.withPayloadType("product")
.withCurrentTenant();
BuildStep buildStep = includeChangeSet ? changeSetStep.withChangeSet() : changeSetStep.withoutChangeSet();
return buildStep
.withStandardContext()
.withContextProperty("locale", product.getCurrentLocale())
.withContextProperty("shopTaxModel", shopRefRepository.getMandatoryCurrent().getTaxModel())
.withAdditionalProperties(additionalProperties)
.build();
}
}
public class DomainMessage<T extends DomainMessagePayload> {
private String messageType;
private LocalDateTime timestamp;
private Integer tenantId;
@JsonInclude(NON_EMPTY)
private Collection<DiffItem> changeSet = emptyList();
private T payload;
private DomainMessage() {
// needed by Jackson
}
@NotNull
public LocalDateTime getTimestamp() {
return timestamp;
}
@Nullable
public Integer getTenantId() {
return tenantId;
}
@NotNull
public String getMessageType() {
return messageType;
}
@NotNull
public Collection<DiffItem> getChangeSet() {
return changeSet;
}
@NotNull
public T getPayload() {
return payload;
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this) //
.add("messageType", messageType) //
.add("timestamp", timestamp) //
.add("tenantId", tenantId) //
.add("changeSet", changeSet) //
.add("payload", payload) //
.toString();
}
@NotNull
public static <T extends DomainMessagePayload> DomainMessageBuilder<T> builder(@NotNull String messageType) {
return new DomainMessageBuilder<>(messageType);
}
/**
* The message consumers might not be interested in all information send out by the message producer.
* Thus their payload will contain only a sub-set of the attributes send out. The intended usage of this
* method is on the consumer side. It provides a convenient way to check whether attributes of its payload are affected
* by the attributes in the change set.
*
* @return {@code true} if one of the elements in the change set is contained in the domain message payload,
* otherwise {@code false}.
*/
@JsonIgnore
public boolean isChangeSetRelevantForPayload() {
Set<String> possiblePathNames = Arrays.stream(BeanUtils.getPropertyDescriptors(payload.getClass()))
.map(PropertyDescriptor::getName)
.map(property -> "/" + property)
.collect(toSet());
return changeSet.stream()
.map(DiffItem::getPath)
.anyMatch(possiblePathNames::contains);
}
public static class DomainMessageBuilder<T extends DomainMessagePayload> {
private final String messageType;
private Integer tenantId;
private LocalDateTime timestamp = LocalDateTime.now();
private Collection<DiffItem> changeSet = emptyList();
@NotNull
private DomainMessageBuilder(@NotNull String messageType) {
this.messageType = checkNotNull(messageType);
}
@NotNull
public DomainMessageBuilder<T> timestamp(@NotNull LocalDateTime timestamp) {
this.timestamp = checkNotNull(timestamp);
return this;
}
@NotNull
public DomainMessageBuilder<T> tenantId(@Nullable Integer tenantId) {
this.tenantId = tenantId;
return this;
}
@NotNull
public DomainMessageBuilder<T> changeSet(@Nullable Collection<DiffItem> changeSet) {
this.changeSet = changeSet;
return this;
}
@NotNull
public DomainMessage<T> build(@NotNull T payload) {
requireJsonTypeInformation(checkNotNull(payload).getClass());
final DomainMessage<T> domainMessage = new DomainMessage<>();
domainMessage.messageType = this.messageType;
domainMessage.timestamp = this.timestamp;
domainMessage.tenantId = this.tenantId;
domainMessage.payload = payload;
domainMessage.changeSet = changeSet;
return domainMessage;
}
}
}
Product
Frontend
merchant-ui
storage
p-management
service
command
decoupled deployment
leverage on purpose-specific db technology for each req.
Benefits
storefront-ui
query
service
How it works additonally
storage
product-view
broker
{
"productId": "54EF2E41-FCA8-E0E5-9323-0A0C05E66C2E",
"name": "Customised headphone",
"visible": true,
"productVariationType": "regular",
"manufacturerProductNumber": "CU001",
"productLength": null,
"productWidth": null,
"productHeight": null,
"productVariationSelection": null,
"shortDescription": null,
"deliveryPeriod": "2-4",
"description": "Customise your headphone in no time!",
"title": "Customized headphone - Technic365",
"productImage": "headphone3.jpg"
}
{
"source" : "product.exchange.dlx",
"vhost" : "/",
"destination" : "product.dlq.all",
"destination_type" : "queue",
"routing_key" : "#",
"arguments" : { }
}
{
"name" : "product.exchange",
"vhost" : "/",
"type" : "topic",
"durable" : true,
"auto_delete" : false,
"internal" : false,
"arguments" : { }
},{
"name" : "product.exchange.dlx",
"vhost" : "/",
"type" : "topic",
"durable" : true,
"auto_delete" : false,
"internal" : false,
"arguments" : { }
}
{
"name" : "product.dlq.all",
"vhost" : "/",
"durable" : true,
"auto_delete" : false,
"arguments" : { }
}
@Configuration
@Import(DomainMessageSubscriberConfiguration.class) // We need this import so this class is recognized as a ImportBeanDefinitionRegistrar and its registerBeanDefinitions method is called
public class DomainMessageSubscriberConfiguration extends AbstractDomainMessageSubscriberConfiguration {
public static final String REPLY_QUEUE_NAME = "product-view.queue";
@Override
public String getServiceName() {
return "product-view";
}
@Override
public String getVerticalName() {
return "product";
}
@Override
protected List<SubscriberConfigurationItem> getSubscriberConfigurations() {
return ImmutableList.of(
SubscriberConfigurationItem.builder()
.receiveFromVerticalName("shop-admin")
.myVerticalName(getVerticalName())
.serviceName(getServiceName())
.routingKey("shop.shop.*.event")
.build(),
SubscriberConfigurationItem.builder()
.receiveFromVerticalName(getVerticalName())
.myVerticalName(getVerticalName())
.serviceName(getServiceName())
.routingKey("product.product.*.event")
.routingKey("product.product-attribute.*.event")
.routingKey("product.product-image.*.event")
.routingKey("product.product-availability.updated.event")
.routingKey("product.category.*.event")
.routingKey("product.product-attribute-definition.*.event")
// events in response to reindex commands are send to product-view.queue directly with queue name as the
// routing key - we need this as a routing key to let such a message end up in the retry queue as well
.routingKey(REPLY_QUEUE_NAME)
.build()
);
}
@Bean
public MessageListenerAdapter domainMessageListenerAdapter(ThreadLocalTenantAccessor tenantAccessor,
ThreadLocalLocaleAccessor localeAccessor,
List<DomainMessageSubscriber> subscribers,
MessageConverter messageConverter
) {
DomainMessageSubscriberHandler domainMessageSubscriberHandler = new LocalizedDomainMessageSubscriberHandler(tenantAccessor, localeAccessor, subscribers);
MessageListenerAdapter listenerAdapter = new MessageListenerAdapter(domainMessageSubscriberHandler);
listenerAdapter.setMessageConverter(messageConverter);
return listenerAdapter;
}
@Override
protected DomainMessagePayloadSubtypeModule getDomainMessagePayloadSubtypeModule() {
return new DomainMessagePayloadSubtypeModule(
ShopPayload.class,
ProductPayload.class,
AttributePayload.class,
ImagePayload.class,
AvailabilityPayload.class,
CategoryPayload.class,
AttributeDefinitionPayload.class
);
}
}
{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product-view.queue",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product.category.*.event",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product.product-attribute.*.event",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product.product-availability.updated.event",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product.product-image.*.event",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product.product.*.event",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "shop.shop.*.event",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product-view.queue",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product.category.*.event",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product.product-attribute.*.event",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product.product-availability.updated.event",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product.product-image.*.event",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product.product.*.event",
"arguments" : { }
},{
"source" : "shop-admin.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "shop.shop.*.event",
"arguments" : { }
}
{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product-view.queue",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product.category.*.event",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product.product-attribute.*.event",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product.product-availability.updated.event",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product.product-image.*.event",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "product.product.*.event",
"arguments" : { }
},{
"source" : "product-view.exchange.rx",
"vhost" : "/",
"destination" : "product-view.queue.rq",
"destination_type" : "queue",
"routing_key" : "shop.shop.*.event",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product-view.queue",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product.category.*.event",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product.product-attribute.*.event",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product.product-availability.updated.event",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product.product-image.*.event",
"arguments" : { }
},{
"source" : "product.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "product.product.*.event",
"arguments" : { }
},{
"source" : "shop-admin.exchange",
"vhost" : "/",
"destination" : "product-view.queue",
"destination_type" : "queue",
"routing_key" : "shop.shop.*.event",
"arguments" : { }
}
{
"name" : "product-view.exchange.rx",
"vhost" : "/",
"type" : "topic",
"durable" : true,
"auto_delete" : false,
"internal" : false,
"arguments" : { }
}
{
"name" : "product-view.queue",
"vhost" : "/",
"durable" : true,
"auto_delete" : false,
"arguments" : {
"x-dead-letter-exchange" : "product-view.exchange.rx"
}
},{
"name" : "product-view.queue.rq",
"vhost" : "/",
"durable" : true,
"auto_delete" : false,
"arguments" : {
"x-dead-letter-exchange" : "product.exchange.dlx"
}
}
FROM rabbitmq:management
ADD ["rabbitmq.config", "/etc/rabbitmq/"]
RUN mkdir /docker-init.d
COPY ["docker-init.d", "/docker-init.d/"]
ADD ["aggregate-definitions.sh", "/"]
RUN rabbitmq-plugins enable rabbitmq_tracing
RUN /aggregate-definitions.sh /docker-init.d > /etc/rabbitmq/definitions.json
#!/usr/bin/env bash
function aggregate() {
entity=$1
comma=$2
files=()
echo " \"${entity}\": ["
for vertical in ${SRC}/*/; do
if [ -f ${vertical}${entity}.json ] && [ -s ${vertical}${entity}.json ]; then
files+=("${vertical}${entity}.json")
fi
for service in ${vertical}*/; do
if [ -f ${service}${entity}.json ] && [ -s ${service}${entity}.json ]; then
files+=("${service}${entity}.json")
fi
done
done
len=${#files[@]}
lastFile=${files[${len} - 1]}
for file in ${files[@]}; do
line=$(cat ${file})
line=${line%$'\n'}
if [ "${file}" != "${lastFile}" ]; then
line="${line},"
fi
echo -e "${line}"
done
if [ $comma -eq 1 ]; then
echo " ],"
else
echo " ]"
fi
}
SRC=$1
cat ${SRC}/header.json
entities=("queues" "exchanges" "bindings");
len=${#entities[@]}
lastEntity=${entities[${len} - 1]}
for entity in ${entities[@]}; do
comma=1
if [ "${entity}" == "${lastEntity}" ]; then
comma=0
fi
aggregate ${entity} $comma;
done
cat ${SRC}/footer.json
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductMessageSubscriber implements DomainMessageSubscriber {
private static final String PRODUCT = "product";
private final ProductRepository productRepository;
private final TenantAccessor tenantAccessor;
@DomainMessageHandler("product.product.created.event")
public void createProduct(DomainMessage<ProductPayload> message) {
ProductPayload payload = message.getPayload();
withMdc(PRODUCT, payload.getId()).execute(() -> {
log.info("Processing event {}", message.getMessageType());
Product product = toProduct(payload).build();
productRepository.index(product);
});
}
// ...
}
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductMessageSubscriber implements DomainMessageSubscriber {
// ...
private ProductBuilder toProduct(ProductPayload payload) {
return Product.builder()
.id(payload.getId().toString())
.tenantId(tenantAccessor.accessTenant().getId())
.visible(payload.isVisible())
.createdAt(payload.getCreatedAt())
.sku(payload.getSku())
.salesPrice(toSimpleMonetaryAmount(payload.getSalesPrice()))
.onSale(payload.isOnSale())
.name(payload.getName())
.description(payload.getDescription())
.manufacturer(payload.getManufacturer())
.essentialFeatures(payload.getEssentialFeatures())
.defaultImageRelUri(payload.getDefaultImageDataUri())
.shippingDimension(payload.getShippingDimension())
.refPrice(toRefPrice(payload.getRefPrice()))
.shippingPeriod(payload.getShippingPeriod())
.listPrice(toSimpleMonetaryAmount(payload.getListPrice()))
.tags(payload.getTags());
}
// ...
}
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductMessageSubscriber implements DomainMessageSubscriber {
// ...
@DomainMessageHandler("product.product.updated.event")
public void updateProduct(DomainMessage<ProductPayload> message) {
ProductPayload payload = message.getPayload();
withMdc(PRODUCT, payload.getId()).execute(() -> {
log.info("Processing event {}", message.getMessageType());
Product product = toProduct(payload).build();
productRepository.partialUpdateProduct(product);
});
}
@DomainMessageHandler("product.product.deleted.event")
public void deleteProduct(DomainMessage<ProductPayload> message) {
ProductPayload payload = message.getPayload();
withMdc(PRODUCT, payload.getId()).execute(() -> {
log.info("Processing event {}", message.getMessageType());
productRepository.delete(payload.getId().toString());
});
}
// ...
}
@SearchField(value = "name", boostFactor = 10)
@SearchField(value = "description", boostFactor = 3)
@SearchField(value = "sku", boostFactor = 1)
@SearchField(value = "attributes.stringValue", boostFactor = 1)
@SortField("_score")
@SortField("createdAt")
@SortField("salesPrice.amount")
@Getter
@Builder
@ToString(of = {"id"})
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = PROTECTED) // Jackson
@AllArgsConstructor(access = PRIVATE) // @Builder
@JsonDeserialize(builder = Product.ProductBuilder.class)
@Document(indexName = INDEX_NAME_SPRING_EXPRESSION, type = Product.TYPE_NAME, createIndex = false)
public class Product implements MultitenantDocument, Scoreable {
public static final String TYPE_NAME = "product";
@Id
private String id; // UUID can not be used as document ID yet. https://jira.spring.io/browse/DATAES-163
@Version
private Long version;
@Wither
private LocalDateTime indexedAt;
@Setter
private Float score;
@JsonView(ProductPartialUpdateView.class)
private Integer tenantId;
@JsonView(ProductPartialUpdateView.class)
private boolean visible;
@JsonView(ProductPartialUpdateView.class)
private String sku;
@JsonView(ProductPartialUpdateView.class)
private String name;
@JsonView(ProductPartialUpdateView.class)
private String description;
@JsonView(ProductPartialUpdateView.class)
private String manufacturer;
@JsonView(ProductPartialUpdateView.class)
private String essentialFeatures;
@JsonView(ProductPartialUpdateView.class)
private String defaultImageRelUri;
@JsonView(ProductPartialUpdateView.class)
private LocalDateTime createdAt;
@JsonView(ProductPartialUpdateView.class)
private SimpleMonetaryAmount salesPrice;
@JsonView(ProductPartialUpdateView.class)
private boolean onSale;
@JsonView(ProductPartialUpdateView.class)
private Dimension shippingDimension;
@Wither
@JsonView(AvailabilityPartialUpdateView.class)
private String availabilityState;
@Singular
@JsonView(ProductPartialUpdateView.class)
private ImmutableSet<String> tags;
@Singular
@JsonView(AttributePartialUpdateView.class)
private ImmutableSet<Attribute> attributes;
@Singular
@JsonView(ImagePartialUpdateView.class)
private ImmutableSet<Image> images;
@JsonView(ProductPartialUpdateView.class)
private RefPrice refPrice;
@JsonView(ProductPartialUpdateView.class)
private ShippingPeriod shippingPeriod;
@JsonView(ProductPartialUpdateView.class)
private SimpleMonetaryAmount listPrice;
private static <T> ImmutableSet.Builder<T> remove(ImmutableSet<T> originalSet, T elementToRemove) {
ImmutableSet.Builder<T> builder = ImmutableSet.builder();
originalSet.forEach(element -> {
if (!element.equals(elementToRemove)) {
builder.add(element);
}
});
return builder;
}
/**
* @param tag tag to be added to this product
* @return <tt>true</tt> if this product did not already contain the specified
* tag
*/
public boolean addOrReplaceTag(@NotNull String tag) {
boolean contained = tags.contains(checkNotNull(tag));
tags = remove(tags, tag).add(tag).build();
return !contained;
}
/**
* @param tag tag to be removed from this product, if present
* @return <tt>true</tt> if this product contained the specified tag
*/
public boolean removeTag(@NotNull String tag) {
boolean contained = tags.contains(checkNotNull(tag));
tags = remove(tags, tag).build();
return contained;
}
/**
* @param image image to be added to this product
* @return <tt>true</tt> if this product did not already contain the specified
* image
*/
public boolean addOrReplaceImage(@NotNull Image image) {
boolean contained = images.contains(checkNotNull(image));
images = remove(images, image).add(image).build();
return !contained;
}
/**
* @param image image to be removed from this product, if present
* @return <tt>true</tt> if this product contained the specified image
*/
public boolean removeImage(@NotNull Image image) {
boolean contained = images.contains(checkNotNull(image));
images = remove(images, image).build();
return contained;
}
/**
* @param attribute attribute to be added to this product
* @return <tt>true</tt> if this product did not already contain the specified
* attribute
*/
public boolean addOrReplaceAttribute(@NotNull Attribute attribute) {
boolean contained = attributes.contains(checkNotNull(attribute));
attributes = remove(attributes, attribute).add(attribute).build();
return !contained;
}
/**
* @param attribute attribute to be removed from this product, if present
* @return <tt>true</tt> if this product contained the specified attribute
*/
public boolean removeAttribute(@NotNull Attribute attribute) {
boolean contained = attributes.contains(checkNotNull(attribute));
attributes = remove(attributes, attribute).build();
return contained;
}
@JsonPOJOBuilder(withPrefix = "")
public static class ProductBuilder {
}
}
// ...
public class Product implements MultitenantDocument, Scoreable {
public static final String TYPE_NAME = "product";
@Id
private String id; // UUID can not be used as document ID yet. https://jira.spring.io/browse/DATAES-163
@Version
private Long version;
@Wither
private LocalDateTime indexedAt;
@Setter
private Float score;
@JsonView(ProductPartialUpdateView.class)
private Integer tenantId;
@JsonView(ProductPartialUpdateView.class)
private boolean visible;
@JsonView(ProductPartialUpdateView.class)
private String sku;
@JsonView(ProductPartialUpdateView.class)
private String name;
@JsonView(ProductPartialUpdateView.class)
private String description;
@JsonView(ProductPartialUpdateView.class)
private String manufacturer;
@JsonView(ProductPartialUpdateView.class)
private String essentialFeatures;
@JsonView(ProductPartialUpdateView.class)
private String defaultImageRelUri;
@JsonView(ProductPartialUpdateView.class)
private LocalDateTime createdAt;
@JsonView(ProductPartialUpdateView.class)
private SimpleMonetaryAmount salesPrice;
@JsonView(ProductPartialUpdateView.class)
private boolean onSale;
@JsonView(ProductPartialUpdateView.class)
private Dimension shippingDimension;
@Wither
@JsonView(AvailabilityPartialUpdateView.class)
private String availabilityState;
@Singular
@JsonView(ProductPartialUpdateView.class)
private ImmutableSet<String> tags;
@Singular
@JsonView(AttributePartialUpdateView.class)
private ImmutableSet<Attribute> attributes;
@Singular
@JsonView(ImagePartialUpdateView.class)
private ImmutableSet<Image> images;
@JsonView(ProductPartialUpdateView.class)
private RefPrice refPrice;
@JsonView(ProductPartialUpdateView.class)
private ShippingPeriod shippingPeriod;
@JsonView(ProductPartialUpdateView.class)
private SimpleMonetaryAmount listPrice;
private static <T> ImmutableSet.Builder<T> remove(ImmutableSet<T> originalSet, T elementToRemove) {
ImmutableSet.Builder<T> builder = ImmutableSet.builder();
originalSet.forEach(element -> {
if (!element.equals(elementToRemove)) {
builder.add(element);
}
});
return builder;
}
/**
* @param tag tag to be added to this product
* @return <tt>true</tt> if this product did not already contain the specified
* tag
*/
public boolean addOrReplaceTag(@NotNull String tag) {
boolean contained = tags.contains(checkNotNull(tag));
tags = remove(tags, tag).add(tag).build();
return !contained;
}
/**
* @param tag tag to be removed from this product, if present
* @return <tt>true</tt> if this product contained the specified tag
*/
public boolean removeTag(@NotNull String tag) {
boolean contained = tags.contains(checkNotNull(tag));
tags = remove(tags, tag).build();
return contained;
}
/**
* @param image image to be added to this product
* @return <tt>true</tt> if this product did not already contain the specified
* image
*/
public boolean addOrReplaceImage(@NotNull Image image) {
boolean contained = images.contains(checkNotNull(image));
images = remove(images, image).add(image).build();
return !contained;
}
/**
* @param image image to be removed from this product, if present
* @return <tt>true</tt> if this product contained the specified image
*/
public boolean removeImage(@NotNull Image image) {
boolean contained = images.contains(checkNotNull(image));
images = remove(images, image).build();
return contained;
}
/**
* @param attribute attribute to be added to this product
* @return <tt>true</tt> if this product did not already contain the specified
* attribute
*/
public boolean addOrReplaceAttribute(@NotNull Attribute attribute) {
boolean contained = attributes.contains(checkNotNull(attribute));
attributes = remove(attributes, attribute).add(attribute).build();
return !contained;
}
/**
* @param attribute attribute to be removed from this product, if present
* @return <tt>true</tt> if this product contained the specified attribute
*/
public boolean removeAttribute(@NotNull Attribute attribute) {
boolean contained = attributes.contains(checkNotNull(attribute));
attributes = remove(attributes, attribute).build();
return contained;
}
@JsonPOJOBuilder(withPrefix = "")
public static class ProductBuilder {
}
}
@RepositoryRestResource(exported = false)
public interface ProductRepository extends ElasticsearchRepository<Product, String>, ProductRepositoryExtension {
Optional<Product> findVisibleById(String id);
}
public interface ProductRepositoryExtension {
Page<Product> findVisibleByTags(@NotNull Set<String> tags, @NotNull Pageable pageable);
Page<Product> findVisibleBySearchTerm(@Nullable String searchTerm, @NotNull Pageable pageable);
Page<Product> findVisibleByCategoryQuery(@NotNull CategoryQuery categoryQuery, @NotNull Pageable pageable);
Optional<Product> findOneIgnoringVisibility(@NotNull String id);
Product partialUpdateProduct(@NotNull Product product);
Product partialUpdateAttributes(@NotNull Product product);
Product partialUpdateImages(@NotNull Product product);
Product partialUpdateAvailability(@NotNull Product product);
}
@Slf4j
@Validated
@RestController
@RequestMapping(value = ProductController.REQUEST_MAPPING, produces = {HAL_JSON_VALUE, APPLICATION_JSON_VALUE})
@RequiredArgsConstructor
public class ProductController {
// ...
@GetMapping
public ResponseEntity<PagedResources<ProductListResource>> listProducts(@ModelAttribute ProductListResourceAssembler resourceAssembler,
@OnlyRecognizedSort Pageable pageable
) {
Page<Product> products = productRepository.findAll(pageable);
return ResponseEntity.ok(toPagedResources(resourceAssembler, products));
}
@GetMapping("/{id}")
public ResponseEntity<ProductDetailResource> findById(@ModelAttribute ProductDetailResourceAssembler resourceAssembler,
@PathVariable UUID id
) {
Optional<Product> product = productRepository.findVisibleById(id.toString());
return withMdc(product).executeAndReturn(() -> product
.map(resourceAssembler::toResource)
.map(ResponseEntity::ok)
.orElse(new ResponseEntity<>(NOT_FOUND))
);
}
// ...
}
@Slf4j
@Validated
@RestController
@RequestMapping(value = ProductController.REQUEST_MAPPING, produces = {HAL_JSON_VALUE, APPLICATION_JSON_VALUE})
@RequiredArgsConstructor
public class ProductController {
// ...
@GetMapping("/search/find-by-tags")
public ResponseEntity<PagedResources<ProductListResource>> findByTags(@ModelAttribute ProductListResourceAssembler resourceAssembler,
@RequestParam(name = "tag", required = false) Set<String> tags,
@OnlyRecognizedSort @PageableDefault(sort = DEFAULT_SORT, direction = DESC, size = DEFAULT_PAGE_SIZE) Pageable pageable
) {
Page<Product> products = productRepository.findVisibleByTags(firstNonNull(tags, NO_TAGS), pageable);
return ResponseEntity.ok(toPagedResources(resourceAssembler, products));
}
@GetMapping("/search/find-by-search-term")
public ResponseEntity<PagedResources<ProductListResource>> findBySearchTerm(@ModelAttribute ProductListResourceAssembler resourceAssembler,
@RequestParam(name = "query", required = false) String query,
@OnlyRecognizedSort Pageable pageable
) {
Page<Product> products = productRepository.findVisibleBySearchTerm(query, pageable);
return ResponseEntity.ok(toPagedResources(resourceAssembler, products));
}
// ...
}
@Slf4j
@Validated
@RestController
@RequestMapping(value = ProductController.REQUEST_MAPPING, produces = {HAL_JSON_VALUE, APPLICATION_JSON_VALUE})
@RequiredArgsConstructor
public class ProductController {
// ...
@GetMapping("/search/find-by-category")
public ResponseEntity<PagedResources<ProductListResource>> findByCategory(@ModelAttribute ProductListResourceAssembler resourceAssembler,
@RequestParam(value = "categoryId") Optional<Category> category,
@OnlyRecognizedSort Pageable pageable
) {
return withMdc(category).executeAndReturn(() -> category
.map(Category::getQuery)
.map(query -> productRepository.findVisibleByCategoryQuery(query, pageable))
.map(products -> toPagedResources(resourceAssembler, products))
.map(ResponseEntity::ok)
.orElse(new ResponseEntity<>(NOT_FOUND))
);
}
@GetMapping("/search/find-by-query")
public ResponseEntity<PagedResources<ProductListResource>> findByQueryWithRequestParam(@ModelAttribute ProductListResourceAssembler resourceAssembler,
@RequestParam(value = CATEGORY_QUERY_REQUEST_PARAM_NAME) String query,
@OnlyRecognizedSort Pageable pageable
) {
CategoryQuery categoryQuery = CategoryQuery.builder(objectMapper).build(query);
Page<Product> products = productRepository.findVisibleByCategoryQuery(categoryQuery, pageable);
return ResponseEntity.ok(toPagedResources(resourceAssembler, products));
}
private PagedResources<ProductListResource> toPagedResources(ProductListResourceAssembler resourceAssembler, Page<Product> products) {
return products.getContent().isEmpty() ?
(PagedResources<ProductListResource>) pagedResourcesAssembler.toEmptyResource(products, ProductListResource.class, null) :
pagedResourcesAssembler.toResource(products, resourceAssembler);
}
// ...
}
FROM java:10-jre
ADD http://central.maven.org/maven2/org/
springframework/spring-instrument/5.1.1.RELEASE/
spring-instrument-5.1.1.RELEASE.jar /app/spring-instrument.jar
ENTRYPOINT ["java","-javaagent:/app/spring-instrument.jar","-jar","/app/app.jar"]
---
apiVersion: "extensions/v1beta1"
kind: "Deployment"
metadata:
name: "product-management"
spec:
replicas: 2
strategy:
rollingUpdate:
maxUnavailable: 1
maxSurge: 0
minReadySeconds: 30
template:
spec:
containers:
- name: "product-management"
image: "..."
volumeMounts:
- name: "application-properties-config"
mountPath: "/app/config"
ports:
- containerPort: 80
name: "http"
env:
- name: "JAVA_TOOL_OPTIONS"
value: "-Xmx512m"
// ...
// ...
resources:
requests:
cpu: "100m"
memory: "512Mi"
readinessProbe:
httpGet:
path: "/system/health"
port: 81
initialDelaySeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: "/system/info"
port: 81
initialDelaySeconds: 180
timeoutSeconds: 3
volumes:
- name: "application-properties-config"
configMap:
name: "application-properties-config"
DDD Examples
CQRS & Event Sourcing Examples
By Benjamin Nothdurft
2019-11-07 Session at XP Days Stuttgart
Software Craftsman, DevOps, @jenadevs @jugthde Founder/Speaker, Traveller & Cyclist – focusing on Continuous Delivery Pipelines, Automation, Docker, Selenium, Java