A parable about missed deadlines and over-engineering
Some time ago I attended to a coding interview for the position of Data Scientist at one start-up. I felt myself well-prepared and confident, practicing lots of programming puzzles, coding various Machine Learning techniques from scratch, and having several years of programming experience under the belt. What can go wrong?
Unfortunately, I failed at the thing that doesn’t have any relation to the Gradient Descent methods or Time Complexity analysis. No, the failure was related to something very different and much more complicated. It was a Tic-Tac-Toe game!
I bet at this moment, some of you just close this story with thoughts: What, a Tic-Tac-Toe game? How can one fail to implement such a simple thing? You should be a total newbie, man, not being able to solve such a trivial exercise!
The rest of you, who decided to give me a chance, I invite you to read my story and probably this experience help you to escape similar mistakes in your software development practice and be more careful.
How Can Anything Go Wrong Here?
Now let me explain the interview task more thoroughly to give you enough context and understanding of the project’s scope. The following list enumerates the minimal requirements.
- Terminal-based interface.
- Turn-based gameplay; the one player makes a move after another one.
- At the very beginning and after each turn, the game shows whose turn it is, and allows to make a choice.
- When the turn is submitted, the game automatically shows a new board’s state and analyzes if there is a winner. If there is one, the UI shows information about the winner, and the game ends.
- The game is written in Python ≥ 3.x.
Sounds very straightforward, right? The greatest twist here is a time limit. You’re only given a 1 hour to build the MVP, i.e., working terminal-based game for two players. Also, there is an additional list of priorities that clarify and extend the design requirements. Note that they list in the order of decreasing importance.
- The game meets the minimum requirements stated above.
- The UI is intuitive and doesn’t require additional instructions.
- The code is well-structured, compact, and easy to read.
- Tests are proving the correctness of the developed program and its components.
- The game can handle incorrect input.
- The UI is convenient.
- The code uses modern language features and libraries.
Ok, great, the requirements are clear. Everything looks pretty simple and its time to start building the code! It shouldn’t be too tricky…
I Know How To Prioritize Things
As soon as I’d read the document with requirements, my thoughts were: Hey, the basic gameplay of a Tic-Tac-Toe game is very trivial so let’s focus on the UX and handling the input at the beginning, and then build the rest of the game logic. I wanted to build something fancy and interactive. Up to this moment, I’ve developed many CLI tools, and simple terminal experience wasn’t enough. So I chose the
curses library to make things interactive and convenient. (I know, it could sound strange to talk about “convenience” of a terminal-based interface, but still…)
There is one small question here: How would you debug a program that controls the terminal? Here is a small snippet to explain what I am talking about. The code below shows the simplest
curses program to interactively with a breakpoint.
Looks nice, right? Until you try to run it.
Having an interactive UI wasn’t a strict requirement, as you see from the list shown above. Still, this thing drained my attention and a considerable part of the allocated time.
When you run this snippet, it not shows you what you could expect. As you have probably already guessed, the
curses take control over the terminal session, and when a debugger tries to render its interface, things are totally broken.
I’d spent a whole lot of time trying to set things up and deal with a broken debugger. Sure enough, the precious minutes wasted without bringing any value to the developed program. The most interesting thing here is that having an interactive UI wasn’t a strict requirement, as you see from the list shown above. Still, this thing drained my attention and a considerable part of the allocated time.
It Is Easy To Refactor This God-Class Later
After I’d gave up my attempts to tame the terminal, I started building the game logic. The main class called
Game (yes, a pretty unusual name for a class that manages the game) was responsible for storing the board state, switching the active player, and managing the whole game process.
As you already know, I was running out of time, so I’d tried to focus on the building working solution and put off the refactoring for the very end of the process. After some time, the
Game was responsible not only for the gameplay but also rendered the board, including a few hard-coded constants and so on. Without notice, my
Game class became responsible for everything.
Ad-hoc solutions and quick patches had led to the avalanche of bad code without time to fix it
So as you can guess, I didn’t have enough time to factor out this
AllmightyGame class into something more manageable and light-weighted. It becomes a great illustration of God-class anti-pattern instead. Ad-hoc solutions and quick patches had led to the avalanche of bad code without time to fix it. It seems that distinguishing the clean code from the ugly one is a more simple thing than spotting how it slowly slides from one category into another.
Writing Tests Takes Too Much Time
An ugly game logic class, a fragile UI, and the time given to complete the task is almost over. Not bad for such a “simple” thing! How can we safe a bit of time to fix all this mess? Which part of the software development process seems to be the one we can easily drop without much consequences?
The answer is unit tests! The code is so compact that you can see every class and function; we can safely jump straight into application logic coding instead of writing these extra functions, right?
Wrong. Even with the simplistic and trivial programs, you need somehow verify that your input generates a valid output. You still need to write some entry point to run your program, and you are going to execute it very often during the development process. These entry points are precisely the thing tests give you.
The tests not only don’t add too much overhead to your code, but they also bring many benefits to the development process, even for pretty simple, toy programs
Moreover, writing tests has a significant effect on your API building process: it forces you to decouple modules and untangle pieces of your system. See the snippet below. You need to verify that game logic works, and the most straightforward way to do so is to pass the game state from outside. Then you only need to call your winner checking function and see if it works as expected. Otherwise, you would need to run the game UI, and type turns manually. Even for small game states, it would steal a few seconds of your time on each run, not talking about testing bigger boards.
Therefore, the tests not only don’t add too much overhead to your code, but they also bring many benefits to the development process, even for pretty simple, toy programs. I believe there is an opposite tendency actually: the tests could even speed up the development process and help to make it better. One should think twice before shrinking testing to save time.
Sure enough, this little story was written not to tell you how to deal with terminal rendering troubles or write puzzle games. It is musing about Software Development in general.
One could find this “parable” quite shallow with lots of obvious mistakes I’d made. Well, good for you! You are a much better developer than I am. Nevertheless, I decided to share this failure with the rest of us who feel confident and experienced but sometimes forget about such “mundane things” like deadlines, KISS, and MVP. Tight time limits and perceived task’s simplicity could play a bad joke of you, and I am talking not only about test tasks but the daily development process as well. So let’s go through the lessons we can learn from this story.
- Don’t put the horse before the cart. If you don’t have a working product, everything else doesn’t make much sense. I’ve seen similar things in my carrier when a company starts with building fancy customized dashboards, a bunch of cross-platform applications, tries to account all possible edge cases when it would be more beneficial to focus on the minimal logic and not to spread the efforts.
- Being busy doesn’t imply making progress. It becomes evident when you’re given just an hour to complete the task. Such a short period highlights things that are true for a broader scope as well. Moreover, long development timespan can easily hide the issue.
- Try to use test-driven development approach whenever it’s possible. Tests can’t guarantee the absence of bugs, but they force you to separate the code into smaller fragments, decouple submodules, inverse dependencies, and in general, write a cleaner API. It is a pretty good method to structure the thinking process and better understand the requirements.
- Go with the technologies you know best. At least, at the very beginning of the development process. Even when the task seems to be super easy, resits the urge to combine its implementation with the learning of a new framework.
- Don’t put the user’s convenience before the clean code. You should keep the convenience of the users in mind but make of it an ultimate goal probably not a good idea. This point has lots in common all the previous ones. Following accidental requests from your customers, you tend to ignore testing, making ad-hoc changes, hacking the codebase, and essentially “overfitting” the quality of your product.
Many of these things could seem obvious and many times repeated. (Not talking about plenty of great books). Nevertheless, I believe that every of us has a chance to fall into the same trap, and it is a good idea to remind yourself about these simple observations every once in a while to be in a good fit and make better decisions.