From

Big Picture to Microservices

with Event Storming

 Benjamin Nothdurft

 @DataDuke

DDD & CQRS

Background

Business Domain

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

Agenda

Top-Down View

Exe / Jar / Zip

Objects

Classes

OOP

Design Patterns

Modules

Layers

Project

Developer cares about ...

Service

Exe / Jar / Zip

Objects

Classes

OOP

Design Patterns

Modules

Layers

Project

Tactical Design

Tactical Design

Strategic Design

Service

Service

Service

Sub-Domain

Sub-Domain

Domain

Domain-Driven-Design

Exe / Jar / Zip

Objects

Classes

OOP

Design Patterns

Modules

Layers

Project

Strategic Design Tools

Event Storming

What is Event Storming?

  • lightweight method to model a domain
    • within an event storming workshop we work in iterations to add layer by layer of elements to sharpen the picture of a domain
  • typical elements
    • ​​domain events, commands, aggregates
    • issues/hot spots, chances

    • external systems, read models, policies

    • users/actors, timers, loops

Why do we use Event Storming?

  • knowledge distribution
    • bring together people from different silos
    • domain experts/analysts/developers/architects
  • enforcing a sharp timeline
    • experts will create locally ordered sequence of events
    • trigger long-awaited conversation
  • following steps
    • structure will emerge
    • people and systems will be displayed
    • every layer sparkles a new type of conversation
    • ...

Domain Events

product added to cart
  • domain event (past tense)

  • orange sticky note

  • relevant for domain experts

Example

Command-Event-Pairs

  • command (present tense)

  • blue sticky note

  • triggers a domain event

product add to cart

Aggregates / Entities

  • aggregate/entity

  • yellow sticky note

  • data that is interacted with

product

Borders & Flow

  • find borders where a domain model has a different meaning

  • domain events flow between different models

Bounded Contexts

  • bounded context/sub-domain

  • red sticky note

  • core name that is relevant/valid

checkout

Example

Outcome / Big Picture

  • narrative/stories/user journey

  • line(s)

  • multiple storytellings

  • common language for everyone

Iterate / Color Puzzle Thinking

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

System Architecture

provider-ui

storefront-ui

merchant-ui

Frontend

api proxy

tenant

auth

Core

business unit

site

shop

Shop Admin

Product

product

. . .

. . .

. . .

. . .

Bounded Contexts > Microservices

Each sub-domain is implemented as vertical:

  • represents a deployable unit (one or more)
  • is exclusively responsible for its services
  • owns its data
  • communicates with other verticals predominantly by using asynchronous messaging
  • provides a REST-API to leverage access to its function

Sub-Domains / Verticals

Ubiquitous Language

  • use the same vocabulary for the same concepts
  • e.g. domain experts, analysts, developers use same language to describe a problem on the whiteboard

Bounded Context

  • area of the problem space where the ubiquitous language is valid
  • e.g.
    • customer in sales = social interests, likes, needs
    • customer in support = history, tickets
    • customer in accounting = method of payment
    • customer in order = addresses, availability

Most important terms... I/II

Domain Model

  • interpretation of reality
  • includes the chosen aspects for implementation
  • refers to the abstraction of the subdomain

Most important terms... II/II

A domain model is to a bounded context

what classes are to objects

Context Map

  • relation between bounded contexts

Context Map Patterns

Upstream / Downstream

Call Flow
Model Flow

Context Map

Call Flow
Model Flow
Downstream System
Upstream System

Context Map Patterns Categorized

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

Context Map Patterns Categorized

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

Open Host Service

  • 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

Open Host Service

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

Open Host Service

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

Event Publisher

  • 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

Event Publisher

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

Event Publisher

@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() }
}

Context Map Patterns Categorized

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

Customer / Supplier

  • 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

Customer / Supplier

@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

Conformist

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

Conformist

Anticorruption Layer

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

Anticorruption Layer

Context Map Patterns Categorized

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

Shared Kernel

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

Shared Kernel


dependencyManagement {
    imports {
        mavenBom "com.example.demo:shared-parent:1.2.3"
    }
}

dependencies {
    compile     ("com.example.demo:shared-message")
}

Shared Kernel

Published Language

  • Similar to Open Host Service

  • A common language between bounded context is modeled

Upstream System
Down-stream System

Published Language

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

Seperate Ways

  • No connection between bounded contexts exists

  • Teams can find their own solution for their domain

Upstream System
Down-stream System

Context Map Patterns Categorized

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

Interim Conclusion

CQRS!? Example

provider-ui

storefront-ui

merchant-ui

Frontend

api proxy

tenant

auth

business unit

site

shop

Shop Admin

Product

product

. . .

. . .

. . .

. . .

System Architecture

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

Typical simplified process

Product

Frontend

merchant-ui

storage

product

service

model

Is this process always suitable?

  • 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   

CQRS with 1 microservice!?

  • service can only be scaled as a whole unit
  • bound to one database technology

Drawbacks

storefront-ui

query

service

  • CQRS = Command-Query Responsiblity-Separation
  • command service routes changes to command model (object)
  • command model updates db
  • query model reads from database
  • query service updates presentation from query model

How it works

Product

Frontend

merchant-ui

storage

 p-management

service

command   

CQRS with 2 microservices!

  • handling asymmetric payloads is possible due to individual scaling
  • decoupled deployment

  • leverage on purpose-specific db technology for each req.

Benefits

storefront-ui

query

service

  • CQRS = Command-Query Responsiblity-Separation
  • management: ... command model is stored to db and publishes a domain event
  • view: subscribes to domain event, reads it and stores it to db ...

How it works additonally

storage

 product-view

bus

Tactical Design Tools

Building Blocks in DDD

Tactical Design Tools

Building Blocks in DDD

product-management

product
product-image
product-attribute
created updated deleted
created updated deleted
created updated deleted

product-view/search

product
product-image
product-attribute

Management

Controller

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

    // ...

}

Product Aggregate (& Entities)

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

}

Price Entity

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

}

Product Repository

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

Product EventHandler


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

Publisher

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

    // ...
}

Publisher

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

    // ...

}

Publisher

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

DomainMessage


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

Message

Product

Frontend

merchant-ui

storage

 p-management

service

command   

CQRS with 2 microservices!

  • handling asymmetric payloads is possible due to individual scaling
  • decoupled deployment

  • leverage on purpose-specific db technology for each req.

Benefits

storefront-ui

query

service

  • CQRS = Command-Query Responsiblity-Separation
  • management: ... command model is stored to db and publishes a domain event
  • view: subscribes to domain event, reads it and stores it to db ...

How it works additonally

storage

 product-view

broker

Product Message Example

 {
      "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"
}

Bindings & Configuration

Message

product/bindings.json

{
  "source" : "product.exchange.dlx",
  "vhost" : "/",
  "destination" : "product.dlq.all",
  "destination_type" : "queue",
  "routing_key" : "#",
  "arguments" : { }
}

product/exchanges.json

{
  "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" : { }
}

product/queues.json

{
  "name" : "product.dlq.all",
  "vhost" : "/",
  "durable" : true,
  "auto_delete" : false,
  "arguments" : { }
}

Subscriber Configuration


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

product-view/bindings.json

{
  "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" : { }
}

product-view/bindings.json

{
  "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" : { }
}

product-view/exchanges.json

{
  "name" : "product-view.exchange.rx",
  "vhost" : "/",
  "type" : "topic",
  "durable" : true,
  "auto_delete" : false,
  "internal" : false,
  "arguments" : { }
}

product-view/queues.json

{
  "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"
  }
}

RabbitMQ Dockerfile

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

aggregate-definitions.sh from init.d

#!/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

View

Subscriber

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

    // ...

}

Subscriber

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

    // ...

}

Subscriber

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

    // ...

}

Entity

@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 {
    }
}

Entity

// ...
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 {
    }
}

Respository

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

Controller

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

    // ...
}

Controller

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

    // ...
}

Controller

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

    // ...
}

Cloud Architecture?!

Deployment: CI/CD-Pipeline

Deployment with same Dockerfile Base Image as other services

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"
        // ...

K8s deployment.yaml (Extract)

K8s deployment.yaml (Extract)


// ...
        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"

More DDD & CQRS Hands-On

Hands-On