Working backward
After becoming frustrated with overengineering, I adopted an approach to prevent it: setting a goal and working backward.
What is working backward?
Working backward is setting goals and asking what’s needed to get there. It’s working from the end to the beginning, from knowns to unknowns. I’ll describe the method with examples of various types and generalize later.
Begin with the problem solved
When you set out to solve a problem, you imagine a world where that problem is solved. Then you ask, “What do I need to make this a reality?”. You start with the known: people don’t have the problem anymore and navigate to the unknown: how to get there. This is true whether you’re creating a new product or adding abilities to an existing one. In the second case, you add the problem to the board as a user story and slice it down into smaller user stories, each incrementing value to the product. When picking up one of them, you start with the UI and move down until you solve the problem.
Beginning with the end in mind (the goal) is the most useful way to focus on the real problem and avoid discussing solutions too early. It also helps to do solely what’s needed. The opposite of this is developing technological solutions and forcing them into user needs.
Having a goal in mind doesn’t mean you need to follow a plan. It’s crucial to remain flexible and adapt to new information as you move forward. As you deliver small chunks of value, your understanding of the domain will likely change, and the world around you evolves. Therefore, it’s important not to view the goal as written in stone.
Begin with the interface
When starting the work on a user story (which is only a problem statement), it’s common to start discussing algorithms or even the database, but that’s plain wrong. Generically speaking, you should start at the top (the top is usually a UI), build the next need, and do it recursively till you reach the lower level — top-down approach. Unlike the bottom-up approach, where you build low-level components first and try to guess what’s needed above, in the top-down approach, you’re driven by needs rather than guesses.
In the top-down approach, we repeat a cycle of defining and implementing an interface (interfaces can be GUIs, CLIs, REST APIs, function/method signatures, etc.). At each level of abstraction, the interface establishes a contract you must fulfill. Only after implementing it can you move further down the tech stack. For example, in web development (with client-side rendering), design a minimal UI first, try to implement it (perhaps using dummy data), and only then think about the needed backend API and use it (repeat this cycle until the database). If you’re API-first, you may want to start with the OpenAPI definitions.
A top-down approach ensures you don’t waste time building generic overengineered components trying to guess how they’ll be used. When you journey from the user problem to the database, you build exactly what’s needed and no more, level by level. Another benefit is that it encourages ubiquitous language since actual needs drive code writing.
Begin with the test
Test-driven development (TDD) is the prime example of beginning with the end in mind and moving steadily toward the goal. It tells you to start with the test (specification) — the reason for the change, the goal. Then, you jump to the implementation. You do it iteratively, relying on the TDD cycle.
When we write a test, we imagine the perfect interface for our operation. We are telling ourselves a story about how the operation will look from the outside. Test-Driven Development
TDD offers separation between interface design decisions & implementation design decisions. TDD forces you to think about the usage (what) before the implementation (how), so you’ll hardly produce code that’s hard to use, as the tests are the first users of the code.
Begin with the comments
Some people defend a comments-first approach. It involves writing a plan as comments in plain language to explain what we aim to do before jumping into the code details. By expressing intentions upfront, we’re forced to articulate and structure our thoughts. This way, we may spare some back-and-forths and code rewrites.
def main():
# 1: request the type of shape
# 1: request the user the dimensions of the shape
# 2: calculate the area of the shape
# 3: print the calculated area
...The second, and most important, benefit of writing the comments at the beginning is that it improves the system design. […] The simpler the comments, the better I feel about my design. A Philosophy of Software Design
In my case, comments-first may be useful when I need to clarify my thoughts, especially if I’m not pairing. In those cases, I convert the comments to function calls. While I appreciate forcing myself to think about the goal before delving into implementation details, I prefer TDD and a steps-first approach, as we’ll see now.
Begin with the function steps
If you want to write a function, you should first write all the steps needed to achieve the function goal without knowing how they will be implemented. Those steps are merely calls to non-existent functions with the inputs and outputs in place.
// the function below was writen without any of the referenced functions
// (when done, you can ask your code editor to generate the needed functions)
func main() {
customers := readCustomersCSV("input_data.csv")
custumers30YearlsOld := filterCustomers(customers, "age", 30)
customersWithDiscount := activateDiscount(custumers30YearlsOld)
writeCustomersCSV("output_result.csv", customersWithDiscount)
}
// you should apply this mindset to any function (not just main)Notice that the steps in the example are written almost in plain language. It works like an outline or an index when you read it at a glance. As in a recipe, you can tell the whole story from there (besides, all the steps present the same level of abstraction). It’s self-documenting code.
With steps-first, you’re forcing yourself to define function signatures before implementation, driven by their intended usage, like an upfront plan on a tiny scale. Then you apply this reasoning recursively (top-down approach), starting with the entry point (e.g., main), creating functions (at the same level of abstraction) and doing the same in each of them until the problem is solved. Besides, you should try to properly name a variable before knowing how it will be computed rather than the opposite, which forces you to define the step purpose first.
Steps-first is a way to tackle inherent complexity because you’re splitting a problem into smaller ones (“dividing to conquer”) — the functions you’ll create later.
Begin with the function goal
What is coding backward? It’s a way to write functions where you start with the function goal — its return or side effect (depending on whether it's a query or a command). Write the function starting at its end (that’s what’s known from the start) in the last line of code, and work your way up (into the unknown). Per line of code, you must ask, “What do I need just above to make this feasible?”. Do this until there’s nothing else to do.
// Iteration 1️⃣
func main() {
// step 1: what do I want to achieve?
// to write a CSV of 30yo customers with discount
// now I need to compute that variable...
writeCustomersCSV("output_result.csv", customersWithDiscount)
}
// Iteration 2️⃣
func main() {
// step 2: now I need custumers30YearlsOld...
customersWithDiscount := activateDiscount(custumers30YearlsOld)
writeCustomersCSV("output_result.csv", customersWithDiscount)
}
// Iteration N
// ... (till you're done)You can apply this technique to any function, especially higher-level ones like main.







