Design: Single Responsibility Principle
Probably the best principle to follow in software engineering, and in life in general.
John is your unicorn employee. He is as good at developing software as he is at selling it. So he’s doing both. One may find this organization simpler than having multiple people doing it, but changes can have a detrimental impact to John’s activities:
- If you assign a new task (sales or development) to John, you need to make sure that it doesn’t interfere with his other planned tasks. Does he have a proper time slot to do it? Could he confuse an API contract with a sales contract? Could he pollute a development task with sales considerations or the other way around?
- when John is not available for work (on vacation, training or sick), all of his tasks are impacted. No more development and no more sales.
- If you want to understand John’s work to know where to include a new business rule, you’ll have to review a number of different things that may not be related to your rule, like expense reports, source code, sales proposals, architecture documents, contracts, test coverage, and so on.
So maybe if John is great, it looks that making him handle multiple responsibilities is not the most efficient, most agile nor simplest option after all.
In a way, John is a piece of the software that runs your company. And, indeed, software development shares exactly the same concerns: in the seek of agility (tolerance to change), we need a rule of thumb to design things that have a high acceptation for change. One way to find such a rule is to identify what makes change difficult.
Basically, the more a change impacts your system, the more it is difficult to handle it. You don’t want it to cause a chain reaction: if you’re being asked to update some business rule or technical behavior, you don’t want to discover that it means changing a lot of things 😱. Not only it increases the cost of the change, but the widened scope of your work also increases the risk of breaking other things, a.k.a. produce regressions.
To avoid this, any design should make sure that any of those change targets, or “responsibilities” are located at a single place.
Let’s take our unicorn employee example again, and design it like the octopus-guy we initially described:
class SuperEmployee {
projects: Set<Project>
/**
* Sales attribute
*/
contracts: Map<Contract, Client>
constructor(vcs) {
this.vcs = vcs
}
/**
* Development capability
*/
commit(proj, code) {
this.vcs.commit(proj, code)
}
/**
* Sales capability
*/
sign(contract, client) {
this.contracts.put(contract, client)
}
}
Looks like this “super” employee is doing a number of different things, right? Actually a newcomer may have a hard time determining what is the purpose of this class… as it has several. It may also be confusing to read the commit
or sign
API which can be interpreted both development or sales operation (you can “commit” to a sale deal, or “sign” cryptographically).
Another oddity appears if we want to perform a couple of sales task for instance:
var vcs = new Git()
var john = new SuperEmployee(vcs)
var client1 = new InsurCorp()
var contract1 = new Contract()
john.sign(contract1, client1)
Why on earth would an employee need a VCS to sign a contract? This is requiring too many resources to perform a simple task: if I want to test this software call, we don’t want to instantiate a VCS that we won’t use. Maybe someone who is not familiar with development acronyms could be confused assuming that a “VCS” is some kind of sales thing.
Now let’s add a method to allow such an employee to take some rest and let another employee take over:
class SuperEmployee {
projects: Set<Project>
contracts: Map<Contract, Client>
constructor(vcs) {
this.vcs = vcs
}
commit(proj, code) {
this.vcs.commit(proj, code)
}
sign(contract, client) {
this.contracts.put(contract, client)
}
// Added method
goVacation(backup: SuperEmployee) {
backup.projects.add(this.projects)
backup.contracts.putAll(this.contracts)
}
}
Hey, wait! Now that we allow more than one employee, we realize that it means that all employees should be unicorns able to handle both sales and development. It also looks that we cannot hand over an employee contracts without handing over its projects as well. Some coupling between development and sales arises here… along with the need for a better design.
Let’s refactor it as two classes with distinct responsibilities:
class Developer {
projects: Set<Project>
constructor(vcs) {
this.vcs = vcs
}
commit(proj, code) {
this.vcs.commit(proj, code)
}
goVacation(backup: Developer) {
backup.projects.add(this.projects)
}
}
Obviously this class is simpler to understand that the previous one. No wonder what commit
is related to. Also, I can make sure that his projects will be handed over to another Developer
that will be able to handle them.
The other class looks even more simple:
class Salesman {
contracts: Map<Contract, Client>
sign(contract, client) {
this.contracts.put(contract, client)
}
goVacation(backup: Saleman) {
backup.contracts.putAll(this.contracts)
}
}
No more need for a VCS to instantiate a Salesman, and this make more sense indeed.
As the result of the refactoring, both Developer
and Salesman
classes are now simpler in themselves (so, more maintainable) and more cohesive (their code is using all their state, not a part of it). Also, specialized teams can now focus on working with their expertise of each domain.
This refactoring follows the Single Responsibility Principle, coined by Robert C. Martin, a.ka. “Uncle Bob” and author of the Clean Code best seller. It states that:
A class should have only one reason to change.
As we’ll see, “class” could be replaced by many things actually, as this principle applies in many areas.
Basically that means that a class (or a component, or a function, or a package, it depends on the abstraction level you’re talking from) should take care of a one single concern*, no more.
This principle promotes simplicity as, the less concerns you’ll have:
- the simpler the code will be (i.e. handling less concepts and reducing code size, which will make your code quality metrics happy) ;
- the less dependencies it will have (thus reducing coupling and easing testing).
Examples
The story of John was more a metaphor, a personification of a software piece. More concretely, here are several examples of applying the SRP.
Widgets
UI Components are good examples of SRP because they are usually “dumb”, i.e. they don’t know what they are used for. A button’s code will never include the implementation of the action it triggers for instance. Instead, it provides an asynchronous API that allows to specify what to execute when the button will be pressed.
The same principle should apply to every reusable component of your own. If you build a more complex component such as a ContactList
, it should focus on displaying a contact list and that only. It should not care about what happens when a contact is selected, added or deleted. Instead it should allow callers of this ContactList
to specify some routines to call back when those events occur.
What is the benefit of this?
- the code is simpler. It just calls callback functions. This eliminates the need for dependencies such as a
ContactService
or whatever should be called. The component’s contract is limited to call its subscribers in a general way, not a specific, concrete object. That also eases testing as you don’t have to mock such dependencies. - the code is more flexible. Maybe the action will not call a
ContactService
after all. Maybe it will do something totally different (for testing at least), and, indeed, your widget is ready to handle any of those case.
Layering
When you split a design (an architecture typically) in layers, it is a way to split responsibilities to lessen coupling between those layers. Typically you don’t want a business rule to span across those layers, as changing it would require a consistent set of changes in different places.
Data structure
The danger here is about hacking/patching a structure to allow conveying data about different things instead of clearly defining different structures.
There could be multiple examples of this, but one of the most common is about hacking a model because of the way it is stored. Suppose you have some Customer data you want to save in a database:
interface Customer {
id: UUID
data: CustomerData
}
Every field is mandatory because it doesn’t make sense to store a Customer if you haven’t any data about it, and even more if you haven’t any key to find it.
But the problem is: you choose the IDs to be database generated. That is, at insertion time, the database will decide of the id value. So you can’t require your insertion code to have it before.
interface Customer {
id?: UUID // Database generated
data: CustomerData
}
For sure it can work, but what you got now is a totally corrupted notion of what a Customer
is: something without a mandatory id
. This is plainly false, and other developers could take it for granted. Even if not, they will now have to check that id
is defined everywhere in the code (or worse, tell the compiler to not care about it). You have just ruined your design.
Why this? Because you wanted that data structure to play two roles at the same time (customer definition and customer persistence). You should have rather added a separate data structure to convey the second information.
Functional programming
Think SRP only applies to Object-Oriented design?
A function for every action
is a pretty good translation of it for the functional paradigm, to help you avoid mixing multiple responsibilities in a single code unit.
Extending it
As we mentioned before, as all principles the SRP should be applied at the relevant level of abstraction. Let‘s have a look at other levels than objects:
- Functions and algorithms in general can also mix multiple concerns or stick to a single one. For instance, see the case for optional parameters.
- Storing data from multiple but different concepts in the same storage location unit (in the same table row for instance) can imply some unwanted adherence (I cannot delete one concept without deleting another for instance).
- Naming things implicitly require that you clearly identify their purpose. If you’re having a hard time naming something, it is likely that it is because it serves too many different purposes and fails to apply the SRP.
- Encapsulation is a programming principle that helps separate public and private (or protected) contracts. The public contract is often insulated in an interface definition.
- Exceptions are a way to separate the business contract from the error handling contract, so that the latter doesn’t pollute the former. Another typical mistake is to mix error handing and resource releasing contracts.
- UX is also notoriously inefficient when displaying or asking too many things at a time to the user, and should always tend to focus the user attention on one task without any other distractions.
- In architecture, doesn’t it make sense for a server to be responsible for a given service, and no more? This is one of the ideas behind micro-services.
- When using a VCS, you don’t want to handle multiple topics in a commit, because we won’t be able to individually cherry pick or revert them.
- In testing, it also make sense that each test checks one feature only, not many (this is why it is recommended to mock the dependencies of the tested object). Because if it checks more than one, you may not know which one has failed.
- In democracy, it is critical that justice, legislative and executive powers be independent one from each other (as one influencing the other would impair the goals of equality and freedom), while collaborating to rule the same country.
- In everyday’s tasks, it is usually more efficient to focus on one task (and finish it) than to switch between them (because it avoids the cost of context switching). This doesn’t mean that parallel processing is less efficient that a sequential one (it is the opposite actually), but that a given processing (parallel or not) should only deal with one task at a time.
Conclusion
If one only, the SRP should be the principle that every developer should follow, as it promotes:
- Readability: code is shorter, and speaks about one thing only.
- Testability: when there is one single subject, there are less dependencies to mock.
- Maintainability: the simpler the code, the easier it is to update or replace.
*SRP should not be confused with Separation of Concerns (SoC). While the latter sounds better and even more intuitively close to what this article aim to express, it was not meant to it. When he formulated it first in 1974, Dijkstra was talking about the different steps of software development, and recommended to isolate the concerns of each of those steps (“what is expected”, “how to do it”, “is it valuable”, etc.) one from each other.