DEV Community

Edward Huang
Edward Huang

Posted on • Originally published at pathtosenior.substack.com on

How to Write Code That Are Extensible

Photo by Hasan Almasi on Unsplash

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.

Thanks for reading Path To Senior! Subscribe for free to receive new posts and support my work.

"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 = ???
}
Enter fullscreen mode Exit fullscreen mode

We instantiate the campaign with its subtype in the main method.

val input = ....
val campaign = new Static()
val expression = campaign.react(input)
Enter fullscreen mode Exit fullscreen mode

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 = ???
}
Enter fullscreen mode Exit fullscreen mode

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 () => 
  }
}
Enter fullscreen mode Exit fullscreen mode

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 () =>  
    }
}
Enter fullscreen mode Exit fullscreen mode

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() => 
  }
Enter fullscreen mode Exit fullscreen mode

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:

  1. The Type Class itself

  2. Instances for particular types

  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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   
    } 
}
Enter fullscreen mode Exit fullscreen mode

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`. 
Enter fullscreen mode Exit fullscreen mode

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!

💡 Want more actionable advice about Software engineering?

I’m Edward. I started writing as a Software Engineer at Disney Streaming Service, trying to document my learnings as I step into a Senior role. I write about functional programming, Scala, distributed systems, and careers-development.

Subscribe to the FREE newsletter to get actionable advice every week and topics about Scala, Functional Programming, and Distributed Systems: https://pathtosenior.substack.com/

Top comments (0)