Let's expand on the previous User
class example. Let our user be a customer of an online store.
Once a customer finalizes an order, any post-dispatch modifications risk integrity breaches. It's essential to ensure the order data's privacy and integrity.
public class User
{
// ...
public IEnumerable<Order> Orders { get; init; }
}
public partial class Order
{
public bool IsValidated { get; init; }
public decimal? Price { get; init; }
public IEnumerable<OrderItem> OrderItems { get; init; }
}
For instance, consider the typical procedures related to an order: verifying warehouse inventory, processing shipments, and handling cancellations.
Is it necessary to confirm inventory before processing payment?
That hinges on specific business rules.
public partial class Order
{
// Validated
public bool IsValidated { get; init; }
public decimal? Price { get; init; }
}
Can we ship an order prior to receiving payment?
Some retailers permit payment upon delivery, especially for local shipments.
public partial class Order
{
// Shipped
public bool IsShipped { get; init; }
public string? TrackingUrl { get; init; }
}
And what if a delivered order needs to be canceled?
Perhaps our system requires a more nuanced status than merely "canceled." A "dispute" status might be more apt, prompting further inquiries: Was the item damaged en route? Was there an error at the warehouse? Or did the customer mistakenly receive what they actually ordered?
public partial class Order
{
// Cancelled
public bool IsCancelled { get; init; }
public string? CancellationReason { get; init; }
}
Problem statement
public class User
{
// ...
public IEnumerable<Order> Orders { get; init; }
}
public class Order
{
// Validated
public bool IsValidated { get; init; }
public decimal? Price { get; init; }
public IEnumerable<OrderItem> OrderItems { get; init; }
// Shipped
public bool IsShipped { get; init; }
public string? TrackingUrl { get; init; }
// Cancelled
public bool IsCancelled { get; init; }
public string? CancellationReason { get; init; }
}
The intricacies related to specific order states are only meaningful within their respective contexts. In C#, the class design might not always explicitly indicate the relationship between an order's status and its content. This singular class attempts to capture diverse order states, leading to potential ambiguities. How can we better encapsulate these states and the associated business logic?
Discriminated Unions
F# offers discriminated unions (DU) to address such challenges:
type ValidatedOrder = {
Price: decimal
}
type ShippedOrder = {
TrackingUrl: string
}
type CancelledOrder = {
CancellationReason: string
}
type Order =
| New
| Validated of ValidatedOrder
| Shipped of ShippedOrder
| Cancelled of CancelledOrder
DUs are akin to enhanced enums in F#, allowing encapsulation of distinct states and their associated data. They're typically used with pattern matching for clarity.
let matchOrder order =
match order with
| Shipped s -> $"Tracking URL: {s.TrackingUrl}"
| Cancelled c -> $"Reason: {c.CancellationReason}"
"Closed" Type Hierarchies
For those rooted in C#, the discriminated unions
proposal has been around for quite some time. Apparently, it's not going to
make it in C#12 mainly because of several important discussion threads:
- Proposal: Add completeness checking to pattern matching draft specification roslyn #188
- Proposal: "Closed" type hierarchies #485
- Discussion: Unions/closed hierarchies/ADTs, which one does C# 7.1+ need & what would the spec look like? #75
The "discriminated unions" and the "closed type hierarchies" are both viable language design alternatives and potent implementation options for the Order
class. In this specific case, we even don't need the hierarchy to be "closed" Let's check the difference between the DU and the hierarchy approaches:
public abstract class OrderBase
{
public IEnumerable<OrderItem> OrderItems { get; init; }
}
public class ValidatedOrder : OrderBase
{
public decimal? Price { get; init; }
}
public class ShippedOrder : OrderBase
{
public string? TrackingUrl { get; init; }
}
public class CancelledOrder : OrderBase
{
public string? CancellationReason { get; init; }
}
Coincidentally, that's how EF core handles hierarchies by default.
Control Flow
The biggest difference between these two implementation options is how the control flow is organized. Discriminated unions use pattern matching while closed type hierarchies rely on good, old polymorphism.
Some people will claim the former option is more "functional" while the latter is more "object-oriented". Practically speaking, C# switch
statements/expressions are, unlike F# match
, not exhaustive. One of the main advantages of "closed" hierarchies is that they give us a list of all derived types at the compile time.
Concluding Thoughts
Whether using Discriminated Unions in F# or exploring Closed Type Hierarchies in C#, the goal remains: clear, maintainable, and robust code that accurately represents business logic and reduces room for errors. Leveraging the best of both functional and object-oriented paradigms can lead to superior code quality.
Top comments (0)