Like many front-end developers, I don’t have a formal computer science background. I rolled into this discipline as a designer wanting more control over the end product and though I did get a bachelors of ICT degree, the actual studies were, ahem, quite light in terms of “fundamental computer science”. This means all I know about capital-s Software Development, I learned as I went from various sources. If that’s you too, this article hopefully saves you a few years.
After 20+ years, the things that have the most effect on my day-to-day aren’t that I learned how to model my OOP systems using UML or know what a Monad is. Instead, it’s a bunch of (sometimes pithy) statements that fall under the umbrella of “programming principles”.
Now, there are many, many programming principles o…
Like many front-end developers, I don’t have a formal computer science background. I rolled into this discipline as a designer wanting more control over the end product and though I did get a bachelors of ICT degree, the actual studies were, ahem, quite light in terms of “fundamental computer science”. This means all I know about capital-s Software Development, I learned as I went from various sources. If that’s you too, this article hopefully saves you a few years.
After 20+ years, the things that have the most effect on my day-to-day aren’t that I learned how to model my OOP systems using UML or know what a Monad is. Instead, it’s a bunch of (sometimes pithy) statements that fall under the umbrella of “programming principles”.
Now, there are many, many programming principles out there. Some are more like “laws” that describe how systems and people behave (like Hofstadter’s law: It always takes longer than you expect, even when you take into account Hofstadter’s law.) and while those are useful in broader contexts, I don’t find them that actionable when I want to write “good code”.
In this article, I go over the rules-of-thumb that help me write better code as I’m writing code. They don’t require me to write out my entire system before they’re useful, they just help me make better decisions as I go.
From pithy statements to actual useful habitspermalink
When you’re just starting out, at some point someone is going to point at your code and say “Premature optimisation is the root of all evil”. That sounds super serious, but also kind of odd.
Optimisation is something that’s good while “premature” usually isn’t. The big problem here is that you’ve now been told that what you’re doing is evil, but you don’t actually know what to do next. Avoiding premature optimisation is a good programming principle, but it doesn’t actually help you write better code.
A helpful senior developer might help you out there and explain You Aren’t Gonna Need It (YAGNI). This is another good programming principle that warns you against writing code that you anticipate needing in the future, but don’t need now. The reason you’re not going to need it is that between not needing it now and the planned future where you would need it, the plans are likely to change in such a way that you actually won’t need it after all. It’s better then to write things “just in time”, when you actually need them.
However, you’ll also be asked to follow the Don’t Repeat Yourself (DRY) principle. Don’t write code that does the same thing in multiple places, because that’s hard to maintain. If you listen to this, all you’d ever do is consolidate code that does the same thing into a single function or module, and call that from multiple places.
All of these acronyms are generally good advice. If you’re building out some functionality, it makes sense to anticipate the future at least somewhat, right? It makes sense that if you need that logic in multiple (future) situations, to optimise or refactor that already. That way you can make sure the current code takes that into account, and you don’t have to write it twice.
YAGNI and premature optimisation are usually brought up because rather than writing the code for the functionality you need now, you start writing code for the general system that offers the functionality you need now, but also the functionality you might need in the future. It means that you end up writing a lot more code, adding a lot more complexity and taking much longer than your senior co-workers.
These principles don’t actually help you as you write code. You could try leaving out random parts that you think you need later, but it would be much nicer to have a principle that helps you know what to leave out.
Enter the “rule of three” — the actually actionable, pragmatic principle that elegantly combines how you should be thinking about YAGNI, DRY and premature optimisation.
The rule of three states that you should only refactor (or optimise) code once you’ve written the same code three times. The first time you write the code, you just write it. It does the thing and only the thing. The second time you see that you need the same code again, you …literally copy-and-paste it and make the few changed you need. Then when you have to do that a third time, only then do you look at the now three implementations you have and generalise them into a single implementation that can handle all three cases.
The concept here is that, once you’ve written that code three times, you have an understanding of what general functionality you actually need, and what parts can be simplified and optimised. After three implementations you know what “level of abstraction” you need, and you can make sure that the generalised implementation actually works for all three cases. I “apply” this principle all the time because it’s so easy (I can count to three!) and it helps me avoid over-engineering right as I’m about to.
When you read up on programming principles, it’s easy to find Laws and Rules that sound very serious, but that require a lot of context to actually apply. I hope that from the example above you can see how getting from the general “premature optimisation is the root of all evil” to the specific “rule of three” has a much bigger impact on your day-to-day coding, and the quality of the code you write.
Writing the right thingpermalink
Lets now zoom in to that first implementation. It can be very tempting to optimise the code right from the start, making sure each line is fast and efficient. Often times though, the fast and efficient code is not the most readable code.
When it comes to writing code, we don’t actually spend that much time writing. Instead, we spend time reading the code we just wrote, or that already exists and reasoning about it to decide what to write next. So the easier it is to read and reason about the code, the faster we can write the right code. Code that’s fast and optimized is great, but if it’s hard to read and reason about, it will slow us down in the long run.
We want it all though: we want to write correct code fast and we want to write code that is fast. So how do we go about that? How do we choose?
Here, I like to apply another principle: “Make it work, make it right, make it fast”, which is attributed to Kent Beck, the inventor of Extreme Programming.
At any point during your coding you can look at your code and ask yourself: does it work? If the answer is no, then you focus on making the code work. You don’t care about anything else. Just make it work. If it works: great!
But things that work aren’t always doing the right thing, so next you ask: is is right? As long as your code isn’t right (it doesn’t behave the way you want to, it makes tests fail, it doesn’t accept the input you give it), you focus on making sure that it works right. Only then, when your code works and your code is right, do you ask: is it fast? If it’s not fast enough, you can now focus on making it fast.
This help you prioritise your efforts. It’s a waste of time optimising code that doesn’t work yet, or that does the wrong thing. You’ll have to rewrite it anyway and then the optimisation is gone too. If your code doesn’t even work yet, there’s no need to worry about whether or not the broken code is right. It’s broken so you need to make it work before you can do anything useful.
What makes this principle so useful is that you can apply it at any time simply by looking at (the behaviour of) your code. You don’t need to plan anything out. You can just ask yourself these three questions, in order, and focus on the one that matters right now. You can forget everything else until it’s relevant.
If you squint, you realise that this is actually the same principle as the rule of three, just applied to a different aspect of programming. Both help you focus on the task you have right now, and avoid getting distracted by other concerns that aren’t relevant yet.
The crappy first versionpermalink
There’s a bunch of principles that help you get into the mindset of writing that first implementation. Principles in this vein tell you to throw away your first implementation, or to build the best simple system for now and, actually, “make it work” in the rule of three is this exact same idea.
There’s a more high-brow version of this too called Gall’s Law, which states that “a complex system that works is invariably found to have evolved from a simple system that worked.”
The idea here is that if you try to account for all things from the start, the thing you end up with doesn’t actually work. An example of this is every “full rewrite” ever that ended up worse than the original and took significantly longer than planned. (this, of course, also has a name: Second-system effect.)
Reading all of that, you might be reminded of Keep It Simple, Stupid (KISS). The problem with KISS though is that if you’re building something to help people complete a task, some complexity might be unavoidable and then, KISS has no answers for you. Some things aren’t simple. Gall’s Law, on the other hand, at least tells you that the complex can exist if you’re honest about starting simple and iterating towards your goal.
Still, that’s not very actionable as you’re writing code, so lets see if we can find some principles that do help you write better code as you’re writing code.
Idempotency
As much as possible, the functions I write are idempotent. That’s a big word isn’t it? All it really means is that a function always does the same thing when given the same arguments. That doesn’t sound groundbreaking, but it actually has pretty big implications for how easy it is to reason about your code.
If you have a function that is idempotent, you can call it as many times with the same argument and it will always return the same result. For example, getting the length of a string is idempotent. No matter how many times you call "hello".length, it will always return 5. If you know the function is idempotent, you don’t have to worry it secretly returns a new string or read a variable from elsewhere in your code. Same arguments in, same result out.
A function that is idempotent can still have side effects as long as those are also idempotent: changing global state or a database entry can be idempotent as long as calling the function more than once always results in the same value being set.
When that’s not the case, calling a function twice (due to a user double-clicking, or a servers retry-logic on a flaky connection) might change the resulting value. That makes reasoning about your code much harder. If you find you need a side-effect, you can split that off into its own function and then make that idempotent. Rather than one larger function, you have two smaller functions that each do one thing.
You can treat idempotent functions like black boxes that let you “forget” the implementation details while you reason about your larger system. It’s like a brain shortcut that makes it easier to reason at a higher level. The function always does the same thing, so you can collapse it into a single step in your mind.
Single responsibility principle
Related to idempotency is the Single Responsibility Principle. This principle states that a function (or module, or class) should have “only one reason to change.”
In practice, this means that there should be one function that takes care of one aspect of your system. A nice example here is using an Object-Relational Mapping (ORM) to handle all your database access. The ORM module is responsible for talking to the database and the rest of your code should have no idea how that works. If you ever need to change how you talk to the database, you need to update your ORM and no other part of your code.
Again, this lets you collapse parts of the system as you reason about it. You don’t need to know how it constructs SQL queries. It’s not the responsibility of the code you’re working on right now. You can just treat an ORM like a black box that does database access for you.
You also see the Single Responsibility Principle described as “a function/module/class should only have one reason to exist.” If that single reason disappears, you should be able to remove that function/module/class entirely. If you switch to a different database and need a new ORM for example, the old one should be completely removable.
What often happens though, is that your ORM grows in functionality: it doesn’t just fetch data from the database, it also mangles that into a specific format that the rest of your code expects. Now, if you want to switch databases, you also need to update all the code that depends on that specific data format. Your ORM has multiple reasons to change, and that makes it harder to reason about.
Instead, you should have one module that only handles getting data from the DB, and another module that only handles data formatting. Now each module has a single responsibility, and you can change (or delete!) one without affecting the other: that’s the single responsibility principle in action.
A small trick to check for single responsibility is to describe what a function/module/class does in a single sentence. If you find yourself saying “and”, then it probably has multiple responsibilities and should be split up.
One level of abstraction
Related to the single responsibility principle is the idea that a function shouldn’t just do one thing, but also only operate at one level of abstraction. The phrase, “level of abstraction” is in itself quite an abstract concept, if you pardon me the overloading of the term. What it means is that when you read through the code in a function, all the operations should be at the same level of detail.
For example, consider this function:
async function processUsers() {
const users = await database.fetchAllUsers();
users.forEach((user) => {
if (user.isActive) {
sendEmail(user.email, "Hello active user!");
}
});
}
In this function, we have three levels of abstraction:
- Fetching data from the database (
database.fetchAllUsers()). - Loop over all the users and filter them to only the active ones (
if (user.isActive)). - Performing an action based on that data (
sendEmail(...)).
Not only do you have to use a lot of “and” to describe this function — it connects to the database to get users and filters them and sends emails only to the filtered users — but you also have to think about three different levels of detail when reasoning about this function: the database, active users and email sending. In real-world code, this might additionally have a bunch more await, validation and error handling.
As you read the function, the thing you’re focused on keeps shifting, making it harder to follow along. For example, first the function is about getting all users, then it’s about filtering them, then it’s about sending emails to those filtered users only.
Some things you can look out for that indicate multiple levels of abstraction:
- Multiple “and” when describing what the function does.
- Multiple loops or iterations over data.
- Mixing “low-level” operations (like database access) with business logic.
If you split this up, you can have three functions that each operating at a single level of abstraction:
async function fetchActiveUsers() {
const users = await database.fetchAllUsers();
return users.filter((user) => user.isActive);
}
function sendEmailsToUsers(users) {
users.forEach((user) => {
sendEmail(user.email, "Hello active user!");
});
}
function processUsers() {
const activeUsers = fetchActiveUsers();
sendEmailsToUsers(activeUsers);
}
The fetchActiveUsers function only deals with fetching and filtering users to those that are active, the sendEmailsToUsers function only deals with sending emails, and theprocessUsers function gets all the active users and then emails all of them. Each function is easier to understand because it only deals with one level of abstraction, and you can reason about each part separately.
Notice how in the refactored version, sendEmailsToUsers doesn’t care where the users come from or what their state is. It just sends emails to the users it has been given. Each function has a single responsibility and operates at a single level of abstraction.
Are they all the same thing?permalink
At this point, you might have noticed that a lot of these principles are related, and that’s because, to butcher a classic:
All good code is alike; each bad code is bad in its own way.
— Tolstoy (if he was a software engineer)
Good code is easy to reason about and things are easy to reason about when it’s clear what each part does. It’s also clear how the parts relate to each other, so all these principles highlight different aspects of writing code that’s easy to reason about.
On the flip side, it can be hard to figure out why some code is bad. Is it because it has multiple responsibilities? Is it optimised but incorrect? Does it take you on a tour of multiple levels of abstraction as you read it line-by-line? Does it do extra things that you aren’t sure what for? All of these things make bad code so hard to work with.
It’s much easier to end up with good code by (dogmatically) sticking to the principles above than it is to have bad code and make it good again.
Next time you’re writing a new function, I hope you catch yourself thinking about these principles, that you stop yourself from optimising code that doesn’t work yet, that you make sure each function only does one thing and that you keep yourself from designing complex systems before you have a simple system that works. Good luck!
Bibliography and further readingpermalink
Like I said, I learned all this stuff on the fly. Here are some of the resources that helped me along the way:
- Refactoring by Martin Fowler. This classic book has a JavaScript edition and though a lot of the code has a distinct Java flavour, the principles are universal.
- Making impossible states impossible by Richard Feldman. A great talk about modelling data structures to make reasoning about your code easier. Though the focus is on data structures and less about business logic like this article, I apply rules from this talk on a nearly daily basis.
- Make It Work Make It Right Make It Fast on the C2 wiki, attributing the quote to Kent Beck.
- Software Design Principles: Single Level of Abstraction
- hacker-laws.com is a collection of programming principles, laws and rules.
Enjoyed this article? You can support us by leaving a tip via Open Collective