Switching a programming language within the object-oriented world or the functional world is merely a change of using new syntax and new libraries that do the same thing.
Although these two styles (object-oriented and functional programming) both accomplish the same thing by computation, the way they approach the problem requires a very different mindset. In order to be consistent and make our code manageable, we follow the mindset of whichever style in which we are coding.
Writing code that is easy to maintain is an art.
The problem
The problem arises when there is either a professional demand or personal choice to move from one mindset of programming to another. The codebase ends up with a mixture of functional and object-oriented code, which is complicated and costly to maintain. It is also difficult to develop a new feature in this case. At OnceHub we experienced this problem when we had some procedural/functional code in our supposedly object-oriented microservice.
That object-oriented microservice is in Ruby (Rails API), so the examples I will be using here will also be in Ruby, but the concepts are easily applicable to any object-oriented language (for example, TypeScript/Python).
We will not get into the concepts of object-oriented programming or how to write practical object-oriented code (for that we will have another blog post); rather, we will review the equivalent concepts and contrasts of functional programming in the object-oriented world.
Potential solution
The hardest part: Data and behavior together
Object-oriented world:
- Here we think about everything as if it is a real-world object and has some behavior. Now in technical terms, an object is nothing but a structure and its behavior is just the methods it has. But in functional programming, we have data and functions as different entities. So this is a major difference.
- In a good object-oriented world, every object does one thing and knows as little about other objects as possible, so there are few interdependencies among them. This is similar to having functions with no side effects in functional programming and depending only on their inputs, like a pure function.
- Objects' main focus is on their behavior and not on data, so data is mutable, unlike immutable data in functional programming.
To highlight how different the approaches are in OOP and FP, I’ve borrowed this example below:
You run a company and you just decided to give all your employees a $10,000 raise. How would you tackle this situation programmatically?
In OOP:
- Create Employee class that initializes with name and salary, and has a change salary instance method
- Create instances of employees
- Use each method to change the salary attribute of employees by +10,000
In FP:
- Create employees array, which is an array of arrays with name and corresponding salary
- Create a change_salary function that returns a copy of a single employee with the salary field updated
- Create a change_salaries function that maps through the employee array and delegates the calculation of the new salary to change_salary
- Use both methods to create a new dataset, named ‘happier employees’
Now we have an idea about how these two are different, let’s see how to make our codebase more object-oriented from a mixed-style (OOP + FP) codebase. The following are some of the problems we faced and how we solved them.
Single Responsibility Principle
This sounds very easy but takes effort to implement. If you’re coming from a functional programming background, I am assuming that you know about it.
We had a class whose primary function was to send some data, which gets used for initializing components in a front-end service. Now as the functionality increased, we began to have more to initialize; one of which was to send a welcome message to that front-end service. The logic of calculating that required at least three good functions, which could also be used in other classes, like when making the JSON response.
The test cases of this class were not fully testing the logic of making the initialized data. They just checked that the method was getting called and then partially compared the response, in which we didn’t check the welcome messages. It was done for one simple reason: the member function was doing a lot of things and no one wanted to touch it. Problems it created:
- No test for actual logic of creating the initialize data because no one wanted to touch that big piece of code.
- Very hard to change or add a new feature in that code.
- Overall -> Very costly in development
How did we handle it?
We extracted out the code for making welcome messages in a helper class, and now we just include it wherever we want. This helper class only takes the current session object and builds welcome messages based on its properties.
# Initial Code
class InitializeFrontEnd # Demo name def initialize(config) @config = config end def setup_data collection = Message.find_by(website_id: @config.website_id, is_welcome: true) # more filtering and condition on result based on config data messages = collection.collect {|question| <some conditions> } . . # (some other things on which I am not going into details) # Making JSON response = { welcome_messages: messages . . } render response end . . . other methods . . end |
# After refactoring
class InitializeFrontEnd include WelcomeMessage def initialize(config) @config = config @welcome = WelcomeMessage.new(@config.current_session) end def setup_data response = { welcome_messages: @welcome.messages } render response end end |
After this refactoring, we did introduce a dependency in our InitializedFrontEnd class, but we are fine with this tradeoff, as now the tests are easy and the heavy logic is lifted into a helper class that can have separate tests.
Remember that a class should do the smallest possible useful thing. That thing ought to be simple to describe. If the simplest description you can devise uses the word “and”, the class likely has more than one responsibility. If it uses the word “or”, then the class has more than one responsibility and they aren’t even very related. -- Sandi Metz
Complex Data Structure
Depending on a complex data structure is worse. It is very hard for anyone to understand it and how to make a change in it.
What is a complex data structure, you ask? Well, imagine we are taking an array of arrays as a parameter in a constructor of a class in a data variable called `data` and then using that like this, “data[0][1]”, in different methods of a class.
Direct references into complicated structures are confusing, because they obscure what the data really is, and they are a maintenance nightmare because every reference will need to be changed when the structure of the array changes.
Example: Consider a class Departmental Store that takes data as an argument in its constructor. This data is an array of arrays having the cost price of an item at the 0th index and tax on that item at the 1st index.
(Here we are not taking the actual example of our microservice, as that is very complex)
# Initial Code
class Store attr_reader :data def initialize(data) @data = data end def selling_prices data.collect{ |item| item[0] + item[1] } end end |
# After refactoring
class Store attr_reader :items def initialize(data) @items = make_items(data) end def prices items.collect { |item| item.cost_price + item.tax} end Item = Struct.new(:cost_price, :tax) def make_items(data) data.collect {|cell| Item.new(cell[0], cell[1]) } end end |
The takeaway is ->
If you are compelled to take complex structure as input, then hide the mess even for yourself.
Writing loosely coupled code
Every new class reference inside a class makes it dependent on the referenced class. The more we have it, the less it is available for reuse.
We used to have methods in our classes that directly referred to another class. The problem with this is that we are hard coding a behavior to a specific kind of object. Rather, it is possible that other objects may also possess that behavior, and in that case, our class should work.
For example: Consider the InitializeFrontEndclass that generates a bill. Now it should be able to generate a bill of an item purchased or for a service taken by the customer.
If we have our Bill class like this:
class Bill attr_reader :price, :tax def initialize(price, tax) @price = price @tax = tax end def generate total = Item.new(price, tax).market_value # Only works for Item class "$ #{total}" end end |
Even if there will be other classes like a `Service` class that will respond to `market_value` behavior, we are not able to use Bill#generate behavior for that. Thus, our object is not reusable.
Instead, look at the following code:
class Bill attr_reader :price, :tax, :any_service def initialize(price, tax, any_service) @price = price @tax = tax @any_service = any_service end def generate total = any_service.market_value "$ #{total}" end end |
We inject our dependency and expect to respond to `market_value` behavior in the “generate” function.
The takeaway is ->
One object should not know much about any other objects/classes. Give it something and expect some behavior out of it.
In the second part, we will look at creating flexible interfaces, reducing cost with duck typing, and cost-effective testing.
These are the principles we are including in our daily development at OnceHub. To find more content like this, you can also follow my personal blog.
Happy growing. :)
Raounak Sharma, Full Stack Developer
Raounak loves helping a business grow through his technical contributions, working with startups and multi-national corporations. He enjoys the challenge of maintaining quality in software, especially as the industry changes with every market trend. He's worked as a Full Stack Developer at OnceHub since July 2019. In his free time, he plays football for clubs in Delhi, India and loves trekking, listening to audiobooks while doing cardio, and watching stand-up comedy.