Tagged with

Over the past couple years, I've become increasingly interested in two specific approaches to improving the reliability and maintainability of the programs I work on: domain-driven design and typed functional programming. I've also been convinced that the two of them work really well together.

So when I was recently tasked with adding support for a new feature in the billing flow in the app I work on, it seemed like a great opportunity to try to put those two ideas into practice. We always want every part of our app to work, and be readily maintained, of course - but getting it right, and making it easy both to add more functionality and to fix whatever bugs do show up, is especially important in the billing section. Users need to be able to pay for their orders! This has to work.

First, let's define these terms a bit. Then we'll see how I actually put these ideas into practice in the app.

Definitions

Domain-driven design is a complex subject, but the best way I know of to summarize it is: work to closely model the essential problem in the domain you're dealing with by defining the elements and concerns of the domain itself clearly and then using those definitions in your code itself. To pull that off, you have to carefully think through the boundaries of each domain and establish clear boundaries in your code to match, and you have to think hard about the appropriate language to use to describe the domain. There is a lot more to say here - books on the subject tend to be a thousand pages long! - but everything else falls out from those underlying principles. Done right, you get code that is easier to maintain. It is also easier to communicate about, not least because the domain experts and the programmers building the implementation can use the same language.

Typed functional programming is an equally hairy concept, but again, I'll summarize as best I can. Functional programming treats the major building blocks of a program as functions (as distinct from steps of execution or objects with behavior) which simply transform data, and which can be combined together - or composed - to create the final desired transformations from input to output. Typed functional programming adds onto this a compile-time type checking step, and usually comes with a type system that lets you express the constraints of your system in a rather different way from what you might be used to in traditional object-oriented typed languages. The goals of typed functional programming as I use it are twofold: to minimize the scope for any single function, and to clearly express the constraints and invariants I want in the system. Put in very practical terms: I want to make illegal states impossible, and I want it so there is only ever one place any given logic bug can be.

Putting them into practice

When I started in on this new feature, I figured out quickly that the existing implementation needed to be rewritten entirely. It had been hacked together in one of those caffeine-fueled we-just-have-to-ship-this hazes, and while it had gotten the job done, it wouldn't support any more complexity.

So I started by thinking through the domain: the user's experience of paying for an order. Our server supplies the billing options in terms of billing schemes and possibly associated memberships (i.e. saved credit or gift cards). Submitting an order requires billing methods which consist of a scheme ID and, as appropriate, card details or a membership ID. But none of those distinctions or details make any difference for thinking about the user flow of selecting a payment option. The user just cares about their options.

By establishing a language for the specific domain of the user interface - determining that users care about billing options, not about the details of our server-side representation of those options - I was able to define the kinds of data transformations we needed at the transition between server and UI. In a previous pass on this part of the app I had attempted to map the server's representation directly into the UI - a bad idea, to say the least. Boundaries like that often correspond to the edges of bounded contexts: places where it is time to shift from one set of language (and therefore implementation) to another.

So I decided to map those server-side types into a type specific to the user interface concerns: the user's options for how to be billed for the order. I started by defining the types that were inputs to the system:

export type Membership =
  { id: number
  , schemeId: string
  , description: string
  // other implentation-specific fields
  }

export type Scheme =
  { id: number,
  , description: string
  , memberships: Membership[]
  // other implentation-specific fields
  }

Each of those Schemes then needed to be transformed into a version that was useful for the user: an Option. At the same time, I needed to make sure to carry through any data required by the server when creating an order. So I defined the Method type next, starting with the different kinds of payment methods that the server accepts and then joining them into a tagged union to define the Method itself:

export type Base =
  { amount: number
  , schemeId: number
  // other implementation-specific fields
  }

export type Cash = Base &
  { variant: 'Cash'
  , membershipId: 'Cash'
  }

export type Card = Base &
  { variant: 'Card'
  , paymentCard: {/* card details */} & { schemeId: number }
  }

export type Membership = Base &
  { variant: 'Membership'
  , membershipId: number
  }

export type Method = Cash | Card | Membership

(Note that Base & here means this new type includes everything `Base` has and adds these further fields and types to it. It's kind of like inheritance, but only kind of, because TypeScript's type system is structural, not nominal: Base isn't a super-class of Cash, but it is a super-type of Cash.)

With the input and output to the system designed, all I needed was to define a data structure that could carry along everything a Method needed from a Scheme, but in a form that was appropriate to the user: an Option:1

export type Base =
  { billingId: string
  , schemeId: number
  , description: string
  // other implementation-specific fields
  }

export type Cash = Base &
  { type: 'Cash'
  , repeatable: false
  }

// The option to add a new card in this session
export type NewCard = Base &
  { type: 'NewCard'
  , repeatable: true
  }

// The option of a card the user has added in this session
export type StoredCard = Base &
  { type: 'StoredCard'
  , repeatable: false
  , card: {/* details */}
  , parentBillingId: string
  }

// Previously saved cards
export type ExistingMembership = Base &
  { type: 'ExistingMembership'
  , repeatable: false
  , membership: Membership
  }

export type Option =
  | Cash
  | NewCard
  | StoredCard
  | ExistingMembership

Note that these variants share some commonalities, but they're don't overlap totally. If you try to create an item of type ExistingMembership with a card field on it, TypeScript will complain:

Object literal may only specify known properties, and 'card' does not exist in type 'ExistingMembership'.

There's plenty of complicated business logic around the Options - can they show up repeatedly as a user splits the payments across multiple options, for example? Thus the repeatable field - but getting from a Scheme to its corresponding Options and from a set of selected Options to the corresponding Methods is straightforward: they're just pure functions which transform Scheme -> Option[] and Option -> Method. So option.ts has a function like this:

// in option.ts
const fromScheme = (scheme: Scheme): Option[] => {
  // turn each Scheme into one or more Options,
  // with one Option for each attached Membership
}

Then we can call it like this at our boundary (using lodash-fp):

// `Scheme -> Option[]` so `Scheme[] -> Option[][]`
const optionsFromSchemes = _.pipe(
  _.map(Option.fromScheme),
  _.flatten
)

const options = optionsFromSchemes(schemes)

If there's a bug in building Options from Schemes, it's easy to track down: there's only one place it can be.

Similarly, when the user has made a selection from various Options and we need to turn them into Methods to hand back to our server for submitting an order, we can just write another pure function to do the transformation. Here, since there's a simple 1-to-1 mapping from Options to Methods, we don't even need to _.flatten when we're transforming them (again, using lodash-fp):

// in method.ts
const fromOption = (option: Option): Method => {
  // do the transformation
}

// wherever we need to do the final transformation:
const methods = _.map(Method.fromOption, options)

All of the functions I wrote for these kinds of transformations are simple, pure functions. There is quite a bit of (sometimes complex) logic to manage of course, but all of it comes down to straightforward transformations from one state to another.

The results

As usual, I found plenty of edge cases and no few bugs during the development process for this feature. But I consistently found that using Domain Driven Design ideas, even in this relatively unsophisticated way, was powerful - and doubly so with typed functional programming for modeling the domain. A colleague commented with some surprise, That was fast! when it took me only a matter of minutes to find and fix a given bug - but it wasn't because I was awesome. It was just that there was only one place that problem even could have arisen, because I had taken time to get the domain right in the first place, and because I had then carefully expressed that domain in the types for the program. Neither DDD nor typed functional programming are a cure-all, but the combination can go a long way toward helping write more robust, maintainable code.


  1. Not to be confused with an Option in e.g. Rust! 


C096ed07142659408dc6651f8320acd3?s=184&d=mm

Chris Krycho Chris Krycho is a Senior Software Engineer at Olo, currently hard at work on modernizing the online ordering experience. He's also the host of the New Rustacean podcast and a total fanboy for both Rust and Elm.