We want systems to grow incrementally by implementing proper separation of concerns and maximizing decoupling at each level of abstraction. The following principles serve as a tool to achieve this goal.
A class should have only one reason to change
class FeedLineFormatter {
def toCsv(listing: Listing): String
}
The FeedLineFormatter
class above has two responsibilities (reasons to change):
- Extracting the feed data from a Listing
- And formatting that data as a CSV line
Mixing responsibilities not only leads to complex implementations but also makes testing harder, since the number of possibilities is greater. A possible solution is to introduce a FeedLineFactory
class that handles the first responsibility, and re-write the formatter to accept a FeedLine
instead.
class FeedLineFactory {
def fromListing(listing: Listing): FeedLine
}
class FeedLineFormatter {
def toCsv(feedLine: FeedLine): String
}
A software artifact should be open for extension and closed for modification
This means that we should understand what is most likely to change in our system and allow that to be extended. In the FeedFormatter
class below, the format of the lines is open for extension however the line separator is closed for modification.
trait FeedLineFormatter {
def format(feedLine: FeedLine): String
}
class FeedFormatter(feedLineFormatter: FeedLineFormatter) {
private val lineSeparator = '\n'
def format(feed: Feed): String
}
Subtypes should not break contracts of base types
The principle is mostly broken when we try to fit a type into an existing one. For example, the FeedLineGroup
class would break the contract of the getLineValues
method in FeedLine
that implies a single line.
class FeedLine {
def getLineValues: List[String]
}
class FeedLineGroup extends FeedLine {
def lines: List[FeedLine]
def getLineValues: List[String] = lines.flatMap(_.getValues)
}
A class should not be forced to depend on methods that doesn't use
The principle make us think about the granularity of our interfaces, the fatter they are the more likely we will have to throw UnsupportedOperationException. For example, a ReadOnlyRepository
would not be able to implement the save
method of the Repository
interface.
trait Repository[V] {
def findById(id: UUID): Option[V]
def save(value: V): Unit
}
trait ReadOnlyRepository[V] extends Repository[V] {
def findById(id: UUID): Option[V]
def save(value: V): Unit = ???
}
Implementation should depend on abstractions, not on details
class UserService {
val repository = new CassandraRepository[User]()
def findById(id: UUID): Option[User]
}
The UserService
class above imposes the use of a Cassandra database, prohibiting reuse with other repositories. Similarly to the single responsibility principle, testing the service would be harder since any bugs in the CassandraRepository
would be propagated.
A possible solution is to define a Repository
abstraction that the UserService
can use, and that the CassandraRepository
can implement.
class UserService(repository: Repository[User]) {
def findById(id: UUID): Option[User]
}