This is more a design question than anything else...
I really like Scala's case classes and use them often. However, I find that I'm often wrapping in my parameters in Options
(or rather, Lift's Boxes
) and setting default values to allow for flexibility and to account that a user might not always specify all the parameters. I think I adopted this practice from.
My question is, is this a reasonable approach? Given that everything may be optional, there can be a lot of boilerplate and checking, to the point whether I wonder whether I'm not just using my case classes like Map[String, Any]
and wonder whether I wouldn't be better off just using a Map
.
Let me give you a real example. Here I am modeling a money transfer:
case class Amount(amount: Double, currency: Box[Currency] = Empty)
trait TransactionSide
case class From(amount: Box[Amount] = Empty, currency: Box[Currency] = Empty, country: Box[Country] = Empty) extends TransactionSide
case class To(amount: Box[Amount] = Empty, currency: Box[Currency] = Empty, country: Box[Country] = Empty) extends TransactionSide
case class Transaction(from: From, to: To)
Relatively simple to understand, I think. At this simplest we might declare a Transaction
like so:
val t = Transaction(From(amount=Full(Amount(100.0)), To(country=Full(US)))
Already I can imagine you think it's verbose. And if we specify everything:
val t2 = Transaction(From(Full(Amount(100.0, Full(EUR))), Full(EUR), Full(Netherlands)), To(Full(Amount(150.0, Full(USD))), Full(USD), Full(US)))
On the other hand, despite having to throw Full
around everywhere, you can still do some nice pattern matching:
t2 match {
case Transaction(From(Full(Amount(amount_from, Full(currency_from1))), Full(currency_from2), Full(country_from)), To(Full(Amount(amount_to, Full(currency_to1))), Full(currency_to2), Full(country_to))) if country_from == country_to => Failure("You're trying to transfer to the same country!")
case Transaction(From(Full(Amount(amount_from, Full(currency_from1))), Full(currency_from2), Full(US)), To(Full(Amount(amount_to, Full(currency_to1))), Full(currency_to2), Full(North_Korea))) => Failure("Transfers from the US to North Korea are not allowed!")
case Transaction(From(Full(Amount(amount_from, Full(currency_from1))), Full(currency_from2), Full(country_from)), To(Full(Amount(amount_to, Full(currency_to1))), Full(currency_to2), Full(country_to))) => Full([something])
case _ => Empty
}
Is this a reasonable approach? Would I be better served by using a Map
? Or should I use case classes but in a different fashion? Perhaps using a whole hierarchy of case classes to represent transactions with different amounts of information specified?
If something is genuinely optional, then you really have no other choice. null
is not an option (no pun intended).
I would strongly advise against the uses of Lift's box type however, unless you need it for dealing specifically with Lift APIs. You're only introducing an unnecessary dependency.
I'd also give some serious thought as to whether it truly makes sense to have an Amount
without a specified currency. If it is valid, then creating a dedicated "null object" to represent an unspecified currency would give you a cleaner API:
class LocalCurrency extends Currency
Alternatively:
sealed trait Amount
case class LocalisedAmount(value: Double, currency: Currency) extends Amount
case class RawAmount(value: Double) extends Amount
For the TransactionSide
subclasses, I find it odd that you can specify Currency
separately from Amount
(which already embeds the notion of currency). I'd favour:
case class TxEnd(
amount: Option[Amount] = None,
country: Option[Country] = None)
case class Transaction(from: TxEnd, to: TxEnd)
Finally...
Yes, use maps if they fit well with your domain, they'll make for much cleaner code.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With