What we want from a Software Architecture
In this article I aim to explain what are the important factors we should look for on a scalable and testable architecture. This will allow us to reduce bugs and the time necessary to modify some new requirements. Additionally, I will explain how we can do this using guidelines (SOLID) with some cool examples in Kotlin.
Why do we write bad code? We do it every day, we know it is an horrible piece of code and yet we keep doing it. Maybe because we have to develop many features in so little time, or because we fail give a wrong estimate and will have to go faster. However with this we neglect discipline, not following important development rules, testing, code separation, flexibility and scalability. Later on, we quickly regret this decision as some new requirement will imply some refactoring, making it more difficult to do it, probably causing some bugs or breaking functionality on unrelated code.
Technology is really important on all our lives, in many fields we already have a great responsibility for the human life. However, we are humans and sooner or later we will make a mistake that will cost many lives. Then, the blame will be pointed to us (developers) and we can’t say that our boss wanted us to go fast.
Therefore, we have to follow some design patterns to avoid any serious accident.
Guidelines to Develop Applications
From my experience, clients tend to change their minds very often. Not only this but they also expect these changes to be fast. For this reason, our architecture has to be flexible and the business logic isolated. As Robert C. Martin (Uncle Bob) said:
A good architecture allows volatile decisions to be easily changed.
Therefore an architecture should be:
- Scalable — An application which can be easy and fast to add features and to reuse components. Decoupling components allows you, for example, to make a refactoring without introducing some unrelated bugs.
- Maintainable — The code and its structure must be simple to understand in case someone reads it. Additionally, you might want to use functional code. Using an observable is easy to filter and to concat to other tasks, helping us debugging and testing.
- Flexible — Keep up with client requirement changes, needs to use functional programming so it is easy to change.
- Testable — Easy to mock, this means that components should be decoupled and in order to be easy to test. Additionally, you should always look for immutability, this will help on flexibility and testing.
- Plug in application — Other components should point towards the business rules. This means that we should rely on a plugin architecture, where components should plug into the business rules, e.g. through interfaces.
In order to accomplish all of this Uncle Bob promoted SOLID, which are a set of guidelines aiming to fix these problems on object oriented programming.
Consequently, SOLID stands for:
- S -> Single Responsibility Principle
Every class should have a single responsibility, this means that classes should only change for one reason. If it has more than one you are probably doing it wrong, for instance:
abstract class Ticket(val movie: Movie, val spot: String, val room: Int) {
open fun cost(): Double{
return 10.00 * DiscountManager().todayDiscount
}
fun printTicket() : String{
return "Ticket valid for watching Movie ${movie.name} between ${movie.startTime} and ${movie.endTime} for the sport ${spot} on room ${room}"
}
}
In this example, the entity Ticket might be refactored for 2 different reasons: one to change the output and other to change the base ticket price method. Therefore, here we can simply refactor this by putting the output method in a Printer class, such as:
abstract class Ticket(val movie: Movie, val spot: String, val room: Int) {
fun cost(): Double{
return 10.00 * DiscountManager().todayDiscount
}
}class Printer{
fun output(result: Ticket): String = "Ticket valid for watching Movie ${result.movie.name} between ${result.movie.startTime} and ${result.movie.endTime} for the sport ${result.spot} on room ${result.room}"
}
- O -> Open Closed Principle
You should extend a class without modifying it, this means that if you are checking which data type by doing too many switches or if else, you are probably doing it wrong.
abstract class Ticket(val movie: Movie, val spot: String, val room: Int) {
fun cost(): Double{
return 10.00 * DiscountManager().todayDiscount
}
}class OnlineTicket(movie: Movie, spot: String, room: Int) : Ticket(movie, spot, room) {
}class VIPTicket(movie: Movie, spot: String, room: Int) : Ticket(movie, spot, room) {
}fun calculateTicketCosts(tickets: List<Ticket>): Double {
var totalCosts : Double = 0.0
for (ticket in tickets) {
when(ticket){
is OnlineTicket -> totalCosts += ticket.cost() * onlineDiscount()
is VIPTicket -> totalCosts += ticket.cost() * vipCost()
}
}
return totalCosts
}
In this example, you can see that OnlineTicket and VIPTicket is extended from Ticket, however when we are calculating costs we are iterating through every type of ticket and creating a costs calculation. You could simply create a cost() on Ticket class and making it as abstract in order to be extended by others classes, like this:
abstract class Ticket(val movie: Movie, val spot: String, val room: Int) {
val BASE_TICKET_COST : Double = 10.00
abstract fun cost(): Double
}class OnlineTicket(movie: Movie, spot: String, room: Int) : Ticket(movie, spot, room) {
override fun cost(): Double {
return BASE_TICKET_COST * onlineDiscount()
}
}class VIPTicket(movie: Movie, spot: String, room: Int) : Ticket(movie, spot, room) {
override fun cost(): Double {
return BASE_TICKET_COST * VIP.cost()
}
}fun calculateTicketCosts(tickets: List<Tickets>): Double {
var totalCosts : Double = 0.0
for (ticket in tickets) {
totalCosts += ticket.cost()
}
return totalCosts
}
- L -> Liskov Substitution Principle
Derived classes must be replaceable for their base classes, this means that base classes needs to be preserved along of the hierarchy. For example:
abstract class Ticket(val movie: Movie, val spot: String, val room: Int) {
val BASE_TICKET_COST : Double = 10.00
abstract fun buy()
}class MondayTicket(movie: Movie, spot: String, room: Int) : Ticket(movie, spot, room) {
override fun buy() {
if (!isMonday()) {
return
}
pay()
}
}
Let’s imagine that we have a ticket type that can only be bought on Mondays. Therefore, the logic for that is done on the ModayTicket. Although, this logic breaks the hierarchy behaviour, as we can no longer derive from ModayTicket and maintain the original Ticket logic. Not only this but, calling buy() will break functionality if the user is not buying on a Monday. So, a possible solution for this can be:
class MondayTicket(movie: Movie, spot: String, room: Int) : Ticket(movie, spot, room) {
override fun buy() {
if (!isMonday()) {
buyNormalTicket()
}else {
pay()
}
}
}
- I -> Interface Segregation Principle
If you find methods in classes that don’t belong there you need to refactor your code. A common example is the an interface with click, long and drag listeners, which not every class need to implement them.
interface ITicketPurchase {
fun buyTicket()
fun buyPopcorn()
fun getAccessVIPLevel()
}
class PurchaseNormalTicket : ITicketPurchase{
override fun buyTicket() {
...
}
override fun buyPopcorn() {
...
}
override fun getAccessVIPLevel() {
//not needed has normal tickets do not have vip privileges
}
}
In this example PurchaseNormalTicket implements the ITicketPurchase interface. However, a normal ticket do not have any VIP privileges and therefore should not implement any logic from getAccessVIPLevel() method. Therefore, we should refactor things a little bit in order to implement only the methods that we want, like this:
interface ITicketPurchase {
fun buyTicket()
fun buyPopcorn()
}interface IVIPPurchage : ITicketPurchase{
fun getAccessVIPLevel()
}class PurchaseNormalTicket : ITicketPurchase{
override fun buyTicket() {
...
}
override fun buyPopcorn() {
...
}
}class PurchaseVIPTicket : IVIPPurchage{
override fun buyTicket() {
...
}
override fun buyPopcorn() {
...
} override fun getAccessVIPLevel() {
//not needed has normal tickets do not have vip privileges
}}
- D -> Dependency Inversion Principle
Depend on abstractions, not on concretions, meaning that we should use interfaces as much as we can do divide everything.
fun getValidSpots(movie: Movie){
networkModel.getRooms(movie)
.flatMap { rooms-> Database().save(rooms) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(rooms -> view.updateSpots(rooms))
}
Notice that the method getValidSpots() is requesting a list of all rooms and their spots from the network, however when we have that response we are initialising the Database, making it difficult to test and to isolate. Therefore, a probable solution would be:
fun getValidSpots(movie: Movie, database: IDatabase){
networkModel.getRooms(movie)
.flatMap { rooms-> database.save(rooms) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(rooms -> view.updateSpots(rooms))
}
Conclusion
Following these guidelines will help us (programmers) a lot, developing faster, making a more understandable and flexible code with flexible components. At first you might think that this is too much work just to develop small applications. However, a customer might request changes in the near future and he will expect that those small changes should be delivered very fast. Not only this, but new people might arrive to the project and they need to understand and continue to use already implemented rules.
Not only this, but you will find the code very easy to maintain. As years pass, technology evolves and we might need to replace a Realm database for other required new component or simply expand the application to other platforms.
Additionally, you will notice that less bugs will appear and if you are doing it correctly, you will see that refactoring a feature will not open some unrelated bug.
Thanks Antonio Ribeiro and César Ferreira for the help reviewing this article :)