Functional S.O.L.I.D. Explained In 5 Examples

I’m in the process of porting the Codemanship course materials to Python and JavaScript. After I did the S.O.L.I.D. examples from the Software Design Principles workshop in JS, I thought it might be useful to illustrate how these “object oriented” principles can be applied to a more functional style of programming.

Single Responsibility

“Classes should have one reason to change.”

The rationale behind the SRP is simple: editing classes risks breaking the code in them. Once code is tested and out there working, we’d ideally prefer to leave it that way. If a class contains code that changes at different times for different reasons, the risk is of breaking code that really didn’t need to change just because it’s in the same class as code that did. (There’s another, much more compelling reason for our classes – and methods and functions – to do only one job, which is explained on the course.)

But “class” is a red herring here. Really, it’s sources files. (Or modules.) Editing code in a source file risks breaking other code in the same file.

For example:

const {debit, credit} = require("./bank_account");
const transfer = (payer, payee, amount) => {
return {
payer: debit(payer, amount),
payee: credit(payee, amount),
amount: amount
};
}
const toXml = (transferRecord) => {
return "<BankTransfer amount='" + transferRecord.amount + "'>" +
"<Payer>" + transferRecord.payer.id + "</Payer>" +
"<Payee>" + transferRecord.payee.id + "</Payee>" +
"</BankTransfer>";
}
module.exports = {transfer, toXml};

view raw
bank_transfer.js
hosted with ❤ by GitHub

Here I can easily imagine wanting to change the XML output without changing the logic of a bank transfer. This module has two reasons to change. So we should split it up.

const {debit, credit} = require("./bank_account");
const transfer = (payer, payee, amount) => {
return {
payer: debit(payer, amount),
payee: credit(payee, amount),
amount: amount
};
}
module.exports = transfer;

view raw
bank_transfer.js
hosted with ❤ by GitHub

const toXml = (transferRecord) => {
return "<BankTransfer amount='" + transferRecord.amount + "'>" +
"<Payer>" + transferRecord.payer.id + "</Payer>" +
"<Payee>" + transferRecord.payee.id + "</Payee>" +
"</BankTransfer>";
}
module.exports = toXml;

view raw
xml_output.js
hosted with ❤ by GitHub

Open-Closed

“Classes should be open to extension and closed to modification”

Once a class is tested and released, modifying it risks breaking it – and any code that depends on it. A safer way to add functionality to an existing system is to extend the existing code without editing it.

This means our code needs to be designed in a way that makes extending easy. Now, this design principle arguably belongs more in the days of C++ and a few other statically-typed languages where, if you want a class to be open to extension, you have to design it a certain way (e.g., any methods you plan to override need to be declared as virtual. The love for pure interfaces for everything was born of this era.)

In the modern era of dynamic languages and duck typing, it’s very easy to swap one implementation with another – provided the client hasn’t directly referenced that implementation (which is what the D in SOLID is all about.)

Back in the day, we no doubt had inheritance in mind. But that has very much fallen out of favour in recent years, and now many developers prefer composition instead. A subclass that presents the same interface as a base class, and delegates method calls to an instance of the base class internally is logically the same as implementation inheritance.

The functional equivalent of that would be a function with the same signature as the “base” function that internaly delegates to it.

Imagine we wanted to extend a function for borrowing videos from a library so that we can prevent people from borrowing titles they’re too young to see:

const borrow = (customer, video) => {
const borrowed = customer.borrowedVideos;
return {
customer: {customer, borrowedVideos: borrowed.concat([video]) },
video: {video, onLoan: true, borrower: customer.id }
}
}
module.exports = borrow;

view raw
video_library.js
hosted with ❤ by GitHub

All we have to do is create a new function that has an identical signature, that calls the original borrow() function.

const base = require("./video_library");
const borrow_rated = (customer, video) => {
if(customer.age < video.rating.minAge){
throw "Customer is too young for this title";
}
return base(customer, video);
}

Liskov Substitution

“An instance of a class can be substituted with an instance of any of its subclasses”

The example above solves the problem of syntactic swappability of a function with an extended version that has the same signature. But… it’s not quite as simple as just syntax in many cases of extension.

First of all, you may have noticed that the extended function relies on customers having an age, and videos having a rating. We didn’t just extend the function, we extended the data structure (in this case, the JSON object) the function accesses. Any client passing in the old data structure will cause an unhandled exception.

Also, the client could be in for a nasty surprise if the customer is too young for the video they’re borrowing, in the shape of an unexpected error. We’d have to rewrite the client code. Ths is how ripples start in our code that can spread from the module we changed/extended to the rest of the code, making even the smallest changes very expensive.

Ideally, we want to be able to extend the software without having to rewrite client code. And that means, in practice, that – as far as existing clients are concerned – the contracts for calling functions must still hold.

The original borrow() function has no precondition. Any customer can borrow any video. The extended version requires the customer be old enough. So there are scenarios for using borrow() the client thinks are valid that no longer are. (Imagine turning up to the airport with your ticket and your passport, and not being allowed to board the flight because, on your way to the airport, they added a requirement to bring the pilot a bunch of bananas.)

Consider this simple bank account module:

function credit(account, amount) {
return {account, balance: account.balance + amount};
}
function debit(account, amount) {
if (amount > account.balance) {
throw "Insufficient funds error"
}
return {account, balance: account.balance amount};
}
module.exports = {credit, debit};

view raw
bank_account.js
hosted with ❤ by GitHub

We’re asked to extend it so that customers can withdraw beyond the balance up to an agreed overdraft limit. In an FP style, this just means “overriding” the debit() function.

const debit = (account, amount) => {
if(amount > account.balance + account.limit){
throw "Insufficient funds error";
}
return {account, balance: account.balance amount};
}
module.exports = debit;

view raw
overdraft_account.js
hosted with ❤ by GitHub

So far, soo good. This works syntactically. Anywhere clients expect the original debit function, we could substitute the new version. But have we broken the original contract?

One way to check would be to somehow run the original bank account tests against the new implementation. Right now, they look like this:

const {credit, debit} = require("../../src/liskov_substitution/bank_account");
describe('bank account', () => {
it('credit account', () => {
const account = {id: 1, balance: 0};
const credited = credit(account, 50);
expect(credited.balance).toBe(50);
})
it('debit account with sufficient funds', () => {
const account = {id: 1, balance: 50};
const debited = debit(account, 50);
expect(debited.balance).toBe(0);
})
it('debit account with insufficient funds', () => {
const account = {id: 1, balance: 50};
expect(() => debit(account, 51)).toThrow('Insufficient funds error');
})
})

view raw
bank_account.test.js
hosted with ❤ by GitHub

As they are, it’s not possible to swap in the new implementation because the tests directly reference the old one. What if we refactored the tests to look like this?

const {credit, debit} = require("../../src/liskov_substitution/bank_account");
const bankAccountTest = (account, credit, debit) => {
it('credit account', () => {
const credited = credit({account, balance: 0}, 50);
expect(credited.balance).toBe(50);
})
it('debit account with sufficient funds', () => {
const debited = debit({account, balance: 50}, 50);
expect(debited.balance).toBe(0);
})
it('debit account with insufficient funds', () => {
expect(() => debit({account, balance: 50}, 51)).toThrow('Insufficient funds error');
})
}
describe("bank account", () => {
const {credit, debit} = require("../../src/liskov_substitution/bank_account");
const account = {id: 1, balance: 0};
bankAccountTest(account, credit, debit);
})
describe("bank account with overdraft facility", () => {
const overdraft_debit = require("../../src/liskov_substitution/overdraft_account");
const overdraft_account = {id: 1, balance: 0, limit: 1000};
bankAccountTest(overdraft_account, credit, overdraft_debit);
})

view raw
bank_account.test.js
hosted with ❤ by GitHub

Now we can run the same tests with two different implementations of debit(), and two different versions of the account object. Now, I wonder what happens when I run these tests…

 

Interface Segregation

“Classes should present client-specific interfaces”

Back in the days when C++ ruled the world, if I changed a class’s interface (e.g., renamed a method), all the code that referenced that class had to be recompiled, re-tested and re-deployed. Unavoidable if those clients use that renamed method, but totally avoidable if they don’t, by extracting interfaces that only include the methods they actually do use.

In FP, there are no interfaces. Or, rather, every funtion can be thought of as an interface with only one method. Add to that dynamic binding, and the problem goes away. So, arguably, a discussion about Interface Segregation is moot.

But in the heat of battle, as functions evolve and move between modules, and dependencies change, it’s entirely possible for one module to end up directly referencing functions and modules they no longer use. The effect is the same.

const {rating, rate, summarize} = require("./book");
const booksWithRating = (number, books) => {
return books.filter((book) => rating(book) == number);
}
module.exports = booksWithRating;

view raw
book_stats.js
hosted with ❤ by GitHub

Although this module only uses the rating() function from book.js, it references two other exported functions. If I were to, say, move summarize() to a different module, this code breaks.

So, in FP, we might re-frame Interface Segregation as:

“Modules should only reference things from other modules they actually use”

Dependency Inversion

“High-level modules should not depend on low-level modules. Both should depend on abstractions.

Abstractions should not depend on details. Details should depend on abstractions.”

This is a rather hifalutin way of saying that dependencies between modules should be swappable.

Consider this example:

const toXml = require("./xml_serializer");
const toHtml = require("./html_serializer");
const toString = require("./string_serializer");
const ResponseKind = {
XML: 1,
HTML: 2,
STRING: 3
}
const write = (customer, responseKind) => {
if(responseKind == ResponseKind.HTML) {
return toHtml(customer);
} else {
if(responseKind == ResponseKind.XML){
return toXml(customer);
}
}
return toString(customer);
}
module.exports = {ResponseKind, write};

view raw
response_writer.js
hosted with ❤ by GitHub

This is a poor design. How do we add new kinds of output format without modifying this write() function? That breaks the Open-Closed principle. OO languages give us a simple mechanism for making choices without the nastiness of enums and conditionals: polymorphism.

FP gives us exactly the same mechanism. Two functions with the same signatures can be invoked by the same client, without the client knowing which implementation it’s using. From the outside, they look exactly the same.

If we refactor write() to remove direct references to the implementations, and inject the output function from the outside, we can make that dependency easily swappable and our design easily extensible.

const write = (customer, output) => {
return output(customer);
}
module.exports = write;

view raw
response_writer.js
hosted with ❤ by GitHub

Now the client decides which output function to use:

const write = require("../../src/dependency_inversion/response_writer");
const toXml = require("../../src/dependency_inversion/xml_serializer");
const toHtml = require("../../src/dependency_inversion/html_serializer");
const toString = require("../../src/dependency_inversion/string_serializer");
describe('response writer outputs', () => {
it('outputs XML when selected', () => {
expect(write({name:""}, toXml)).toMatch("<customer>");
})
it('outputs HTML when selected', () => {
expect(write({name:""}, toHtml)).toMatch("<html>");
})
it('outputs as string when selected', () => {
expect(write({name:""}, toString)).toMatch("Customer:");
})
})

Notice that all the implementation references are at the top of our call stack now. This is the effect that composing functions (and objects in OOP) by dependency injection tends to have.

Dependency Inversion (and dependency injection that enables it) is as much a foundation of good, extensible FP as it is of good OOP. You may have noticed how many, many staples of the FP paradigm – e.g., map(), filter() and reduce() – work by allowing us to pass in functions that are invoked inside without having to anything about the function being called other than its signature.

 

So there you have it: S.O.L.I.D. applied to functional JavaScript in five examples. If you want to take a more detailed look at the example code and try the refactorings for yourself, it’s all on https://github.com/jasongorman/JS_design_principles

And if you’d like Software Design Principles training and coaching for your team, you know where to find me.

 

Author: codemanship

Founder of Codemanship Ltd and code craft coach and trainer

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s