How to Write Code That Are Extensible
How to not break code that works properly and decrease the effort on adding new functionality
In a conversation with a colleague these past few weeks, we discussed that our core codebase needs to be refactored because developers need more time to change any tiny features in the code.
"I don't like how the code is structured right now. It wasn't intended to be this way initially. Do you have any ideas on how to refactor this to make it easier to extend in the future?” he asked.
"I don’t have a solution, but the way it is structured right now works for extending form but not for extending expression," I responded.
The code is written in Scala. Thus, there are 2000 different ways to refactor it. However, the codebase was done in the Java-Scala style - inheritance and self-typing (cake pattern) everywhere.
He asks about the difference between extensibility through form vs. expression. It is the classic trait to make if you work with a general programming language that exposes you to functional and object-oriented programming languages.
Code evolves all the time because many software engineers are working on it simultaneously. There is always some refactoring, bug-fixing, and new features. Thus, many bugs are often introduced by misaligned product requirements and constant code changes.
The first problem - misaligned product requirements - is often solved through constant communication and iterations. The second problem can be elevated by optimizing the number of necessary changes when adding new features by making the code extensible.
Making your code extensible is so vital that software engineers create design patterns and principles forall programming languages.
If you break down extensibility into a pure form, you can see that there are two ways: form and expression. In this article, I talk about the similarities and differences in software extensibility traits between object-oriented and functional programming. At the end of this article, I share my opinion on how you can solve such complexities using a popular functional programming pattern: type class.
The Problem
To illustrate the extensibility of our code, let’s start with an example of a codebase.
Imagine you are working on a marketing campaign that contains multiple campaign attributes. Currently, marketers want to have two types of campaigns, dynamic and static. Both campaigns will have a `react`, which will do some reaction when some external request triggers it.
val input = ....
val campaign = ....
val expression = campaign.react(input)
The Object-Oriented Programming Approach
The OOP approach to this problem is to create an element (interface) or abstract class, Campaign, with the `react` abstract method. The Dynamic and Static campaigns will extend the Campaign trait and implement the `react` abstract method.
trait Campaign {
def react(input: Input): Output
}
class Static() extends Campaign {
override def react(input:Input): Output = ???
}
class Dynamic() extends Campaign {
override def react(input:Input): Output = ???
}
We instantiate the `campaign
` with its subtype in the main method.
val input = ....
val campaign = new Static()
val expression = campaign.react(input)
This property of OOP is called polymorphism, the so-called subtyping polymorphism. It assumes that react
has multiple forms (Dynamic and Static), and we can link these forms together by creating a common abstract interface.
Let's see how this structure helps with software extensibility.
Adding a New Form
The product manager wants to add another Campaign targeted towards millennials. Thus, we want to create the "MillenialCampaign."
We can easily extend our codebase in this structure by adding `MillenialCampaign` that implements react
.
class MillenialCampaign() extends Campaign {
override def react(input:Input): Output = ???
}
Let's see what happens if we need to extend a new operation.
Adding A New Operation
Let's add a new operation, getInviteLink
, that will get all the shortened URLs and redirect the user to a new page.
First, we will create a getInviteLink
method in the Campaign
trait. Then, we must go through all three files in our application that extend the trait and implement getInviteLink
.
This is a huge problem in Java if we have multiple nested inheritances, we need to change all the classes that implement that interface.
Let's see how the functional programming way of structuring our code will have better extensibility.
The Functional Programming Approach
In functional programming, data and operations are often separated. We will start with an Algebraic Data Type (ADT) defining our Campaign. The `react` function will be called inside the companion object of Campaign that will implement all of its data.
sealed trait Campaign
object Campaign {
case class Dynamic() extends Campaign
case class Static() extends Campaign
def react(campaign: Campaign, input: Input): Output =
campaign match {
case Dynamic() =>
case Static () =>
}
}
The above code does the same thing as in OOP, except the code structure is different.
Let's see how this structure helps with software extensibility.
Adding A New Form
If we want to add a new Campaign, MillenialCampaign
, we extend the MillenialCampaign
as a Product or Coproduct under the ADT. Then, we need to change all functions that use the Campaign ADT to account for `MillenialCampaign`.
object Campaign {
case class Dynamic() extends Campaign
case class Static() extends Campaign
case class MillenialCampaign() extends Campaign
def react(campaign: Campaign, input: Input): Output =
campaign match {
case Dynamic() =>
case Static () =>
case MillenialCampaign () =>
}
}
Imagine having ten functions in multiple files. You must go through each function to account for MillenialCampaign
.
Adding A New Operation
On the other hand, if the Campaign ADT wants to have functionality getInviteLink
, we can create one under react
that accounts for all Campaigns.
def getInviteLink(campaign:Campaign) =
campaign match {
case Dynamic() =>
case Static () =>
case MillenialCampaign() =>
}
We don't have to go through different files for this change.
What do we learn about OOP and FP?
You see, there is no silver bullet for solving the extensibility problem. OOP's way of structuring your application solves the form extensibility but fails operation extensibility. The FP way of structuring your application solves the operation extensibility but fails form extensibility.
Flexibility is critical.
Ideally, software applications should withstand both extensibility - there is no business application that only A/B test new Campaigns without adding any new operations and vice versa.
Type Class Approach
Type class is a popular pattern heavily used in functional programming. It is a way to solve the extensibility problem.
It was first introduced in Haskell to achieve ad-hoc polymorphism.
This pattern consists of 3 sides:
The Type Class itself
Instances for particular types
Interface methods that we expose to users
If you want to dive deeper into Type Class, please check out one of my previous articles about Type Class.
To approach the extensibility problem with Type Class, we will put each operation as its Type Class and have our ADT implement its instances.
trait ReactOp[A] {
def react(campaign: A, input:Input): Output
}
Dynamic, Static, and Millenial Campaigns can extend an abstract trait Campaign. However, we no longer need to because we can derive them through the type class interface methods. You'll see what I mean in this example.
case class Dynamic()
object Dynamic {
implicit val reactInstance: ReactOp[Dynamic] =
new ReactOp[Dynamic] {
def react(campaign: Dynamic, input: Input): Output
}
}
case class Static()
object Static {
implicit val reactInstance: ReactOp[Static] =
new ReactOp[Static] {
def react(campaign: Static, input: Input): Output
}
}
// create the interface syntax so that the user can run it like Campaign.react(input)
implicit class ReactOpInterface[A](campaign: A) {
def react(input: Input)(ev:ReactOp[A]) = ev.react(campaign, input)
}// user run in Main.
import ReactOpInterface._
val input = ....
val campaign = ....
val expression = campaign.react(input)
Let's see how Type Class will extend forms and operations.
Extending Form
If we add a new form, MillenialCampaign
, we don't need to search through all the files and change the structure of our application. We can extend the form by creating a MillenialCampaign
and implementing the ReactOp
Type Class.
case class MillenialCampaign()
object MillenialCampaign {
implicit val reactInstance: ReactOp[Millenial] =
new ReactOp[Millenial] {
def react(campaign: Millenial, input: Input): Output
}
}
Extending Operation
If we want to add a new operation, `GetInviteLink`, we can create an additional Type Class responsible for getting invite links.
trait InviteLinkOp[A] {
def getInviteLink(campaign: A): Output
}
Now we must provide instances of `MillenialCampaign`, `Static`, and `Dynamic`.
The difference between these operations versus OOP is that it is loosely coupled. The language doesn't force us to do sub-type polymorphism, and we can define the Type Class instance in any file. This solves the problem of extending operations on an API not contained in the current working repository.
Recap
We all know how good it feels when it’s straightforward and we don't need to dig into the whole codebase to add a single new feature. I briefly described how FP and OOP approach form and operation extensions. However, neither OOP sub-typing nor FP pattern matching completely solves the problem of two-dimensional extensibility.
Lastly, I discussed a popular functional programming pattern, Type Class, which is a way to help solve both form and expression extensibility.
Designing software is partly art and partly technical. Therefore, each developer will have their own style of structuring their application. This is a topic widely discussed on the web about what constitutes best practices for structuring your code. However, the best way is based on your current application and specific business logic.
Back to you, how would you solve such a problem? Comment them down below!