Search code examples
typesfunctional-programmingf#domain-driven-designdiscriminated-union

How can you model a complex type in multiple abstract dimensions?


Background

I am trying to use the style of functional DDD laid out in "Domain Modeling Made Functional" by Scott Wlaschin. One particular point of interest to me is the concept of breaking apart a single type (in our case this will be a Payment) into separate types that more accurately represent the data available in the different "states" of the original type. In Scott's example on page 84 he separates an Order type into an UnvalidatedOrder type and a ValidatedOrder type. Thus you could describe an Order type as a union type as follows

type Order = 
  | UnvalidatedOrder of { ... }
  | ValidatedOrder of { ValidatedAt: DateTime; ... }

I am using an F#-like pseudocode here for brevity

This works well because the Order type here is only variant in "one dimension", that being the "Validation Dimension". So what if we have a type that varies in two "dimensions"?

The Example of a Payment

Let's consider the example of a Payment model that has the following requirements:

Requirement 1) Status of a Payment

Payments must be in a valid state (New, InProgress, or Complete). A Payment may be changed to be InProgress iff a Payment is New A Payment may be changed to be Complete iff a Payment is InProgress

Requirement 2) Payment Result

Payments that are Complete must have a valid result (Approved, Cancelled, Declined, Failed) An Approved Payment requires an ApprovedAmount A Cancelled, Declined, or Failed Payment requires a Reason

Requirement 3) Payment information

All Payments must have a unique Guid to track them All Payments must have a RequestedAmount on them

My attempt at modelling this looked as follows

type PaymentResult =
  | Approved of { ApprovedAmount: decimal }
  | Cancelled of { Reason: String; }
  | Declined of { Reason: String; }
  | Failed of { Reason: String; }

type PaymentStatus = 
  | New
  | InProgress of { StartedAt: DateTime; }
  | Complete of { 
      StartedAt: DateTime; 
      CompletedAt: DateTime; 
    }

type Payment = {
  Id: Guid;
  RequestedAmount: decimal;
  Status: PaymentStatus;
}

type StartPayment = (Status: PaymentStatus.New) (Now: DateTime) -> PaymentStatus.InProgress
type FinishPayment = (Status: PaymentStatus.InProgress) (Now: DateTime) (Result: PaymentResult) -> PaymentStatus.Complete

My concern about this attempt is that we are storing information about the Payment in the PaymentStatus type. In my understanding, the data that PaymentStatus contains should only be information about the PaymentStatus.

We can fix this problem by splitting the Payment type as Scott does and we get

type Payment =
  | NewPayment of { ... }
  | InProgressPayment of { StartedAt: DateTime; ... }
  | CompletedPayment of { 
      StartedAt: DateTime; 
      CompletedAt: DateTime; 
      Result: PaymentResult; 
      ... 
    }

This model seems to keep the data related to the Payment in Payment records.

Now what if we add the concept of being able to abandon a Payment, regardless of it's status.

If we were just modelling the abandoned Payments we would get

type Payment =
  | NonAbandonedPayment of { ... }
  | AbandonedPayment of { AbandonedAt: DateTime; ... }

However, in combining the two "dimensions", that of "Abandoned-ness" and "Status", I would have to create 6 separate types to represent all of the cases

type Payment =
  | NonAbanonedNewPayment
  | AbandonedNewPayment
  | NonAbandonedInProgressPayment
  | AbandonedInProgressPayment
  | NonAbandonedCompletePayment
  | AbandonedCompletePayment

This results in a cartesian product of the two "dimensions" of a Payment. This is already unwieldly and it is as simple a case as I can think of.

The Question

How would you go about representing these multiple dimensions of a type, given the premise that all information relevant to a type should be stored inside that type?

My current best answer is to abandon the premise in the question and simply define a PaymentStatus type and a AbandonedStatus type are used as properties in the single Payment type as given in my initial attempt in the example.

type AbandonedStatus =
  | NotAbandoned
  | Abandoned

type PaymentStatus =
  | New
  | InProgress
  | Complete

type Payment = {
  Status: PaymentStatus;
  AbandonedStatus: AbandonedStatus
}

Solution

  • I think you're very close. All you need to do is model each dimension fully, and then combine them in a record type, like this:

    type PaymentStatus =
      | New of { ... }
      | InProgress of { StartedAt: DateTime; ... }
      | Completed of { 
          StartedAt: DateTime; 
          CompletedAt: DateTime; 
          Result: PaymentResult; 
          ... 
        }
    
    type PaymentAbandonment =
      | NonAbandoned of { ... }
      | Abandoned of { AbandonedAt: DateTime; ... }
    
    type Payment =
      {
        Status : PaymentStatus
        Abandonment : PaymentAbandonment
      }
    

    Since your final Payment type has 3x2=6 states, it should definitely be a product type (i.e. record), rather than a sum type (i.e. union). These are algebraic data types.