I want to take a look at making effective use of Scala's pattern matching capabilities for a fairly trivial example. Don't expect any of this to be mind blowing but maybe you're venturing in Scala for the first time or have been tinkering for a while you might find this useful.
The example I give below has been taken from a real world example but the context has been changed and made bit more trivial and non-specific.
Setting the stage
We have a solution by which a customer can place an order through our system. They can pay by various means - Credit Card, Bitcoin and Direct Debit. Once they have placed their order they are taken to a payment selection screen where they can select how they want to pay. Finally, once they have selected their payment method they are redirected to the appropriate payment gateway.
First lets model the PaymentMethod
types
sealed trait PaymentMethod
object PaymentMethod {
case object Card extends PaymentMethod
case object Bitcoin extends PaymentMethod
case object DirectDebit extends PaymentMethod
}
This gives us case objects that represent the payment types our system supports. Next lets create an Order
class to model our actual order.
case class Order(id: String, total: BigDecimal, selectedPaymentMethod: Option[PaymentMethod])
This class has some properties relating to the order
- An
id
property, and - A
total
property that holds the final price of the order.
It also has the selectedPaymentMethod
property which is an option of our PaymentMethod
type. It is defined as an Option
because prior to payment selection we will have an order but no payment method selected.
Now lets assume our system is an event based system. Once payment select has been made the Order
instance is persisted to a data store with the chosen option and a system event is triggered to being processing payment and routing the customer.
Within whatever event handling strategy we use (Actor, Queue, Bus, Callback etc.) we then need to retrieve the customers most recent order and if there is a payment method defined route to the correct payment gateway. We have defined a method like for retrieving the mot recent customer order,
def mostRecentCustomerOrder(customerId: String): Option[Order]
It returns a Option[Order]
because there are situations where this is used and the customer doesn't yet have a recent order. Just go with it.
Decisions using pattern matching
Now to the juicy bit. Given we've got the most recent order we need to route accordingly. At this point we want to return yet another Option
this time of a potential Route
which is an abstraction that allows us to route to external systems (the details are really not important for this example).
- If there is no order we return
None
- If there is an order but no selected payment method we return
None
- If there is an order and a selected method we return a
Some
of a route depending on what the select method is.
Lets code this up.
mostRecentCustomerOrder(customerId) match {
case Some(order) if order.selectedPaymentMethod == Some(PaymentMethod.Card) =>
Some(redirectToPaymentGateway())
case Some(order) if order.selectedPaymentMethod == Some(PaymentMethod.Bitcoin) =>
Some(redirectToBitcoinGateway())
case Some(order) if order.selectedPaymentMethod == Some(PaymentMethod.DirectDebit) =>
Some(redirectToBankGateway())
case _ => None
}
I see this code a lot. It's not necessarily wrong but it is rather long winded. We are doing some things unnecessarily. For example, we are extracting the Order
into an order
value and inspecting the contents. Not a major issue but we aren't actually using it inside the case statements body. We also run the risk of shadowing other values of the same name.
Deeper matching
Pattern matching in Scala isn't confined to the top level class. It is entirely possible to match all the way down the object graph. So lets revisit the same bit of code and remove the unnecessary extraction of a value and just use pure matching.
mostRecentCustomerOrder(customerId) match {
case Some(Order(_, _, Some(PaymentMethod.Card))) =>
Some(redirectToPaymentGateway())
case Some(Order(_, _, Some(PaymentMethod.Bitcoin))) =>
Some(redirectToBitcoinGateway())
case Some(Order(_, _, Some(PaymentMethod.DirectDebit))) =>
Some(redirectToBankGateway())
case _ => None
}
In this case we avoid extracting the order
value and make each case
a bit more succinct by matching purely on the existence of a PaymentMethod
within the Option
. We also use _
to mark values we aren't interested in. Whether or not this is easier to read than the previous example is very subjective. I think it is but only really marginally so.
Option.collect
There is another option (pun intended). As mostRecentCustomerOrder
returns an Option
and we are expected to return an Option
we can use Option.collect
.
collect[B](pf: PartialFunction[A, B]): Option[B]
Returns a scala.Some containing the result of applying pf to this scala.Option's contained value, if this option is nonempty and pf is defined for that value.
This gives us the power of pattern matching so we can transform the input Option
in to what we want if and only if the input satisfies our conditions, falling back to None
if it doesn't. You can think of collect
like a conditional map
mostRecentCustomerOrder(customerId).collect {
case Order(_, _, Some(PaymentMethod.Card)) =>
redirectToPaymentGateway()
case Order(_, _, Some(PaymentMethod.Bitcoin)) =>
redirectToBitcoinGateway()
case Order(_, _, Some(PaymentMethod.DirectDebit)) =>
redirectToBankGateway()
}
By using collect
we don't need to provide the default fallback case of returning None
plus we get to unwrap all those Some(Order(...))
cases as well as not having to wrap each cases return value in a Some
.
Again this is purely personal opinion but this reduction of noise makes this code much easier to read. We are letting the language deal with the default cases without having to repeat ourselves.
Summary
None (pun intended) of the options (pun intended) above are necessarily wrong and one approach may fit better in some scenarios than another but I feel that laying out the options (pun intended) helps us make more informed decisions while we try and build quality code.