Blog

Towards an immutable domain model – believe the type (part 4)

We’re already on the fourth part of this series. In this part we’ll explore how we can improve on the original design now that the invoice is immutable.

Other parts

Cohesion

Let’s take a look at the cohesion of current the Invoice implementation. We do this by looking at the public methods and the instance fields they affect. Here they are, grouped by pre-condition:

Pre-condition Methods Affects
Draft? changeRecipient
addItem
removeItem
recipient_?
nextItemId
items
Ready to sent? send sent_?
dueDate
Sent? readyToPay_?
pay
late_?
remind
paid_?
Paid? (none) (none)

So it looks like there are actually a couple of classes hiding inside our big Invoice class. Mostly related to the draft, sent, and paid state of the invoice.

Another improvement we can make is to ensure that the bad code listed below fails to compile, instead of blowing up at runtime:

Invoice.create(2).changeRecipient(Some("Erik")).pay.send.addItem("Food", 2.95)

Believe the type

Can we make the type system work for us and prohibit this code? Certainly! We have to split the current invoice class into a few subclasses where each subclass only defines the methods that make sense. We also need to change the public methods that return an Invoice to return an instance of the correct subtype instead.

Let’s see how this works out for the DraftInvoice class:

sealed trait Invoice extends AggregateRoot[Invoice, InvoiceEvent]

case class DraftInvoice(
    uncommittedEvents: List[InvoiceEvent],
    id: Int,
    recipient_? : Boolean = false,
    nextItemId: Int = 1,
    items: Map[Int, InvoiceItem] = Map.empty)
  extends Invoice {
  // [... code omitted ...]
}

Compared to the previous version of Invoice we managed to drop the three fields (send_?, dueDate, and paid_?) that are not applicable to a draft invoice. The send method now looks like this:

def send: SentInvoice = {
  require(readyToSend_?, "recipient and items must be specified before sending")
  val now = new LocalDate
  applySent(InvoiceSent(id, sentDate = now, dueDate = now.plusDays(14)))
}

private def applySent(event: InvoiceSent) = new SentInvoice(event :: uncommittedEvents, id, event.dueDate)

There are a couple of changes here:

  • The method returns a SentInvoice.
  • The run-time check to see if the invoice was already sent before is removed, since this is impossible for a draft invoice.
  • A new method applySent was added and is called from send instead of the generic applyEvent method. The reason is that the generic applyEvent returns an Invoice, not a SentInvoice.

The addition of the applySent method means that the applyEvent method needs to be changed accordingly:

def applyEvent = {
  // [... code omitted ...]
  case event: InvoiceSent => applySent(event)
  // [... code omitted ...]
}

Finally, the loadFromHistory method inherited by the Invoice companion object from AggregateFactory returns a plain Invoice, which is now an empty interface. Not very client friendly. Let’s change it so that the client can specify the expected type:

trait AggregateFactory[AR <: AggregateRoot[AR, Event], Event] extends EventSourced[Event] {
  def loadFromHistory[T <: AR](history: Iterable[Event]): T = {
    var aggregate = applyEvent(history.head)
    for (event <- history.tail)
      aggregate = aggregate.applyEvent(event)
    aggregate.asInstanceOf[AR].markCommitted.asInstanceOf[T]
  }
}

That's it! The rest of the classes can be implemented in a similar fashion. Just for fun, here's the full implementation of the PaidInvoice class:

case class PaidInvoice(uncommittedEvents: List[InvoiceEvent]) extends Invoice {
  def markCommitted = copy(uncommittedEvents = Nil)

  def applyEvent = unhandled
}

It's now immediately clear that the paid state of an invoice is a final state. No domain behavior is provided and no events are accepted. There is only some boilerplate code left.

From a client perspective little has changed, except that the bad call sequence at the start of this blog will no longer compile. And if you're using an IDE, code completion will be helped by the additional compile-time information. Another nice thing is that unit tests like these are no longer required:

"draft invoice" should {
  val invoice: DraftInvoice = Invoice.loadFromHistory(Seq(InvoiceCreated(1)))

  "not be payable" in {
    invoice.pay must throwA[IllegalArgumentException]
  }
}

In fact, tests like these will no longer compile at all!

The complete code of the typed invoice implementation can be found in Invoice.scala. The amount of boilerplate code has slightly increased with the addition of typed event handlers and the need to reimplement the uncommittedEvents and markCommitted methods in each Invoice subclass.

But the domain logic has been simplified and each class is now much more cohesive, with less data to manage. Also notice that the dueDate field of a SentInvoice is no longer an Option[LocalDate], but simply a LocalDate. This is because a sent invoice always has a due date, and we no longer need to make the field optional just because the class also needs to support the draft invoice state.

Futhermore, methods like readyToPay_? are no longer needed. The only remaining runtime checks are those that are truly dynamic (like late_?) or that are harder to encode in the type system such as readyToSend_?. Maybe the latter constraint can still be encoded with phantom types, but that seems overly complicated for the current problem.

Conclusion

The immutable representation of our domain made it easy to adapt the invoice type dynamically, based on the changes applied to the invoice. This helped us to implement the domain in a more concise and understandable fashion, while also improving type safety and reducing the need to implement runtime checks. Basically we're getting a statically typed GoF State Pattern for free.

For me this may very well be the nicest features of immutable domain models and object-functional programming. Many, many kinds of business domain objects have a lifecycle, where behavior changes in each state. Immutable objects made it possible to directly encode this.

In the fifth (and final) part we'll look at using the swiss army nuclear rocket of programming to reduce the amount of boilerplate code related to tracking uncommitted events: the monad.

Zilverline gebruikt cookies om content en advertenties te personaliseren en om ons websiteverkeer te analyseren. Ook delen we informatie over uw gebruik van onze site met onze partners voor adverteren en analyse. Deze partners kunnen deze gegevens combineren met andere informatie die u aan ze heeft verstrekt of die ze hebben verzameld op basis van uw gebruik van hun services.

Okee