6 minutes
Violating the Law of Demeter
A co-worker linked to the following article describing some of JavaScript’s new features in 2020 this past week: https://www.freecodecamp.org/news/javascript-new-features-es2020/. One of the features that stood out was optional chaining.
This brings with it something that I’ve already seen in other languages that I work with, namely Ruby, and its #try
or &.
(safe navigation operator). This sort of convenience feature is a double-edged sword:
- good for scripting
- not so good for any software that is to be extended and or maintained
1. why is it good for scripting?
Instead of the verbosity below:
if (foo && foo.bar && foo.bar.baz) console.log(foo.bar.baz);
// -OR- 🤮
if (foo) {
if (foo.bar) {
if (foo.bar.baz) {
console.log(foo.bar.baz);
}
}
}
We have this. Concise ain’t it?
console.log(foo?.bar?.baz?);
Scripts are typically one-offs and/or throw-away code that likely won’t be used again once time moves forward. It isn’t always the case, of course. Sometimes we just have to do things that are quick and dirty in scenarios where we are scripting and don’t own the content and/or data that we are traversing. Because we aren’t the owners, the schema/shape of the data/objects that we want to navigate are opaque to us, therefore subjecting us to defensive programming practices to ensure that we don’t have runtime exceptions that halt our script.
That said, the terseness of the new feature makes our script much more readable and removes possible blocks of indentation from conditional logic.
2. why isn’t it so good for any real software?
The TL;DR : maintenance or feature extension nightmares from tight-coupling and implicit dependencies
In almost all organizations that I’ve been at, the following conversation almost always comes up (back-pressure from the devs on the team):
In order for us to build this feature, we need a sprint to refactor our code, because we weren’t aware of this use case
Sometimes we even put the blame on the business and/or product team for NOT having envisioned this in the first place. This is a really poor mindset to have, because at the end of the day – the only constant in software is change. Some of us may be able to get away with this being a contractor with a single deliverable and pawning off any extension or maintenance to the hapless downstream dev that gets asked to extend it with new features.
This saddens me, because we’ve made assumptions about our software that make it too rigid for extension without inducing a refactor and sometimes (hopefully not) a complete re-write.
We should strive to write software that’s amenable to change, but this also DOES NOT mean that we try to support every possible use case that we can think of, just flexible enough to not lock us in.
Without digressing too far, let’s get back to why something that seems to be a welcome, and maybe even a very nice, addition to the language may get us into trouble…
class UsaAddress {
constructor(street, state) {
this.street = street;
this.state = state;
this.country = "United States of America";
}
}
class Person {
constructor(name, address) {
this.name = name;
this.address = address;
}
}
/*
Is the following better? 🧐
BEFORE:
person && person.address && person.address.street
person && person.address && person.address.state
*/
function printAddress(person) {
console.log([
person.address?.street?, // 👈 nicer syntax
person.address?.state?, // 👈 with new optional chaining
person.address.country
].join('\n'));
}
let address = new UsaAddress('123 Foo St.', 'New York');
let andrea = new Person('Andrea', address),
printAddress(andrea);
What’s wrong here?
Our printAddress
function assumes several things about a person:
- that the person has an address property
- that the address has a state property
So what?
Hey team! We’ve got new business!!! We’re going to be supporting Canadian addresses!
class CanadaAddress {
constructor(street, province, country) {
this.street = street;
this.province = province; // 👈 difference
this.country = "Canada";
}
}
What most of us do to support this new use case
function printAddress(person) {
console.log([
person.address?.street?,
person.address?.state? || person.address?.province?, // ⚠️
person.address.country
].join('\n'));
}
// - OR - 🤮
function printAddress(person) {
let state = null;
if (person.address.constructor.name === 'UsaAddress') {
state = person.address.state;
} else if (person.address.constructor.name === 'CanadianAddress') {
state = person.address.province;
} else {
state = '';
}
console.log([
person.address?.street?,
state,
person.address.country
].join('\n'));
}
Guess what happens when we need to support another type of address…
Easy!! Just add another condition!
Unfortunately, the above is NOT the right answer if we want to maintain extensibility and avoid the possibility of introducing a regression when mangling with existing code.
The printAddress
function has dependencies that aren’t passed into it:
- address
- the state OR province on an address
This function knows about something that is two degrees of separation away from itself, and if it changes, which the introduction of Canadian addresses introduces, our printAddress
must change, too. This is tight-coupling – this function must move in lock-step with any address changes. It’s rigid. It likely also requires updates to unit tests if there were any to begin with.
👇 // one degree of separation
person.address?.state?
👆 // two degrees of separation
NOTE
- The only thing passed in as an argument to our function is a person object
- This implies that our function depends on having a person for it to operate
- BUT it implicitly also says that the person must have an address and its address must have a state or province
How do we fix this? Follow the Law of Demeter which simply states:
Each unit should have only limited knowledge about other units: only units “closely” related to the current unit.
Each unit should only talk to its friends; don’t talk to strangers.
Only talk to your immediate friends.
With the above guidelines, we can interpret it to say, “a function should only know about things that are about a degree of separation away”.
Ideally, we only go as far as this:
console.log(person.address);
When we spot chaining that goes beyond a degree of separation away from injected dependencies (e.g., person
), red flags should be raised.
We’ll talk about how to fix this in a follow up post.
additional thoughts
I don’t really see a much of difference between software developers with years of experience and ones that are new. Some of us are lucky enough to learn it from the job having good mentors, but many of us don’t. A genuine curiosity and simply just caring has led me to find solutions to these problems without re-inventing the wheel.
I implore anyone that truly cares about writing maintainable software to check out “Simple Made Easy” by Rich Hickey, author of the Clojure programming language and giving oneself the opportunity to understand the principles that make up the acronym that SOLID embodies.