There is a familiar piece of programming advice: a function should do one thing.
The trouble is that a function rarely starts out doing several things. It gets
there one reasonable change at a time.
Imagine a function that takes several positional arguments:
function createInvoice(customerId, address, currency) {
// ...
}
Later, somebody already has a Customer object containing all of that
information. Passing each value separately feels wasteful, so the function is
extended to accept the object in place of its first argument:
function createInvoice(customerIdOrCustomer, address, currency) {
if (typeof customerIdOrCustomer === "object") {
const customer = customerIdOrCustomer;
customerIdOrCustomer = customer.id;
address = customer.address;
currency = customer.currency;
}
// ...
}
This can look like a tidy, backwards-compatible change. Existing callers keep
working, new callers become shorter and no logic is duplicated. But the function
now has two jobs and two calling conventions.
In one mode, its arguments mean exactly what their names suggest. In the other,
the first argument changes type and the remaining arguments become irrelevant.
The function has acquired a fork in its logic and some combinations of arguments
no longer make sense. Every reader now has to understand both paths.
Complexity leaks out
The cost becomes clearer when every call that passes an object must be updated.
Searching for createInvoice finds both calling conventions, while searching
for object literals or variables is hopeless: objects are everywhere in
JavaScript, and their names do not reliably reveal their shape.
In one real case, distinguishing the problematic calls required parsing the
codebase with an abstract syntax tree. What should have been an editor search
became a static-analysis project because two operations shared one name.
Give the new idea a new name
The alternative is wonderfully unremarkable. Add an independent function:
function createInvoice(customerId, address, currency) {
// ...
}
function createInvoiceForCustomer(customer) {
return createInvoice(customer.id, customer.address, customer.currency);
}
The wrapper contains a little extra code, but each function now has one clear
contract. Either can evolve independently, and every use of the object-based API
can be found with a plain text search. If they later diverge, the new function can
own its implementation instead of wrapping the old one.
One Reason to Change
This is the practical benefit of the Single Responsibility Principle: code with
one responsibility has one reason to change. Its contract is easier to name,
test and search for; changes stay local; and callers are not forced to understand
unrelated branches. A little duplication is often cheaper than coupling two
ideas that only happen to look similar today.
How Far Should You Take It?
A Reddit discussion of this principle
highlights a provocative example from Clean Code: because error handling is
one job, a function containing try/catch delegates the actual work to another
function.
function deletePage(page) {
try {
deletePageAndAllReferences(page);
} catch (error) {
logError(error);
}
}
function deletePageAndAllReferences(page) {
deletePageFromStore(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name);
}
Applying that rule to every try block may create more indirection than value,
but it asks a useful question: are error handling and the operation itself likely
to change for different reasons? As one reply put it:
That particular example is pretty ugly, I'll admit. But the philosophy still
makes sense to me. What is a system if not a million little functions working
in tandem?
A million small functions that each do one thing are preferable to 500,000
functions with two responsibilities. Clear names and narrow contracts win every
time.