What you will find below is not a typical style guide. If you're looking for a general resource of this kind, consider the official Scala Style Guide or the great Scala Best Practices collection.
What you will find here is a collection of hints and advice on how to write your code in a way that brings the focus to the business domain and problems being solved rather than technical aspects of it. Most of the points are opinionated and hence highly questionable. Use your own experience and judgment to decide which advice to follow.
Note
This document is new and minimal. Please help to make it better, contributions are much welcomed!
Table of Contents
- Domain-oriented Scala Style Guide
- Rules
Both ways are fine from the general standpoint, but one of them is better if we want to prioritize thinking about the business domain.
// Wrong!
example/
├── repository/
│ ├── UserRepository.scala
│ ├── OrderRepository.scala
├── service/
│ ├── UserService.scala
│ ├── OrderService.scala
// Right!
example/
├── user/
│ ├── UserRepository.scala
│ ├── UserService.scala
├── order/
│ ├── OrderRepository.scala
│ ├── OrderService.scala
String
is not a domain concept. Avoid using simple types in your domain model. Instead, try to name them, especially
if they are used in a standalone way. Select a typesafety level and modeling approach that you're comfortable with: type
aliases, opaque types, case class wrappers,
new-types.
// Wrong!
case class Rectangle(width: Int, height: Int)
// Right!
case class Rectangle(width: Width, height: Height)
Business logic should be expressed in terms of concepts belonging to the domain and not in technical terms used to solve a particular problem.
// Problem
arbitrageFinder.findArbitrage(rates)
// Solution
ratesGraph.findNegativeCycle
// Problem
sendUserNotification()
// Solution
sendEmail()
// Problem
processPayment()
// Solution
repository.updateAccountBalance()
Monoid is typically not a business-domain concept. While domain objects might form certain algebraic structures, this should be an implementation detail, not a first-class property.
// Wrong
trait TaxCalculator {
def calculateTax[T: Monoid](transactionAmounts: Seq[T]): Money
}
// Right!
trait TaxCalculator {
def calculateTax[T <: Taxable](transactionAmounts: Seq[Taxable]): Money
// Or
def calculateTax[T: Taxable](transactionAmounts: Seq[Taxable]): Money
}
// Or
trait TaxCalculator[T] {
def calculateTax(transactionAmounts: Seq[T]): Money
}
class ProfitTaxCalculator extends TaxCalculator[Profit]
It's also an FP concept that often leaks into the domain interfaces. Should we try to eliminate it as well?
We could try, but this becomes rather difficult or costly in practice. One of the ways to do it to parametrize
interfaces by F[_]
but it comes with its own tradeoffs. So instead, let's stay pragmatic and allow IO as a special
case.
Monad transformers are isomorphic to the underlying structure and bring only practical benefits but no conceptual difference. Keep the signatures as simple as possible.
// Wrong!
trait NotificationService {
def notifyUser(): EitherT[IO, UserDisabled, NotificationId]
}
// Right!
trait NotificationService {
def notifyUser(): IO[Either[UserDisabled, NotificationId]]
}
All core business logic should be exposed through interfaces, even if there is exactly one implementation. The goal of
this is to make the interface explicit and bring focus to it. Such an interface can then be understood on its own and
analyzed for coherence or distance from the business domain. This applies also to static methods with no
dependencies (object
s).
// Wrong!
class NotificationService() {
def notifyUser()
}
// Right!
trait NotificationService {
def notifyUser()
}
class NotificationServiceImpl extends NotificationService
// Wrong!
object ReferenceParser {
def parse()
}
// Right!
trait ReferenceParser {
def parse()
}
object ReferenceParser extends ReferenceParser
Sequential logic should read as a series of steps, with one line per expression and vertical alignment (if you can stand it).
// Wrong!
for {
_ <- if (amount > 0) IO.unit else IO.raiseError(InvalidAmount)
transactionId <- transactionType match {
case CryptoTransfer => sendThroughBlockchain()
case FiatTransfer => sendThroughBank()
}
_ <- if (isUserTransaction) notifyUser(transactionId) else IO.unit
} yield ()
// Right!
for {
_ <- validate()
transactionId <- execute()
_ <- notify(transactionId)
} yield ()
Code is read from left to right, and line should begin with the most important information. If the types need to be
adjusted in particular context use suffix operators (e.g., from cats syntax) or pipe
method from the standard library.
// Wrong!
IO.pure(amount)
// Right!
amount.pure[IO]
// Wrong!
EitherT.fromEither[IO](validationResult)
// Right!
validationResult.pipe(EitherT.fromEither[IO])