Modeling Domain Logic With Algebraic Data Types

There are a number of ways one might approach designing the logic of a domain problem. Most enterprise software engineers may be accustomed to modeling the problem using object-oriented programming, where classes encapsulate behavior and data. In this approach, the design often reflects the use of various design patterns, such as mediator, factory, visitor, and repository. When visualized, the resulting design might resemble an Entity-Relationship Diagram (ERD) that shows how classes relate to each other and the flow of control between them. However, when sharing this design with domain experts, there is a risk of details getting lost within the technical terminology of the patterns or the intricacies of the relationships among the many layers.

In the object-oriented approach, what we often think of as 'types' tend to be interfaces, which are more of a consequence of the design rather than the driving force behind it. In this context, types, or interfaces, serve to encourage polymorphism, extensibility, and testability of the codebase. However, they are not the primary driver of the design, which can lead to a different focus in the modeling process.

The algebraic type-driven approach, on the other hand, differs from the OOP-driven one in that it prioritizes representing the business domain from the outset. In this approach, domain logic is designed so that both the raw domain concepts and the logic that follows from them are accurately reflected. This enables a more natural and intuitive understanding of the domain, making it easier for developers and domain experts to communicate effectively and maintain the codebase.

Before diving in, let's establish what I mean by algebraic data types. Algebraic data types are a composable way of modeling data structures in functional programming languages. They consist of two key components: product types and sum types.

  • Product types represent combinations of other types, where each element of the combination is present. Records and tuples are examples of product types.
  • Sum types represent a choice between different types, where only one element of the choice is present at a time. Discriminated unions are examples of sum types.

In this article, we will focus on using algebraic data types in F#. However, it's worth noting that other languages, like TypeScript, also lend themselves well to using algebraic data types. In TypeScript, for example, you can use interfaces and type unions to achieve similar modeling capabilities.

It is also worth mentioning that even if a language does not support algebraic data types in code, it can still be worthwhile to model domain logic in a type-driven way similar to what is demonstrated in this post, even if the design doesn’t translate directly to code. The main advantage of adopting a type-driven approach, regardless of the language, is that it encourages clear communication of domain concepts and logic among developers and domain experts. By focusing on the types and their relationships, you can create a more robust and comprehensible model of the domain problem, which can help prevent bugs, facilitate easier code maintenance, and improve collaboration within the team.

Avoiding Old Habits

Imagine we have a domain concept called an "Assignment" that we need to model. Upon discussing the requirements, one might be tempted to immediately visualize the layers of the software and start designing the solution. "OK, so we'll need a new controller, a new repository, and we'll need to make sure this inherits from X...". While this approach might be common and could potentially work, it's not the most effective way to model domain concepts, especially when working with F# and algebraic data types.

In type-driven design, we should resist the urge to dive straight into the architectural layers and relationships between classes. Instead, we should focus on accurately representing the domain concepts and their relationships through a clear and expressive type system. This approach allows us to create a more robust and comprehensible model of the domain problem, which can help prevent bugs, facilitate easier code maintenance, and improve collaboration within the team.

By focusing on the types first, we ensure that our design accurately captures the essential aspects of the domain, making it more natural and intuitive for both developers and domain experts. Once we have a solid understanding of the types and their relationships, we can then proceed to implement the necessary components and layers to support the domain logic. In F#, this often results in a much easier implementation and a more concise and functional codebase, which can be easier to maintain and extend.

Designing Assignment with Types

We've suppressed the urge to design according to our architectural layers. Now we can start modeling with the domain expert. Once we've gone back and forth to understand the essence of an assignment, we can begin with a simple record (product type).

type Assignment = {
    Title: string
    Description: string
    DueDate: DateTime
}

This is a good start, but it's not enough for both the developer and the domain expert to walk away confident that we fully understand the feature. Next, we ask the domain expert about the assignment statuses. What are the possible states? Based on their input, we might come up with something like this:

type AssignmentStatus =
    | NotStarted
    | InProgress
    | Completed
    | Overdue

Now that we have a clearer understanding of the possible assignment states, we can incorporate this new information into our existing Assignment type:

type Assignment = {
    Title: string
    Description: string
    DueDate: DateTime
    Status: AssignmentStatus
}

With this updated model, we've now captured that the Assignment can only be in one of 4 states at any time, and in F#, since this is functioning code, we've given ourselves type safety which enforces that we handle all of these scenarios each time we encounter them.

Further discussion with the domain expert reveals that we want to think of DueDate in specifically three states, based on the DueDate value. As a result of that conversation, we define a new type for that state and utilize active pattern matching to cover the rules:

type DeadlineStatus =
    | NotDue
    | Due
    | Overdue

let (|NotDue|Due|Overdue|) assignment currentTime =
    if currentTime < assignment.DueDate then
        NotDue
    elif currentTime = assignment.DueDate then
        Due
    else
        Overdue

After discussing the rules around AssignmentStatus transitions with the domain expert, we might end up with the following state transition diagram:

NotStarted -> InProgress
InProgress -> Completed
InProgress -> Canceled
type HasNotStarted = HasNotStarted of Assignment
type IsInProgress = IsInProgress of Assignment
type IsCompleted = IsCompleted of Assignment
type IsOverdue =
    | OverdueInProgress of Assignment
    | OverdueNotStarted of Assignment

type AssignmentTransition =
    | ToInProgress of HasNotStarted
    | ToCompleted of IsInProgress
    | ToOverdue of IsOverdue

With this addition we define the state transitions that can happen in a type-safe way. In the design it communicates the transitions in a readable way, and in code it will enforce handling of each transition and also that the contents of each transition is the proper type.

Putting it all together

Here is the full design of the Assignment type-driven design session.

type AssignmentStatus =
    | NotStarted
    | InProgress
    | Completed
    | Overdue
    
type Assignment = {
    Title: string
    Description: string
    DueDate: DateTime
    Status: AssignmentStatus
}

type DeadlineStatus =
    | NotDue
    | Due
    | Overdue

let (|NotDue|Due|Overdue|) assignment currentTime =
    if currentTime < assignment.DueDate then
        NotDue
    elif currentTime = assignment.DueDate then
        Due
    else
        Overdue

type HasNotStarted = HasNotStarted of Assignment
type IsInProgress = IsInProgress of Assignment
type IsCompleted = IsCompleted of Assignment
type IsOverdue =
    | OverdueInProgress of Assignment
    | OverdueNotStarted of Assignment

type AssignmentTransition =
    | ToInProgress of HasNotStarted
    | ToCompleted of IsInProgress
    | ToOverdue of IsOverdue

From this design we can assertain the following domain logic from the types.

  1. What is an assignment? What does it look like?
  2. All the possible statuses of an assignment
  3. We've captured that, even though the DueDate is a System.DateTime, it conforms to three states.
  4. How statuses transition from one state to the next, and which statuses can trasition to each.

There are a number of benefits to this type-driven design. It clearly and concisely communicates, not only the concept of what an assignment is, but business logic around the assignment. It also does so in a way that is expressive and readable by a domain expert - they only need to be familiar with very few coding concepts to know what this design expresses. Finally, in F#, this is functioning code. Although the implementation is not yet done, the types themselves provide effective guardrails during implemention - enforcing that each pathway of the disciminated unions be addressed.

Although this example uses F# as the type-driven approach, I encourage this approach even if it is not functioning code in your language. TypeScript has an impressive structural implementation of algebraic data types. TypeScript is used in many companies, so the likelihood that design notation in TS would make sense in codebases that are not written in it is fairly high. Alternatively, an agreed-upon psuedo-code notation of algebraic data types could be useful for modeling domain logic.