Search code examples
domain-driven-designviewmodelcqrsprojection

CQRS Read Model Projections: How complex is too complex a data transformation


I want to sanity check myself on a view projection, in regards to if an intermediary concept can purely exist in the read model while providing a bridge between commands.

Let me use a contrived example to explain.

We place an order which raises an OrderPlaced event. The workflow then involves generating a picking slip, which is used to prepare a shipment.

A picking slip can be generated from an order (or group of orders) without any additional information being supplied from any external source or user. Is it acceptable then that the picking slip can be represented purely as a read model?

So:

PlaceOrderCommand -> OrderPlacedEvent
OrderPlacedEvent -> PickingSlipView

The warehouse manager can then view a picking slip, select the lines they would like to ship, and then perform a PrepareShipment command. A ShipmentPrepared event will then update the original order, and remove the relevant lines from the PickingSlipView.

I know it's a toy example, but I have a conceptually similar use case where a colleague believes the PickingSlip should be a domain entity/aggregate in its own right, as it's conceptually different to order. So you have PlaceOrder, GeneratePickingSlip, and PrepareShipment commands.

The GeneratePickingSlip command however simply takes an order number (identifier), transforms the order data into a picking slip entity, and persists the entity. You can't modify or remove a picking slip or perform any action on it, apart from using it to prepare a shipment.

This feels like introducing unnecessary overhead on the write model, for what is ultimately just a transformation of existing information to enable another command.

So (and without delving deeply into the problem space of warehouses and shipping)...

Is what I'm proposing a legitimate use case for a read model?

Acting as an intermediary between two commands, via transformation of some data into a different view. Or, as my colleague proposes, should every concept be represented in the write model in all cases?

I feel my approach is simpler, and avoiding unneeded complexity, but I'm new to CQRS and so perhaps missing something.

Edit - Alternative Example

Providing another example to explore:

We have a book of record for categories, where each record is information about products and their location. The book of record is populated by an external system, and contains SKU numbers, mapped to available locations:

Book of Record (Electronics)
SKU#    Location1     Location2    Location3   ...    Location 10
XXXX    Introduce     Remove       Introduce   ...    N/A
YYYY    N/A           Introduce    Introduce   ...    Remove

Each book of record is an entity, and each line is a value object.

The book of record is used to generate different Tasks (which are grouped in a TaskPlan to be assigned to a person). The plan may only cover a subset of locations.

There are different types of Tasks: One TaskPlan is for the individual who is on a location to add or remove stock from shelves. Call this an AllocateStock task. Another type of Task exists for a regional supervisor managing multiple locations, to check that shelving is properly following store guidelines, say CheckDisplay task. For allocating stock, we are interested in both introduced and removed SKUs. For checking the displays, we're only interested in newly Introduced SKUs, etc.

We are exploring two options:

Option 1

The person creating the tasks has a View (read model) that allows them to select Book of Records. Say they select Electronics and Fashion. They then select one or more locations. They could then submit a command like:

GenerateCheckDisplayTasks(TaskPlanId, List<BookOfRecordId>, List<Locations>)

The commands would then orchestrate going through the records, filtering out locations we don't need, processing only the 'Introduced' items, and creating the corresponding CheckDisplayTasks for each SKU in the TaskPlan.

Option 2

The other option is to shift the filtering to the read model before generating the tasks.

When a book of record is added a view model for each type of task is maintained. The data might be transposed, and would only include relevant info. ie. the CheckDisplayScopeView might project the book of record to:

Category                       SKU     Location
Electronics (BookOfRecordId)   XXXX    Location1
Electronics (BookOfRecordId)   XXXX    Location3
Electronics (BookOfRecordId)   YYYY    Location2
Electronics (BookOfRecordId)   YYYY    Location3
Fashion     (BookOfRecordId)   ...     ... etc

When generating tasks, the view enables the user to select the category and locations they want to generate the tasks for. Perhaps they select the Electronics category and Location 1 and 3.

The command is now:

GenerateCheckDisplayTasks(TaskPlanId,  List<BookOfRecordId, SKU, Location>)

Where the command now no longer is responsible for the logic needed to filter out the locations, the Removed and N/A items, etc.

So the command for the first option just submits the ID of the entity that is being converted to tasks, along with the filter options, and does all the work internally, likely utilizing domain services.

The second option offloads the filtering aspect to the view model, and now the command submits values that will generate the tasks.

Note: In terms of the guidance that Aggregates shouldn't appear out of thin air, the Task Plan aggregate will create the Tasks.

I'm trying to determine if option 2 is pushing too much responsibility onto the read model, or whether this filtering behavior is more applicable there.

Sorry, I attempted to use the PickingSlip example as I thought it would be a more recognizable problem space, but realize now that there are connotations that go along with the concept that may have muddied the waters.


Solution

  • The answer to your question, in my opinion, very much depends on how you design your domain, not how you implement CQRS. The way you present it, it seems that all these operations and aggregates are in the same Bounded Context but at first glance, I would think that there are 3 (naming is difficult!):

    1. Order Management or Sales, where orders are placed
    2. Warehouse Operations, where goods are packaged to be shipped
    3. Shipments, where packages are put in trucks and leave

    When an Order is Placed in Order Management, Warehouse reacts and starts the Packaging workflow. At this point, Warehouse should have all the data required to perform its logic, without needing the Order anymore.

    The warehouse manager can then view a picking slip, select the lines they would like to ship, and then perform a PrepareShipment command.

    To me, this clearly indicates the need for an aggregate that will ensure the invariants are respected. You cannot select items not present in the picking slip, you cannot select more items than the quantities specified, you cannot select items that have already been packaged in a previous package and so on.

    A ShipmentPrepared event will then update the original order, and remove the relevant lines from the PickingSlipView.

    I don't understand why you would modify the original order. Also, removing lines from a view is not a safe operation per se. You want to guarantee that concurrency doesn't cause a single item to be placed in multiple packages, for example. You guarantee that using an aggregate that contains all the items, generates the packaging instructions, and marks the items of each package safely and transactionally.

    Acting as an intermediary between two commands

    Aggregates execute the commands, they are not in between.

    Viewing it from another angle, an indication that you need that aggregate is that the PrepareShippingCommand needs to create an aggregate (Shipping), and according to Udi Dahan, you should not create aggregate roots (out of thin air). Instead, other aggregate roots create them. So, it seems fair to say that there needs to be some aggregate, which ensures that the policies to create shippings are applied.

    As a final note, domain design is difficult and you need to know the domain very well, so it is very likely that my proposed solution is not correct, but I hope the considerations I made on each step are helpful to you to come up with the right solution.


    UPDATE after question update

    I read a couple of times the updated question and updated several times my answer, but ended up every time with answers very specific to your example again and I'm most likely missing a lot of details to actually be helpful (I'd be happy to discuss it on another channel though). Therefore, I want to go back to the first sentence of your question to add an important comment that I missed:

    an intermediary concept can purely exist in the read model, while providing a bridge between commands.

    In my opinion, read models are disposable. They are not a single source of truth. They are a representation of the data to easily fulfil the current query needs. When these query needs change, old read models are deleted and new ones are created based on the data from the write models.

    So, only based on this, I would recommend to not prepare a read model to facilitate your commands operations.

    I think that your solution is here:

    When a book of record is added a view model for each type of task is maintained. The data might be transposed, and would only include relevant info.

    If I understand it correctly, what you should do here is not create view model, but create an Aggregate (or multiple). Then this aggregate can receive the commands, apply the business rules and mutate the state. So, instead of having a domain service reading data from "clever" read models and putting it all together, you have an aggregate which encapsulates the data it needs and the business logic.

    I hope it makes sense. It's a broad topic and we could talk about it for hours probably.