- Published on
Simple isn't easy
- Authors
- Name
- Damian Płaza
- @raimeyuu
You can run but you can't hide
The destiny is merciless.
The time has come and I made the full circle - I had exposition to code written, probably by me, 8 years ago.
Angular.js 1.x, pure JavaScript, no tests (who writes tests...Don't I trust myself?!).
Turns out that there was a bug.
Was it truly a bug?
Or maybe it was rather a behavior of "the system", unintentionally designed?
Built-in without proper usage considerations?
Still, something I needed to help taking care of!
What is the duration of code?
Except the issue itself was pretty easy to be tracked down, I found this piece of behavior:
var duration = token.duration
if(duration > 60) {
// react to duraction is less than 60
}
With high chances, I wrote this code.
What was I thinking back then?
What does "60" mean? There's "duration" which points at time domain.
Everything was placed in the access token and authentication surroundings - possibly it said something about for how long this token is going to be valid?
So this magic "60" might mean minutes, maybe seconds; for sure not tons, meters or pieces.
Time passed
Of course, it's not that easy to show you, dear Reader, all the code around, but bear with me.
I spend a little more time to figure out what's happening there, as I couldn't let go this feeling of not understanding it.
A personal flaw of mine.
Turned out that it was checking how much time was left before token will eventually expire.
Easy.
We could just leave it as it is because it wasn't the point of my journey in this past, this historical piece of code.
The "original" bug was already fixed, or rather the new extension of behavior was provided.
But hey, let's do the experiment - what could we do there to improve the readability of the code?
No magic
This magical "60" turned out to be the time left before token expiration - 60 seconds.
So in other words, if one minute was left before expiration, we need to do some action.
How this could be then improved?
import { ONE_MINUTE } from "@/common/time"
var duration = token.duration
if(duration > ONE_MINUTE) {
// ...
}
Not bad, now at least we put the bare, naked number in a specific context - time domain.
If someone peeks ONE_MINUTE
, he or she will see "60" - so there might be a little glitch when reading.
"ONE" vs "60" might be not challenging at all, but jumping between levels might take some mana points.
An alternative?
var duration = token.duration
if(duration > 60 /*seconds*/)
Well, it communicates the time-intention there, so no naked number. Also, this communication goes on the same "cognition level" so there will be no "one to sixty" glitch-like surprise.
Could we do better?
Stop primitive thinking
Remember this is pure JS, so no types at all.
What if we take this time domain more seriously.
So seriously that we will capture this language in a programming constructs.
Let's see how it could look like.
var duration = token.duration
if (duration.isGreaterThan(Time.OneMinute)) {
// ...
}
To achieve so, we would need to introduce a new class:
export class Duration {
isGreaterThan(threashold) {
// ...
}
}
// and
export class Time {
static OneMinute = Time.of(60 /*seconds*/)
}
I predict we would need to add something around 60-100 lines more to achieve that expresiveness.
If I came to this place next time, I would need less time to orient myself.
Someone could say: "how often you visit this code? Probably not that often. Leave it, cost-to-benefit ratio doesn't look good".
Well, that might be true. With high chances, I might not need to see this code in next 8 years.
This might be regarded as complete waste of time and resources to "overengineer" this code in that way.
On the other hand, how might one know how often such place in the code is going to be visited?
Magical orb, black cat and oracle-like foresight?
Could an understanding ever expire?
Let's give us one more chance to grow this piece of code.
We know that we operate in the time domain AND it is about the token expiration.
What if we do something like this:
if(token.willExpire()) {
// ...
}
So instead of treating "token" as a bag of data, we treat it as "a thing", that can manage on its own, within the boundaries of the language.
Then, a definition of the token
might look like:
export class AccessToken {
constructor(accessToken, timeBeforeExpiration) {
// ...
}
willExpire() {
if(this._duration.isGreaterThan(timeBeforeExpiration)) {
// ...
}
}
}
In this case, we might increase number of lines to maintain by 100-200 lines.
So overengineering.
Who does write code in that way?!
You never know
8 years ago I didn't consider I was writing this code for future myself to see.
I just wrote the code.
Just placed those characters, those ifs, those numbers to finish a task.
Complete a feature.
One might say that this part of code might not be critical so there's no need to put the highest quality in it.
What's more, we work with highly intelligent people, full of insightful practices and skills, so decoding this code will be easy for them.
It all might be true.
But when you take the perspective of next 2 months, next 2 years, next 2 decades - someone might eventually look at the code you wrote - including yourself.
Don't make them thinking too much.
All those transformations we made along this tale produced much more code.
There was much more code, not less.
But this served the specific purpose - readability, understability and contextual thinking.
Bare strings, naked numbers, raw bools do not carry the context with itself.
The value of Value Objects
Many people do not see The value of Value Objects.
Such constructs might easily be called "primivite values' wrappers", because they don't have any behavior.
And in such model of thinking, this leads to "overengineering" - "YAGNI" they say.
To me, there's a "behavior" that is not visible in the code.
I mean, you can't "dot" it and get some meaningful, domain-oriented operations (other than toString
and similar).
This behavior is communication of the intent.
It is very subtle, implicit. It helps one to Slow down, absorb the context and build up the understanding.
Types capture the context, and it gets carried everywhere this thing appears.
And regarding "the value of wrappers" - it might not be directly visible because it manifests itself later, in the future, not now.
I haven't expected to browse the code I wrote 8 years ago - I would rather have said this code would extinct in next 2 years from writing, back then.
I really like how Steve Freeman and Nat Pryce described it in "GOOS":
When we want to mark a new domain concept in the code, we often introduce a placeholder type that wraps a single field, or maybe has no fields at all. As the code grows, we fill in more detail in the new type by adding fields and methods. With each type that we add, we're raising the level of abstraction of the code.
Each concept becomes manifested as "a placeholder" to grow.
It all raises the level of abstraction we operate with - and yet again we might misunderstand the intention of abstraction (you might be interested in The ambiguity of abstraction).
It serves the purpose of embedding the right language in the right place, expressing Language of the problem so that we can reason about the "code" in higher-order way.
For me, similar heuristics apply to so-called "strongly-typed identifiers" - creating a specific boundary for expressing meaning around Guid
, e.g. TenantId
or ProcessingId
, leaves some space to be filled when there's a need.
If we treat each concept's manifestation in a "mechnical way" - and call working with them as "wrapping and unwrapping" - well, no doubt that we might find it as unnecessary overhead.
Yet again, there's some subtle, hidden behavior - communication of the intent - even if it looks and sounds obvious, it is obvious for us, now.
Even if I need to write some more characters, add some more lines - I take this downside of more "code" to maintain, at the same time adopting the benefit of higher-order reasoning.
This reminds me Mark Seeman's observation that "Typing is not a programming bottleneck".
I am not afraid to type more, provided that I might get benefit of better understability and better readability.
What's more, some languages require more typing (also regarded) than others which might eventually lead to the topic of The cost of modeling. I see that's why people might get discouraged to model domain concepts accordingly.
You might hear that such code is "overengineering", "non-idiomatic" and even get more similar labels - the best would be to ask this guy in next 5 years who might touch this part of code and look at bare strings, naked numbers and primitive booleans, without encapsulation and with no composition.
For sure, such code is easy to write.
Simple isn't easy
In his great and insightful presentation, "Simple made easy", Rich Hickey showed us how we can understand something that is "simple", "complex" or "easy".
"Easy" might not be "simple", and "simple" migh not be "easy".
They express two different perspectives on doing things.
If you haven't seen this presentation, dear Reader, stop right now and watch it - you won't regret it.
In one of his slides, one can see the following:
Simplicity often mean making more things, not fewer
That's why I consider simplicity as not easy state to reach.
It requires one to dive into complexity, decompose problem into manageable parts, yield understanding and meaning, and eventually come back and compose everything with simplicity in mind.
Similar conclusion also appears in Rich's slide:
Create abstractions with simplicity as basis
In our tiny tale we saw that we could grow our software - and it required more parts, not less.
This mantra of conflating "less things" with simplicity - might eventually be very harmful.
Simple isn't easy, it requires effort to explore the problem area, learn language and find out concepts that are relevant to the context, eventually picking what is worth modeling.
And it all takes time.
Don't afraid to say "I don't know", Slow down and face complexity, eventually finding simplicity.
You never know if code you just wrote survives next 10 years and you will get back to it.