Skip to content

Instantly share code, notes, and snippets.

@xpepper
Last active January 26, 2025 15:14
Show Gist options
  • Save xpepper/bed195b4e7b6090a52db2bd2db45ab5a to your computer and use it in GitHub Desktop.
Save xpepper/bed195b4e7b6090a52db2bd2db45ab5a to your computer and use it in GitHub Desktop.
Domain Modeling Made Functional - chapter 5 - order and order line

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?

@xpepper
Copy link
Author

xpepper commented Jan 23, 2025

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?

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 the orderLineId is valid.

Let’s explore this further by unpacking the design considerations and potential alternatives.

Why use orderLineId as a parameter?

  1. Entity identity: Since OrderLine is modeled as an entity, it inherently has a unique identifier (OrderLineId). Using the orderLineId to locate the specific OrderLine aligns with the entity design. This is a way to ensure that you’re targeting a specific instance within the aggregate.
  2. Decoupling the logic: By requiring the 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.
  3. Immutability and aggregates: As the book emphasizes, immutability means that changes to lower-level components (like OrderLine) propagate up through the hierarchy (in this case, creating a new Order). This approach makes it explicit that the operation affects the entire Order aggregate and avoids direct mutation of the OrderLine.

The downside of exposing orderLineId in the API

Your critique highlights one of the trade-offs: exposing orderLineId in the API can tightly couple the client to the internal structure of the Order. This forces the client to:

  • Traverse the Order object to discover which OrderLineId to use, or
  • Already know the OrderLineId 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 exposing orderLineId or OrderLine still feels like leaking details, you could encapsulate more of the logic into higher-level domain operations.

For example:

let changeOrderLinePriceByProductCode order productCode newPrice =
    let newOrderLines =
        order.OrderLines
        |> List.map (fun ol ->
            if ol.ProductCode = productCode then
                { ol with Price = newPrice }
            else
                ol
        )
    { order with OrderLines = newOrderLines }

Here, instead of requiring orderLineId, the operation allows the client to specify the ProductCode (a business-friendly identifier) to locate the relevant OrderLine. 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:

  • If you prioritize clear boundaries and don’t want clients modifying internal structures, the original approach (requiring orderLineId) works well.
  • If you want to simplify the API and reduce friction for clients, passing higher-level domain parameters (like 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.

@xpepper
Copy link
Author

xpepper commented Jan 23, 2025

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 an excellent question, and it gets to the heart of domain modeling: deciding whether a concept should be modeled as an Entity or a Value Object. Let’s examine whether OrderLine should really be treated as an entity in the example.

Recap: Entity vs. Value Object

  1. Entities are uniquely identifiable and typically have an identity that persists over time, even as their attributes change. For example, an Order is an entity because it has a unique OrderId that identifies it regardless of how its contents or metadata (e.g., shipping address) change.
  2. Value Objects, on the other hand, are immutable and defined entirely by their attributes. They don’t have an inherent identity. For example, an address might be a value object; two identical addresses would be treated as indistinguishable.

The key question to ask:

Does the domain care about the identity of an OrderLine beyond its attributes? Or can it be uniquely identified by the combination of its attributes?

Is OrderLine an Entity?

In the book, OrderLine is modeled as an entity, but whether this makes sense depends on your domain:

Arguments for OrderLine as an Entity:

  1. Independent Lifecycle: If the OrderLine has a lifecycle of its own (e.g., it can be modified, tracked, or deleted independently of the Order), it’s a strong candidate for an entity. For example:
  • If you need to track updates to the OrderLine (e.g., when the quantity was changed, by whom, etc.).
  • If the OrderLine has its own business processes (e.g., workflows like inventory reservations or backorders).
  1. Identity Matters: If your domain explicitly tracks OrderLineId (e.g., for auditing or integration with other systems), the line’s identity becomes important. In this case, OrderLine being an entity makes sense.
  2. Uniqueness Beyond Attributes: If two OrderLines with the same ProductCode, Quantity, and Price are still treated as distinct (e.g., they represent separate transactions), then OrderLine needs an identity beyond its attributes.

Arguments for OrderLine as a Value Object:

  1. Defined Solely by Attributes: If the domain treats OrderLine as entirely defined by its attributes (ProductCode, OrderQuantity, Price, etc.), there’s no need for a unique identity. Two OrderLines with the same values are indistinguishable, making them Value Objects.
  2. Immutable by Nature: Value Objects work best in immutable models. If OrderLine is immutable, any change (e.g., to OrderQuantity) would result in replacing the old OrderLine with a new one. The identity is irrelevant because changes propagate through the Order aggregate.
  3. No Independent Lifecycle: If OrderLine exists purely as part of the Order aggregate and has no meaningful existence outside of it, modeling it as a Value Object simplifies the design.
  4. Simpler Domain Logic: Treating OrderLine as a Value Object removes the need to manage an additional ID (OrderLineId). Instead, its uniqueness within the Order can be enforced by a combination of ProductCode and possibly OrderId.

When does OrderLine work better as a Value Object?

Example Scenario

Imagine an e-commerce system where:

  • An Order is essentially a collection of OrderLines, and the OrderLine is just a way to represent a product (ProductCode) and its associated quantity and price.
  • The system doesn’t track OrderLine independently (e.g., it doesn’t need to log specific changes to OrderLine attributes or treat them as distinct entities).
  • Changes to OrderLine always flow through the Order aggregate.

In this case, OrderLine can be modeled as:

type OrderLine = {
    ProductCode : ProductCode
    OrderQuantity : OrderQuantity
    Price : Price
}

The Order would then manage OrderLine as a collection of Value Objects:

type Order = {
    Id : OrderId
    CustomerId : CustomerId
    ShippingAddress : ShippingAddress
    BillingAddress : BillingAddress
    OrderLines : OrderLine list
    AmountToBill : BillingAmount
}

When updating an OrderLine (e.g., changing its price), you simply replace the old OrderLine with a new one, maintaining immutability:

let updateOrderLinePrice order productCode newPrice =
    let newOrderLines =
        order.OrderLines
        |> List.map (fun ol ->
            if ol.ProductCode = productCode then
                { ol with Price = newPrice }
            else
                ol
        )
    { order with OrderLines = newOrderLines }

Trade-offs of Treating OrderLine as a Value Object

  • Simpler model: You avoid the need to manage OrderLineId, which reduces complexity and keeps the aggregate focused.
  • Immutability-friendly: Changes to OrderLine naturally propagate through the aggregate, which aligns well with the immutable data structure principles in functional programming.
  • Domain alignment: If your domain doesn’t care about the OrderLine’s identity, treating it as a Value Object better matches the ubiquitous language.

When does OrderLine need to be an Entity?

There are cases where OrderLine should retain its identity:

  • Auditing: If your system needs to track changes to an OrderLine over time (e.g., who changed the quantity or price, and when), then OrderLineId becomes necessary to distinguish between instances.
  • Business Rules: If there are rules that explicitly depend on OrderLineId (e.g., references in external systems), you need to preserve its identity.
  • Complex Operations: If OrderLine is involved in complex workflows, like inventory management or shipping logistics, treating it as an entity can make the model more expressive.

So, whether OrderLine should be an Entity or a Value Object depends entirely on the domain requirements and how the ubiquitous language describes it. If the identity of the OrderLine matters in your domain, stick with it as an entity. But if the OrderLine is just a compositional part of the Order and defined solely by its attributes, modeling it as a Value Object simplifies the design and aligns better with immutability principles.

@xpepper
Copy link
Author

xpepper commented Jan 23, 2025

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?

DeepSeek response

Your concerns about the design choices in the book "Domain Modeling Made Functional" are valid and worth exploring. Let's break down the issues you raised:

1. OrderLine as an Entity vs. Value Object

Entity

An Entity is defined by its identity, which remains consistent over time, even if its attributes change. In the context of an OrderLine, if you consider it an Entity, it implies that the OrderLine has a unique identity (OrderLineId) and can change its attributes (like Quantity or Price) while still being the same OrderLine.

Value Object

A Value Object, on the other hand, is defined by its attributes and does not have a conceptual identity. If two Value Objects have the same attributes, they are considered equal. If you model OrderLine as a Value Object, it would mean that any change to its attributes (like Quantity or Price) would result in a completely new OrderLine.

Which one to choose?

  • Entity: If you need to track changes to an OrderLine over time or if the OrderLine has a lifecycle that is important to your domain, then modeling it as an Entity makes sense. For example, if you need to maintain a history of changes to an OrderLine or if the OrderLine is referenced by other parts of the system, it should be an Entity.

  • Value Object: If the OrderLine is simply a collection of attributes that describe a product in an order and you don't need to track changes to it individually, then modeling it as a Value Object might be more appropriate. In this case, changing any attribute of the OrderLine would result in a new OrderLine instance.

2. changeOrderLinePrice Function and OrderLineId

The function changeOrderLinePrice takes an orderLineId as a parameter, which implies that the client needs to know the OrderLineId to change the price. This design choice has implications:

  • Coupling: This design couples the client to the internal structure of the Order aggregate. The client needs to know the OrderLineId to make changes, which might not be ideal if the client should only interact with the Order aggregate without knowing its internal details.

  • Alternative Design: Instead of exposing the OrderLineId to the client, you could design the Order aggregate to handle changes internally. For example, you could have a method on the Order aggregate that takes a ProductCode and a newPrice, and the Order aggregate would find the corresponding OrderLine and update its price. This way, the client doesn't need to know about the OrderLineId.

3. Immutability and Ripple Effects

The book emphasizes immutability, which is a common practice in functional programming. When you change an OrderLine, you create a new OrderLine instance, and consequently, a new Order instance with the updated OrderLine. This ripple effect is inherent in immutable data structures and ensures that the entire aggregate remains consistent.

4. Revised Design Considerations

Given your concerns, here’s how you might reconsider the design:

Option 1: OrderLine as a Value Object

If you model OrderLine as a Value Object, you would not need an OrderLineId. Instead, the OrderLine would be identified by its attributes (ProductCode, OrderId, Quantity, Price). Changing any attribute would result in a new OrderLine.

type OrderLine = {
  ProductCode : ProductCode 
  OrderId : OrderId
  OrderQuantity : OrderQuantity 
  Price : Price
}

In this case, the changeOrderLinePrice function would not need an OrderLineId:

let changeOrderLinePrice order productCode newPrice =
    let newOrderLines =
        order.OrderLines
        |> List.map (fun line ->
            if line.ProductCode = productCode then
                { line with Price = newPrice }
            else
                line)
    { order with OrderLines = newOrderLines }

Option 2: OrderLine as an Entity with Encapsulation

If you decide to keep OrderLine as an Entity, you could encapsulate the logic within the Order aggregate to avoid exposing OrderLineId to the client:

let changeOrderLinePrice order productCode newPrice =
    let newOrderLines =
        order.OrderLines
        |> List.map (fun line ->
            if line.ProductCode = productCode then
                { line with Price = newPrice }
            else
                line)
    { order with OrderLines = newOrderLines }

In this design, the client only needs to know the ProductCode and the new price, not the OrderLineId.

Conclusion

The choice between modeling OrderLine as an Entity or a Value Object depends on the specific requirements of your domain. If you need to track changes to individual OrderLines or if they have a lifecycle, then modeling them as Entities makes sense. However, if OrderLines are simply descriptive and do not need to be tracked individually, modeling them as Value Objects might be more appropriate.

Additionally, consider encapsulating the logic within the Order aggregate to avoid exposing internal details like OrderLineId to the client, which can lead to a more maintainable and decoupled design.

@xpepper
Copy link
Author

xpepper commented Jan 23, 2025

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?

DeepSeek R1 response

The decision to model OrderLine as an Entity or Value Object hinges on the domain requirements and the lifecycle of the OrderLine. Let’s break down the trade-offs:


Option 1: OrderLine as a Value Object

If OrderLine is a Value Object, it has no identity and is defined solely by its attributes:
ProductCode, OrderId, Quantity, and Price. Any change to these attributes creates a new OrderLine.

Pros:

  1. Simplicity: No need to manage an OrderLineId or track identity.
  2. Immutability: Aligns with functional programming principles—data is never modified in-place.
  3. Domain Alignment: Works if OrderLine is merely a collection of attributes (e.g., in a read-only order).

Cons:

  1. No Tracking: Changes to individual lines (e.g., updating a price) cannot be tracked over time, as there’s no identity to reference.
  2. Ambiguity Risk: If two lines have identical attributes (e.g., same ProductCode and Price), they are indistinguishable.
  3. Client Coupling: To modify a line, the client must know all its attributes (e.g., ProductCode), which may not be reliable or practical.

Example:

type OrderLine = {
  ProductCode : ProductCode
  OrderId : OrderId
  Quantity : OrderQuantity
  Price : Price
}

// To update a line, replace it entirely in the Order's list:
let changePrice order productCode newPrice =
  { order with
      OrderLines =
        order.OrderLines
        |> List.map (fun line ->
            if line.ProductCode = productCode then
              { line with Price = newPrice } // New Value Object
            else line)
  }

Option 2: OrderLine as an Entity

If OrderLine is an Entity, it has a unique OrderLineId that persists even when its attributes change. This is the approach in the book.

Pros:

  1. Identity: Uniquely reference and track individual lines, even if attributes change.
  2. Explicit Updates: Clients can modify specific lines using OrderLineId, avoiding ambiguity.
  3. Auditability: Track lifecycle events (e.g., price changes) for individual lines.

Cons:

  1. Complexity: Requires managing OrderLineId and ensuring it’s propagated correctly.
  2. Overhead: May be unnecessary if lines never change after creation.

Example:

type OrderLine = {
  Id : OrderLineId // Unique identifier
  ProductCode : ProductCode
  OrderId : OrderId
  Quantity : OrderQuantity
  Price : Price
}

// To update a line, use its Id:
let changePrice order lineId newPrice =
  { order with
      OrderLines =
        order.OrderLines
        |> List.map (fun line ->
            if line.Id = lineId then
              { line with Price = newPrice } // Same Id, updated attributes
            else line)
  }

When to Choose Which Model?

Scenario Value Object Entity
OrderLines never change after creation ✅ Simpler, no need for IDs. ❌ Unnecessary complexity.
OrderLines can be modified ❌ Can’t track changes. ✅ Required for updates.
Need to reference lines uniquely ❌ Ambiguous if attributes match. ✅ Use OrderLineId for precision.
Audit/logging requirements ❌ No way to track individual lines. ✅ Track changes via OrderLineId.
Functional/immutable design ✅ Natural fit for immutability. ✅ Still works (immutable updates via IDs).

Recommendation

The book models OrderLine as an Entity because:

  1. Modifications are allowed: The example includes a changeOrderLinePrice function, implying lines can be updated.
  2. Precision: Using OrderLineId avoids ambiguity when multiple lines have the same ProductCode.
  3. Domain Realism: In most systems, order lines need to be referenced uniquely (e.g., for partial refunds, cancellations, or audits).

However, if your domain treats OrderLine as immutable (e.g., once an order is placed, lines cannot change), a Value Object could work. For example, in a system where orders are read-only snapshots after creation, you might omit OrderLineId.


Final Note

The choice depends on how the domain treats OrderLine:

  • Entity: If the domain cares about the lifecycle of individual lines (e.g., "Update the price of line #3").
  • Value Object: If the domain treats lines as static data (e.g., "These are the items in the order as it was placed").

The book’s design is appropriate for a domain where lines can evolve, but your critique highlights a valid consideration: always validate the model against real-world requirements.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment