This is another excerpt from my latest book, which is currently part of Manning's Early Access Program. Take 37% off Object Design Style Guide by entering fccnoback into the discount code box at checkout at manning.com.
Make sure to read part 1 first.
Create read models directly from their data source
Instead of creating a StockReport
model from PurchaseOrderForStock
objects, we could go directly to the source of the data, that is, the database where the application stores its purchase orders. If this is a relational database, there might be a table called purchase_orders
, with columns for purchase_order_id
, product_id
, ordered_quantity
, and was_received
. If that's the case, then StockReportRepository
wouldn't have to load any other object before it could build a StockReport
object; it could make a single SQL query and use it to create the StockReport
, as shown in Listing 11).
Listing 11. StockReportSqlRepository
creates a stock report using plain SQL.
final class StockReportSqlRepository implements StockReportRepository
{
public function getStockReport(): StockReport
{
result = this.connection.execute(
'SELECT ' .
' product_id, ' .
' SUM(ordered_quantity) as quantity_in_stock ' .
'FROM purchase_orders ' .
'WHERE was_received = 1 ' .
'GROUP BY product_id'
);
data = result.fetchAll();
return new StockReport(data);
}
}
Creating read models directly from the write model's data source is usually pretty efficient in terms of runtime performance. It's also an efficient solution in terms of development and maintenance costs. This solution will be less efficient if the write model changes often, or if the raw data can't easily be used as-is, because it needs to be interpreted first.
Build read models from domain events
One disadvantage of creating the StockReport
read model directly from the write model's data is that the application will make the calculations again and again, every time the user requests a stock report. Although the SQL query won't take too long to execute (until the table grows very large), in some cases it'll be necessary to use another approach for creating read models.
First, let's take another look at the result of the SQL query we used in the previous example (Table 1).
Table 1. The result of the SQL query for generating a stock report.
product_id | quantity_in_stock |
---|---|
123 |
10 |
124 |
5 |
What would be another way in which we can come up with the numbers in the second column, that wouldn't involve looking up all the records in the purchase_orders
table and summing their ordered_quantity
values?
What if we could sit next to the user with a piece of paper, and whenever they mark a purchase order as "received", we'd write down the ID of the product and how many items of it were received. The resulting list would look like Table 2:
Table 2. The result if we write down every received product.
product_id | received |
---|---|
123 |
2 |
124 |
4 |
124 |
1 |
123 |
8 |
Now, instead of having multiple rows for the same product, we could also look up the row with the product that was just received, and add the quantity we received to the number that's already in the received
column, as is done in Table 3.
Table 3. The result of combining received quantities per product.
product_id | received |
---|---|
123 |
2 + 8 |
124 |
4 + 1 |
Doing the calculations, this amounts to the exact same result as when we used the SUM
query to find out how much of a product we have in stock.
Instead of sitting next to the user with a piece of paper, we should listen in on our PurchaseOrder
entity to find out when a user marks it as received. We can do this by recording and dispatching Domain events; a technique we already saw in a previous chapter. First, we need to let PurchaseOrder
record a domain event, indicating that the ordered items were received. This is shown in Listing 12.
Listing 12. PurchaseOrder
entities record PurchaseOrderReceived
events.
final class PurchaseOrderReceived
{
private int purchaseOrderId;
private int productId;
private int receivedQuantity;
public function __construct(
int purchaseOrderId,
int productId,
int receivedQuantity
) {
this.purchaseOrderId = purchaseOrderId;
this.productId = productId;
this.receivedQuantity = receivedQuantity;
}
public function productId(): int
{
return this.productId;
}
public function receivedQuantity(): int
{
return this.receivedQuantity;
}
}
final class PurchaseOrder
{
private array events = [];
// ...
public function markAsReceived(): void
{
this.wasReceived = true;
this.events[] = new PurchaseOrderReceived(
this.purchaseOrderId,
this.productId,
this.orderedQuantity
);
}
public function recordedEvents(): array
{
return this.events;
}
}
This is the new domain event.
We record the domain event inside
PurchaseOrder
.
Calling markAsReceived()
will from now on add a PurchaseOrderReceived
event object to the list of internally recorded events. These events can be taken out and handed over to an event dispatcher, for example in the ReceiveItems
service (Listing 13).
Listing 13. ReceiveItems
dispatches any recorded domain event.
final class ReceiveItems
{
// ...
public function receiveItems(int purchaseOrderId): void
{
// ...
this.repository.save(purchaseOrder);
this.eventDispatcher.dispatchAll(
purchaseOrder.recordedEvents()
);
}
}
An event listener that has been registered for this particular event can take the relevant data from the event object, and update its own private list of products and quantities in stock. For instance, it could build up the stock report, by maintaining its own table stock_report
with rows for every product. It has to process the incoming PurchaseOrderReceived
events and create new rows or updating existing ones in this stock_report
table, as shown in Listing 14).
Listing 14. UpdateStockReport
uses the event to update the stock_report
table.
final class UpdateStockReport
{
public function whenPurchaseOrderReceived(
PurchaseOrderReceived event
): void {
this.connection.transactional(function () {
/*
* Find out if we have an existing row.
*/
existingRow = this.connection
.prepare(
'SELECT quantity_in_stock ' .
'FROM stock_report ' .
'WHERE product_id = :productId FOR UPDATE'
)
.bindValue('productId', event.productId())
.execute()
.fetch();
if (!existingRow) {
/*
* If no row exists for this product, create a new
* one and set an initial value for quantity-in-stock.
*/
this.connection
.prepare(
'INSERT INTO stock_report ' .
' (product_id, quantity_in_stock) ' .
'VALUES (:productId, :quantityInStock)'
)
.bindValue(
'productId',
event.productId()
)
.bindValue(
'quantityInStock',
event.quantityReceived()
)
.execute();
} else {
/*
* Otherwise, update the existing one; increase
* the quantity-in-stock.
*/
this.connection
.prepare(
'UPDATE stock_report ' .
'SET quantity_in_stock = ' .
' quantity_in_stock + :quantityReceived ' .
'WHERE product_id = :productId'
)
.bindValue(
'productId',
event.productId()
)
.bindValue(
'quantityReceived',
event.quantityReceived()
)
.execute();
}
});
}
}
Figure 1. This diagram shows how the ReceiveItems
service makes a change to the PurchaseOrder
write model, after which it allows other services like UpdateStockReport
to listen in on those changes by dispatching domain events to the EventDispatcher
.
Once we have a separate data source for the stock report, we can make StockReportSqlRepository
even simpler, because all the information is already in the stock_reports
table (see Listing 15).
Listing 15. The query in StockReportSqlRepository
is now much simpler.
final class StockReportSqlRepository implements StockReportRepository
{
public function getStockReport(): StockReport
{
result = this.connection.execute(
'SELECT * FROM stock_report'
);
data = result.fetchAll();
return new StockReport(data);
}
}
This kind of simplification may offer you a way to make your read model queries more efficient. However, in terms of development and maintenance costs, using domain events to build up read models is more expensive. As you can see by looking at the examples in this section, there are more moving parts involved. If something changes about a domain event, it will be more work to adapt the other parts that depend on it. If one of the event listeners fails, you need to be able to fix the error, and run it again, which requires some extra effort in terms of tooling and operations as well.
Things will be even more complex if besides using events for building up read models, you also use events for reconstructing write models. This technique is called Event sourcing and fits very well with the idea of separating write models from read models. However, as demonstrated in this chapter, you don't need to apply event sourcing if you're only looking for a better way to divide responsibilities between objects. Using any of the techniques described here, you can already provide clients that only want to retrieve information from an entity with a separate read model.
Summary
- For your domain objects, make sure to separate write models from read models. Clients that are only interested in an entity because they need data from it should use a dedicated object, instead of the same entity that exposes methods for changing its state.
- Read models can be created directly from the write model. A more efficient way would be to create it from the data source used by the write model. If that is impossible, or the read model can't be created in an efficient way, consider using domain events to build up the read model over time.
If you want to learn more about the book, check it out here on liveBook.
One may fall into "Write/Read" terminology trap. It may seem mistakenly obvious always to ask a Read repo for a Foo before further update.
Think of a Foo edit form in a CMS. Definitely, this form needs a data, this data reflects a Foo as it is - a true model, so it's exactly a Write model being queried, thus it's Write's repo task to perform a query. Hence it's completely optionally ok to have find* methods in a Write repo along with full pack of getters in a Write model.
Thanks for pointing this out. I didn't consider a regular CRUD scenario in this post. If a part of your application wants to show a complete entity, allow people to edit parts of it, save it, and do it all again, this means CRUD is the best choice, and for such a model, you may not need the strict separation between a read and a write model.