In chapter 5, p.95, Scott writes:
"First, is Order an Entity or a Value Object? Obviously it’s an Entity—the details of the order may change over time, but it’s the same order.
What about an OrderLine, though? If we change the quantity of a particular order line, for example, is it still the same order line? In most designs, it would make sense to say yes, it is still the same order line, even though the quantity or price has changed over time. So OrderLine is an Entity too, with its own identifier.
But now here’s a question: if you change an order line, have you also changed the order that it belongs to?
In this case, it’s clear that the answer is yes: changing a line also changes the entire order. In fact, having immutable data structures makes this unavoidable. If I have an immutable Order containing immutable OrderLines, then just making a copy of one of the order lines does not also make a copy of the Order as well. In order to make a change to an OrderLine contained in an Order, I need to make the change at the level of the Order, not at the level of the OrderLine.
For example, here’s some pseudocode for updating the price of an order line:
/// We pass in three parameters:
/// * the top-level order
/// * the id of the order line we want to change
/// * the new price
let changeOrderLinePrice order orderLineId newPrice =
// 1. find the line to change using the orderLineId
let orderLine = order.OrderLines |> findOrderLine orderLineId // 2. make a new version of the OrderLine with the new price
let newOrderLine = {orderLine with Price = newPrice}
// 3. create a new list of lines, replacing // the old line with the new line
let newOrderLines =
order.OrderLines |> replaceOrderLine orderLineId newOrderLine
// 4. make a new version of the entire order, replacing // all the old lines with the new lines
let newOrder = {order with OrderLines = newOrderLines}
// 5. return the new order
newOrder
The final result, the output of the function, is a new Order containing a new list of lines, where one of the lines has a new price. You can see that immutability causes a ripple effect in a data structure, whereby changing one low-level component can force changes to higher-level components too."
where Order and OrderLine are defined in the book like this:
type Order = {
Id : OrderId // id for entity
CustomerId : CustomerId // customer reference
ShippingAddress : ShippingAddress
BillingAddress : BillingAddress
OrderLines : OrderLine list
AmountToBill : BillingAmount
}
and OrderLine = {
Id : OrderLineId // id for entity
OrderId : OrderId
ProductCode : ProductCode
OrderQuantity : OrderQuantity
Price : Price
}
I'm not fully convinced of having the Order
aggregate having a changeOrderLinePrice
function that takes an orderLineId
as one of its parameters... isn't this forcing the clients using this function to know what the id of the order line is in order to change the price?
Also, are we sure that OrderLine
should be modelled as an Entity? Can we consider it instead a ValueObject composed by a ProductCode
, OrderId
, Quantity
and Price
?
ChatGPT 4o response
This is a thoughtful observation, and you’re not wrong to question the design. The approach Scott takes in the book is largely focused on demonstrating how immutability works with hierarchical aggregates and how changes ripple through the entire structure. However, the design decision to require an
orderLineId
as an input does have trade-offs, and your concern about clients needing to know theorderLineId
is valid.Let’s explore this further by unpacking the design considerations and potential alternatives.
Why use
orderLineId
as a parameter?OrderLine
is modeled as an entity, it inherently has a unique identifier (OrderLineId
). Using theorderLineId
to locate the specificOrderLine
aligns with the entity design. This is a way to ensure that you’re targeting a specific instance within the aggregate.orderLineId
in the function signature, the logic for identifying which order line to change is externalized. The client must “know” which line needs to be updated beforehand, and the function itself is only responsible for performing the update.OrderLine
) propagate up through the hierarchy (in this case, creating a newOrder
). This approach makes it explicit that the operation affects the entireOrder
aggregate and avoids direct mutation of theOrderLine
.The downside of exposing
orderLineId
in the APIYour critique highlights one of the trade-offs: exposing
orderLineId
in the API can tightly couple the client to the internal structure of theOrder
. This forces the client to:Order
object to discover whichOrderLineId
to use, orOrderLineId
ahead of time.This design can create a bit of friction for the client and arguably leaks some implementation details of the
Order
aggregate.Alternatives to the current design
Instead of requiring the
orderLineId
as a parameter, an alternative design that address your concern would be to provide higher-level domain operations: if exposingorderLineId
orOrderLine
still feels like leaking details, you could encapsulate more of the logic into higher-level domain operations.For example:
Here, instead of requiring
orderLineId
, the operation allows the client to specify theProductCode
(a business-friendly identifier) to locate the relevantOrderLine
. This is closer to the ubiquitous language and may better match the intent of the domain.Choosing the right approach
The design choice depends on the domain and the trade-offs you’re willing to make:
orderLineId
) works well.ProductCode
) or higher-level operations can improve usability.Closing thoughts
Your critique highlights a key challenge in designing aggregates: balancing encapsulation and usability. Scott’s example focuses on demonstrating how immutability propagates changes, but domain models should always reflect the needs of the business domain and the ubiquitous language. If requiring
orderLineId
doesn’t feel right for your domain, it’s worth exploring these alternative approaches to find a better fit.