Building a constrained domain model with Phantom Types
- 8 minsWhen designing a domain model, I attempt to ensure that domain objects go through well defined state transitions and that consuming code only receive objects in a valid, expected state (and of course, can only induce a transition to other valid states). It’s possible to complete this approach in Java, but Scala makes it substantially easier. The language’s type system offers a feature called Phantom Types that may be used to lightly mark a class’ valid states in a typesafe way, restricting consuming code to respect the constraints embedded in the types. Let’s play a game to get a better sense of the feature’s capabilities.
Let’s play some cards
Let’s say your you want to build a library to aid you in writing card games. You’ve decided to use Scala to build this library, and you want to use the full range of the language’s type system and ecosystem to make your logic correct and tractable.
We’ll start by describing the high-level objects we want:
Card
- Any card in the deck. It could be a face or non-face card.Deck
- We’ll assume a standard deck of 52 cards for now.
A reasonable first draft of your class hierarchy might look like the following:
So we’ve distinguished between face and non-face cards, created a trait to allow some flexibility in defining decks, and provided a convenience function for creating a full standard deck of cards.
Restricting domain behavior
With our first draft done, let’s think about Deck
’s various states:
Fresh
- An ordered sequence of 52 cards from smallest to largest non-face card, followed by face cards. The cards should be grouped by suit.Shuffled
- A deck whose cards have been shuffled using your standard Seq/Array shuffle mechanism. We still have 52 cards here.Drawn
- A deck with at most 51 cards, since at least one card was drawn from it.
We could expand this to include Empty
for a fully depleted deck, or include representations for intermediate states, but this should do for now.
We want methods that operate on our domain objects to be aware of these states, and ensure that they only accept a deck in an expected state.
Traditional Object-oriented programming might call for a class hierarchy such as:
and classes that use decks of a particular state would accept a specific subclass as an argument
This approach works reasonably well as a first approximation, and if that’s the extent of what your language allows you’ll make it work. It satisfies the demand for domain objects being aware of object states while restricting those methods to receive only those states that they expect.
However, in pursuit of a modeling solution we’ve added more cruft than is strictly necessary. Since the deck’s behavior barely changes while transitioning states, a lightweight solution that merely marks the states would be preferable. Phantom types accommodates this need.
Let’s see how Deck
might be modified to use Phantom types as markers for its acceptable states.
We’ll start by creating a sealed trait hierarchy and annotating StandardDeck
with a simple upper type bound, then we’ll modify some methods to accommodate the new constraint:
We always want to start-off with a fresh Deck (for now), so we’ll prevent users from initializing a deck in an alternative state.
Additionally, draw
may now only operate on a Shuffled
or Drawn
deck, and can produce a Shuffled
or Drawn
deck (see trait hierarchy). It’s a really clean approach, and coupled with scala’s copy constructor for case classes, it’s verbosity-free.
In my experience this setup works really well.