Domains Run Amok

8 minute read

I am a huge fan of Domain Driven Design and I have been trying to apply it more and more. I ran into a problem last week that kept beating me over the head though. I kept using a bottom up approach and kept coming up with terrible solutions. Finally, I took a more outside to in approach which cleaned up the solution. I credit Mark Seemann for the idea to work from the outside in. I am wanting to show some of the difficulties you can run into using a bottom up approach so that others don’t make the same mistakes that I did. Hopefully this little exercise helps provide others some guidance on how to get unstuck when attempting Domain Driven Design.

Our Refactoring Problem

I have a project where we are rebuilding how we calculate the replenishment logic for our Supply Chain. Replenishment is the process of ordering product from Vendors for your Warehouses so that we can fill customer orders. I work for an e-commerce company so Replenishment is at the heart of what we do.

The current solution is a monolith application which is all fed from an Azure SQL instance. It is comprised of a large set of batch process that run in order and populate tables in the database. This mess was inherited from an old system and has been warped beyond comprehension at this point. It is so fragile we don’t dare touch it. The plan is to decompose the monolith into separate services which communicate via messages. To do this though, we need to create those separate services. At the heart of one of those services is the analysis of Time Series data. This is my attempt to create a tiny little domain for modeling this analysis and the mistakes I made along the way.

Modeling TimeSeries Take 1: From the Bottom Up

All of our Replenishment logic is built on analyzing Time Series data. This data can be thought of as a array of tuples where one value is the timestamp and the other is the observed value, DateTimeOffset * 'a.

What I set out to do is create a domain model that allows us to analyze these Time Series in a robust and performant way. My initial thought was, “I know that my data will always be Decimal or String so I can think of an ObservedValue in my Time Series as a Discriminated Union and an Observation is a record with a DateTimeOffset and an ObservedValue. A TimeSeries is just an array of the type Observation. When I am done with an analysis the result will be either decimal or string so I’ll define an AnalysisResult type to contain the result.”

type ObservedValue =
    | Decimal of decimal
    | String of string

type Observation = {
    DateTime : DateTimeOffset
    Value : ObservedValue
}

type TimeSeries = array<Observation>

type AnalysisResult =
    | Decimal of decimal
    | String of string

This doesn’t seem bad so far. Now I need to add some basic functions for analyzing my TimeSeries. Some simple and obvious ones are mean, first, and last. There are actually many functions I will need but these will suffice to make my point. I now try to write these simple functions for my TimeSeries type.

module TimeSeries =
    let private create observedType t : TimeSeries =
        t
        |> Seq.map (fun (t, v) -> {DateTime = t; Value = observedType v})
        |> Seq.toArray

    let fromDecimal s : TimeSeries =
        create ObservedValue.Decimal s

    let fromString s : TimeSeries =
        create ObservedValue.String s

    let first (ts : TimeSeries) =
        ts.[0].Value

    let last (ts : TimeSeries) =
        ts.[-1].Value

    let mean (ts : TimeSeries) =
        ts
        |> Array.averageBy (fun x -> x.Value) // Error: The type ObservedValue does not support the operator '+'

I have encountered my first problem with this approach. I want to be able to take the mean of my TimeSeries but the ObservedValue type does not support the + operator. I think, “No problem, I’ll just add the + operator.” I then look at the type again and realize I may be doing something wrong. Adding a decimal to a decimal makes sense and I also understand adding string to string but this is going to require me to have a + defined for decimal to string and string to decimal. That does not make any sense.

Modeling TimeSeries Take 2: Homogenous Values

My problem is that I am allowing a single TimeSeries to be heterogenous, containing both decimal and string values. Really a single TimeSeries needs to be homogeneous, containing only decimal or only string. Okay, no problem! I’ll reformulate the domain to have the TimeSeries be a Discriminated Union instead of the ObservedValue.

open System

type Observation<'a> = {
    DateTime : DateTimeOffset
    Value : 'a
}

type TimeSeries =
    | Decimal of array<Observation<decimal>>
    | String of array<Observation<string>>

type AnalysisResult =
    | Decimal of decimal
    | String of string

Now let’s try to implement our analysis functions again. Don’t judge me for what you see next. Once I wrote it, I felt a little ill. I’ll go into why after the code.

module TimeSeries =
    let private create observedType t : TimeSeries =
        t
        |> Seq.map (fun (t, v) -> {DateTime = t; Value = v})
        |> Seq.toArray
        |> observedType

    let fromDecimal t =
        create TimeSeries.Decimal t

    let fromString t =
        create TimeSeries.String t

    let private map df sf ts =
        match ts with
        | TimeSeries.Decimal t -> df t
        | TimeSeries.String t -> sf t

    let first (ts : TimeSeries) =
        let f = fun (t : array<Observation<'a>>) -> t.[0].Value
        map (f >> AnalysisResult.Decimal) (f >> AnalysisResult.String) ts

    let last (ts : TimeSeries) =
        let f = fun (t : array<Observation<'a>>) -> t.[-1].Value
        map (f >> AnalysisResult.Decimal) (f >> AnalysisResult.String) ts

    let mean (ts : TimeSeries) =
        let df =
            fun (t : array<Observation<decimal>>) ->
                t |> Array.averageBy (fun x -> x.Value) |> AnalysisResult.Decimal
        let sf =
            fun (t : array<Observation<string>>) -> "" |> AnalysisResult.String
        map df sf ts

I will readily admit this is clunky. Let me explain the thought process. I know that the TimeSeries type is a Discriminated Union and therefore I should have a map like function for easily applying the correct function, depending on which value TimeSeries takes on. In many cases I would use the exact same logic (Ex: first and last) so I just defined a generic function and used that for both arguments of the map function.

When I get to the mean function I run into another problem. It does not make sense to take the mean of a set of string observations but the code allows it. In this code I am returning an empty string but that is not in line with the heart of what I am going for. If something does not make sense, I don’t want to allow it. I want invalid states to be unrepresentable in the code. I don’t want myself or someone else to even be able to call mean with a TimeSeries containing string values.

It’s at this point I start to feel really dumb. How can this be so hard? Here is what I am wanting to accomplish:

  • Model a TimeSeries made up of either decimal or string
  • Reuse function logic wherever I can (DRY principle)
  • Prevent unrepresentable states

Modeling TimeSeries Take 3: Generic TimeSeries

I would rather not admit how long I was stumped at this point. It felt like I was missing something glaringly obvious. I mulled on this problem for awhile until the next thought came to me, “What is really going on is that I have two special cases of TimeSeries<'a> here. I have a TimeSeries<decimal> and a TimeSeries<string>. Why not have a full set of functions for TimeSeries<'a> and then have two different types for the TimeSeries<decimal> case and the TimeSeries<string> case which only have a subset of the functions available?” Here is what I came up with.

open System

type Observation<'a> = {
    DateTime : DateTimeOffset
    Value : 'a
}

type TimeSeries<'a> = array<Observation<'a>>

type DecimalSeries = TimeSeries<decimal>

type StringSeries = TimeSeries<string>

type AnalysisResult =
    | Decimal of decimal
    | String of string

module TimeSeries =
    let private create t : TimeSeries<'a>=
        t
        |> Seq.map (fun (t, v) -> {DateTime = t; Value = v} )
        |> Seq.toArray

    let private first (ts : TimeSeries<'a>) =
        ts.[0].Value

    let private last (ts : TimeSeries<'a>) =
        ts.[-1].Value

    let inline private mean (ts : TimeSeries<'a>) =
        ts
        |> Array.averageBy (fun x -> x.Value)

    module DecimalSeries =
        let create t : DecimalSeries =
            create t

        let first (ds : DecimalSeries) =
            first ds |> AnalysisResult.Decimal

        let last (ds : DecimalSeries) =
            last ds |> AnalysisResult.Decimal

        let mean (ds : DecimalSeries) =
            mean ds |> AnalysisResult.Decimal

    module StringSeries =
        let create t : StringSeries =
            create t

        let first (ds : StringSeries) =
            first ds |> AnalysisResult.String

        let last (ds : StringSeries) =
            last ds |> AnalysisResult.String

One thing to note, I had to add the keyword inline to the mean function in the TimeSeries module. This makes the compiler figure out the types at the point the function is used. Now I am still not really proud of this code yet but it is accomplishing most of my goals. I am getting code reuse while being able to control which functions can be used by which type of TimeSeries. Since I only define a create function for the DecimalSeries and StringSeries types, I don’t have to fear someone creating a random TimeSeries<'a> if they follow the convention of using the create function. The functions for TimeSeries are also private and can only be called from the sub-modules DecimalSeries and StringSeries.

Conclusion

I hope my failures prove useful and an encouragement to others wandering through the process of learning Domain Driven Design. This was just one small problem that made me feel rather silly as I wrestled with it. Maybe I will come up with a more elegant solution but as of now, I like the code reuse and guarantees this is providing me. If you have a better solution, please message me on Twitter (@McCrews). When you get stuck coding, remember most of progress feels like wandering down dark halls until you come to the light. Keep calm and curry on!

Updated: