Make things work, make things right, make things fast

← PreviousElliptic Curves introduction

I have always lived by the mantra “Make things work, make things right, make things fast”. It’s a great way to approach software development, and it has served me well over the years. Lately, though, I see more and more developers, especially newer ones, trying to do all three steps at once, or worse, skipping the first two and focusing purely on making things fast.

Some of this comes from how teams work. I’m more of a Kanban person myself, but I understand why Scrum is popular. The trouble is that many organizations misuse Scrum metrics to reward velocity over engineering quality. Just like the Spice, the Scrum must flow, velocity drops mean long retrospectives and friendly reminders to give our 110% at all times. Speed is everything, because speed delivers.

But even where speed really is the priority, you still have to start with the basics. Thinking about the problem, designing a solution, and implementing it so it’s maintainable and scalable takes a few extra steps up front. I’d argue those steps save time in the long run, because you won’t spend nearly as much of it later fixing bugs and refactoring code. The order is what makes it fast, not skipping ahead.

So here are my thoughts on the three steps.

Make things work

The first step is the foundation for everything else, and it’s the most important, because this is where you figure out how to solve your problem.

Say you’ve come up with a great idea. Your goal now is to validate it as quickly as possible: show that it works and fixes the problem. We don’t care about quality, design, maintainability, or even the language used. The only thing we want to know is whether the idea actually works. It’s as proof-of-concept as it gets.

You’ll find out soon enough that running into problems and making mistakes is part of this step. Maybe the idea didn’t work as well as you hoped, or maybe you hit some unexpected issues. That’s fine. You didn’t sink any time into non-essential things, so delete everything and start over. Now, with extra knowledge and experience, you can try other solutions and see if they work better. The key is to keep iterating until you find something that solves the problem.

One clarification, because it trips people up: at this stage “does it work” can include “does it finish at all”. If your naive approach is so slow it never produces a result, then speed is correctness, and you’re allowed to swap data structures, algorithms, or even languages to get there. That’s different from optimization, we still don’t care whether the working version is fast enough, only whether it works at all.

Make things right

Often we don’t even reach this step, and that’s okay. Maybe it was enough that it worked. Maybe it was a one-time thing, or a prototype to show a customer, or something we end up not needing at all. Because we didn’t spend time on non-essential things, we still used our time as efficiently as we could.

But if we do move on, this is where we focus on getting things right. Suppose you need to read from or write to a database. In the previous step, you might have just dumped a JSON blob to a file, perfect at the time, since it saved you from setting up a database, creating tables, and wiring up an ORM or hand-written SQL. Now that you have a solution that works, it’s much easier to design a proper database layer. Maybe you reach for an ORM, maybe you write raw SQL.

The same goes for the rest of the code. A simple mutable structure holding your state might be a good candidate to refactor into something like a message-passing system. And now that the code works, it’s far easier to extract interfaces from the concrete implementations you threw together during the prototype. These are all simple things to do after the fact, but much harder while you’re still trying to make things work. Once the system works, the right abstractions usually become obvious.

This is the time to see where the design can be more maintainable, more scalable, more testable. We want to make sure the solution isn’t just a quick hack, but something that can be used and maintained for the long run, clean, well-structured, and easy to understand. Not a band-aid, but something you can build on and improve over time.

This step is so much easier than it sounds, precisely because we’re now only improving a design rather than inventing one. We can focus on one thing, without worrying that we’re polishing code that’s going to be thrown away five minutes from now.

Make things fast

As with the previous step, we might not even need to do this. Maybe the solution is already fast enough. Maybe we don’t care about performance at all, there’s a nice xkcd comic on whether it’s even worth the time.

But when we do need to optimize, we want to make the right things fast, not just things in general. At this point we already have a working, maintainable, well-structured solution, which means we can identify bottlenecks far more easily. We can swap implementations, benchmark different approaches properly, and verify whether a change actually improves performance instead of just feeling faster. And we haven’t fallen into the “premature optimization” trap, because we’ve already made sure the solution is right and that it works. There’s nothing premature about it, this is exactly the right time to optimize.

In this step your codebase is clear enough, and your solution is clear enough, that performance work is much easier than it would have been earlier. Once again, you can focus on one thing at a time, without redesigning the whole system as you go.

One PR per step?

Here’s where I’ll take a stance that not everyone will agree with: I think each of these steps can be its own pull request. One PR to make it work, one to make it right, one to make it fast, merged in sequence into the main branch.

I can hear people screaming already. “We don’t want unoptimized code in main!” “We don’t merge things we know aren’t up to our standards!” And sure, you could do all three steps in one PR, but then you’ve handed the reviewer everything at once, and now they’re judging whether it works, whether it’s well-designed, and whether it’s fast, all in the same breath. A single “make it work” PR asks one question: does it work? The “make it right” PR asks whether the design holds up. The “make it fast” PR asks whether the numbers improved. Each one is smaller, sharper, and far easier to review well.

This is where each organization needs to find the right balance. If your team is disciplined enough to follow through on the “make it right” and “make it fast” PRs, then the benefits of merging the “make it work” PR early are huge. You get working code into the main branch sooner, which means you can test it, build on it, and iterate on it faster. You also get the benefits of reviewing each step independently, which means you can focus on the specific concerns of each stage without getting overwhelmed by the whole picture at once.

But you could all do this separated on (stacked) branches instead of the main branch, and merge them all together at the end. That way you don’t have to worry about unoptimized code in main at all. The downside is that you lose the benefits of getting working code into main early. The “make it work” PR might sit on a long-lived branch for a while, which means it can drift further from reality as time goes on.

Conclusion

“Make things work, make things right, make things fast” is a great mantra to live by in software development. It lets us experiment, iterate, and improve systems without trying to solve every problem at the same time.

It should be perfectly fine to build something that works, even if it isn’t pretty or fast yet. In the end, even a slow and ugly solution will make more money than a fast and beautiful one that doesn’t work.

← PreviousElliptic Curves introduction