Writing "bad code" isn't always bad, especially in a small startup where speed, learning and iteration trump perfection.
Writing bad code well can become a competitive edge to iterate faster, while avoiding future disasters.
Even with bad code, there are some non negotiable areas to happy production code bases.
- It must be simple to refactor, without causing broad or odd bugs
- It must be simple to reason about
- it must be simple to decommission, without risking introducing bugs elsewhere
- No silent failures
The Art of Writing Bad Code
Hyperbolic header aside, there is a definite level of skill to write bad code that doesn't turn into a maintenance nightmare or cost the company money.
It really comes down to learning the rules so well, that you know when and why to break them.
When best practices are understood, it becomes possible to bend or break those practices to suit the current business objectives at hand.
It's important to differentiate that intentional short-cuts differ from ignorance driven ones. If short cuts turn into playing "whack-a-mole" with new and exciting bugs, this approach is not for you.
Your Future Self
When I worked at the Canadian Mental Health Association many years ago, a co-worker had a saying "Your future self will thank you."
She would use this in the context of how your current actions have implications that your future self will need to deal with. Will those implications be good, or bad? Will you thank your past self, or loathe them?
The same approach applies here, but maintainability is the key focus.
In my mind, maintainable code is easy to reason about, simple to extend and can be refactored without causing issue or developer stress.
Without these key factors in play, pillar business goals will be missed anyways.
Devs can't confidently refactor the code? --> they'll procrastinate pushing new features to prod New devs can't understand the code? --> slow on-boarding
To "cut corners" effectively, you must think of your future self, and your future team members. Business goals, team bandwidth capabilities or time to market must never suffer.
In a startup, speed is survival. Often you need to "ship ugly" to test hypothesis. This is often what separates the startups with the pixel-perfect websites and beautiful logos that run out of financial runway with the startups that find a glimmer of hope in their market, and iterate fast until they "crack the code" revenue wise.
As long as the code you ship isn't causing other issues in the company, it is "good enough" and likely contributing to the company's growth and success.
Strong Contracts, Loose Internals
In my experience, in a software product with a frontend, database and backend REST API, there are two main "points of contact" that need to be deeply considered regardless of how "ugly" the code is, or how fast the feature is shipped.
- the contracts between the frontend client and the backend API
- the contracts between the backend API and the database
The beauty of this approach is that it keeps "bad code" isolated to three distinct and easy to reason about areas within the codebase:
- Frontend
- Backend
- Database / data layer
Even if each of these systems have some messy code in them, as long as the contracts between them are strong the system at large is protected in many ways.
It also means reasoning about problems, determining how to refactor, decommission or extend the system become simple exercises. You just need to consider the contracts, and how they may change, or what new contracts need introduced.
As long as the interfaces are clean, the internals are easier and less risky to muck about with.
Something as simple as using DTOs and validators everywhere, even if the rest of the code is a mess, means it is immediately self documented, and very easy to pick back up later.
Another "easy win" here is to document the "why" not the "what" within the internals of each layer because it means you can spell out why a shortcut was taken. Without the "why" being obvious, a future developer (or you!) may look at some odd code in the future and think it was unintentional and change it, unknowingly breaking something else and also departing from the strategy to begin with.
With this approach, logging and hard failures are also very helpful. It means incorrect assumptions or overlooked logic is immediately surfaced, and can be acted on. This avoids deadly silent errors, that ruin user experience unknowingly and send the scrappy MVP to the pit of dead ideas.
Think of the three layers as "under construction islands" that have strong and finished bridges between them.
Context and Intent
As with anything in business, context matters a lot. This approach may or may not be suitable to any individual company. Risk tolerance, company culture, industry and company maturity matter a lot.
The intent shouldn't be to save time just to save time. It's to effectively hit business goals, and understand the risks that are introduced when certain tradeoffs come into play, without introducing "future problems" that could have been avoided.
Takeaways
- “Bad” code isn’t bad if it’s intentional, isolated, and well-communicated.
- Strong contracts between layers matter more than perfect internals.
- Comment why, not just what.
- Plan exit ramps early to make refactoring easy.
- Move fast, but build guardrails that keep the chaos contained.