DEV Community

Cover image for Stop Overengineering: How to Write Clean Code That Actually Ships 🚀
TheBitForge
TheBitForge

Posted on

Stop Overengineering: How to Write Clean Code That Actually Ships 🚀

I've spent the better part of a decade writing code that I thought was brilliant, only to realize six months later that it was a monument to my own insecurity. You know the type—the kind of codebase where you need a PhD to understand why a simple feature takes three layers of abstraction, two design patterns you learned from that one blog post, and a configuration file that's somehow longer than the actual implementation.

Here's what nobody tells you when you're learning to code: the goal isn't to write the most elegant, theoretically perfect system. The goal is to solve a problem for real people, ship it before your competitor does, and make enough money to keep the lights on while you figure out what to build next. Everything else is just intellectual masturbation dressed up as engineering excellence.

I learned this the hard way, naturally. We all do. There's something deeply ironic about the fact that the worst code I've ever written was the code I spent the most time trying to make "clean." The best code I've ever written? That was usually the stuff I banged out in an afternoon because we had a customer waiting and I didn't have time to be clever.

This isn't going to be another blog post telling you to write unit tests or use meaningful variable names. You already know that stuff. This is about the much harder problem of knowing when to stop—when your code is good enough to ship, and when you're just stalling because you're afraid of what happens when real users touch your precious system.

What Overengineering Actually Looks Like in the Wild

Let me tell you about a project I worked on three years ago. We were building a feature that let users export their data as a CSV file. Simple, right? Download some rows from the database, format them as comma-separated values, send them to the browser. Any junior developer could knock that out in an afternoon.

But we were a "serious engineering team" at a "high-growth startup," which meant we couldn't just write a simple function that dumped data to a file. No, we needed to think about the future. What if we wanted to support JSON exports? What if we needed XML? What about Excel files with multiple sheets? What if we needed to support exports of billions of rows?

So we built an abstraction layer. We created an ExportStrategyFactory that returned different implementations of an IExportStrategy interface. We had a configuration system that mapped file types to strategy classes. We wrote a custom streaming framework to handle large datasets, even though our largest customer had maybe fifty thousand rows of data. We spent three weeks on this feature.

The kicker? Two years later, nobody had ever requested anything except CSV exports. Not once. All that infrastructure, all that clever architecture, all those unit tests for edge cases that never materialized—it was all waste. Pure, uncut waste. And when we finally did need to add PDF exports for a big enterprise customer, the abstraction we'd built was so specific to our original assumptions that we had to hack around it anyway.

That's overengineering. It's not about writing bad code or being lazy. It's about solving problems you don't have, building for futures that will never arrive, and prioritizing theoretical elegance over practical shipping. It's the difference between building a treehouse and building a treehouse that could theoretically be converted into a spaceship if we ever need to evacuate Earth.

Here's another example that might hit closer to home. A friend of mine was building a web application for a local bakery. They needed to track orders, manage inventory, and send email receipts. Standard stuff. But my friend had just read a book about microservices, and he was convinced that this was the moment to apply everything he'd learned.

He split the application into seven different services. One for user authentication. One for order management. One for inventory tracking. One for email notifications. One for payment processing. One for reporting. And one orchestrator service to coordinate them all. Each service had its own database, its own deployment pipeline, its own monitoring setup. The whole thing communicated through a message queue because, you know, loose coupling and all that.

The bakery never opened. My friend spent nine months building infrastructure and never got around to the actual features the bakery needed. Last I heard, the owner went with a competitor who had a working product in three weeks. The competitor's code was probably a mess—one big Rails monolith with all the business logic crammed into fat controllers. But you know what? It worked. It shipped. It made money.

The painful truth is that overengineering doesn't look like bad code on the surface. In fact, it often looks like really good code—clean abstractions, clear separation of concerns, all the design patterns in their proper places. The problem is that all of that goodness is in service of problems that don't exist yet and might never exist at all.

Why Smart Developers Build Things Nobody Asked For

This is the part where I have to be honest about why we do this to ourselves. Overengineering isn't usually about incompetence or laziness. It's about fear, ego, and a fundamental misunderstanding of what our jobs actually are.

Let's start with fear. When you're staring at a blank editor and you need to ship a feature by Friday, there's a voice in your head that says: "What if this code isn't good enough? What if people look at this and think I'm a bad developer? What if this design doesn't scale? What if I have to rewrite this in six months?" That voice is loud, and it's convincing, and it tells you that the only way to be safe is to build something so robust, so flexible, so theoretically perfect that nobody could ever criticize it.

So you add another abstraction layer. You make everything configurable. You build in extension points for features that don't exist yet. You write twice as much test code as production code. And with each addition, you feel a little bit safer, a little bit more professional, a little bit more like you're doing Real Engineering™.

The problem is that all of this is a form of premature optimization, but for your ego instead of your performance. You're not building for the users—you're building for the imaginary code reviewer in your head who's going to judge every decision you make. And that reviewer is way harsher than any real human would ever be.

Then there's ego. Let's not dance around it. We became developers because we're smart, and we like solving hard problems, and we want other people to know we're smart at solving hard problems. Writing a simple function that does one thing well doesn't feel impressive. It doesn't showcase your skills. It doesn't prove that you've read all the right books and blogs and understand all the patterns.

But building a elegant system with multiple layers, each one showcasing a different design principle? That feels like real engineering. That feels like the kind of work that separates the seniors from the juniors. Never mind that the juniors are shipping features twice as fast because they're not bogged down in abstraction hell.

I've seen this play out in code reviews dozens of times. Someone submits a simple, straightforward solution to a problem, and immediately the comments roll in: "Have you considered using the Strategy pattern here?" "This should really be a separate service." "We should make this more generic so we can reuse it." And nine times out of ten, those comments aren't actually improving the code—they're just someone trying to prove they know things.

The third reason we overengineer is that we genuinely don't understand the trade-offs we're making. When you read blog posts and documentation and conference talks, they're almost always about big companies solving big problems. Google talking about how they scale to billions of users. Netflix explaining their microservices architecture. Amazon describing their deployment pipelines. And all of that is really impressive and sounds really smart, so we assume that's how we should build software too.

But here's what those blog posts don't tell you: those companies didn't start with that architecture. They evolved into it, painfully, over years, after they already had millions of users and hundreds of engineers and actual problems that required those solutions. Google didn't start with Kubernetes. Netflix didn't launch with microservices. Amazon didn't begin with service-oriented architecture. They started with code that was simple enough to ship, and then they refactored as their problems actually manifested.

When you copy the architectures of companies at massive scale without having massive-scale problems, you're not learning from their success—you're cargo-culting their current state while ignoring the journey that got them there. It's like watching someone who's run ten marathons and deciding that you should train the exact same way they do now, ignoring the fact that they started by jogging around the block.

Clean Code vs. Clever Code: There's a Difference and It Matters

Here's a distinction that took me way too long to understand: clean code and clever code are not the same thing, and in fact they're often opposites. Clean code is code that someone else can read, understand, and modify without wanting to cry. Clever code is code that makes you feel smart for having written it.

I used to write a lot of clever code. I'd find ways to accomplish something in one line instead of five. I'd use language features that most people didn't know about. I'd structure things in ways that were technically elegant but required you to hold a lot of context in your head to understand what was happening. And I felt really good about this code. I'd show it to other developers and watch them puzzle over it, and I'd interpret their confusion as admiration for my skills.

Then one day I had to fix a bug in some code I'd written six months earlier. I looked at it and had absolutely no idea what I'd been thinking. The cleverness that had seemed so obvious at the time was now completely opaque. I spent two hours unraveling my own abstractions just to change one conditional. That's when it finally clicked: code isn't just written once, it's read hundreds of times. And every minute someone spends trying to understand your clever solution is a minute they're not spending on actually solving problems.

Clean code, on the other hand, is almost boring in its clarity. It does what it says it's going to do. The functions have names that tell you exactly what they accomplish. The structure mirrors the problem domain in a way that makes sense. When you read it, you're not impressed by the author's technical wizardry—you barely think about the author at all. You're just thinking about the problem being solved.

Let me give you a concrete example. Here's some clever code I wrote a few years ago:

const processUsers = users => users
  .filter(u => u.active && !u.deleted && u.email)
  .map(u => ({...u, normalized: u.email.toLowerCase().trim()}))
  .reduce((acc, u) => ({...acc, [u.id]: u}), {});
Enter fullscreen mode Exit fullscreen mode

Is this bad code? Not really. It's concise, it uses functional programming principles, it doesn't have side effects. I was pretty proud of it at the time. But here's the thing: when someone else needed to add a new transformation to this pipeline, they had to understand the entire chain of operations. And when we needed to debug why certain users weren't showing up, we had to break it apart to figure out which step was filtering them out.

Here's the clean version:

function processUsers(users) {
  const activeUsers = users.filter(user => {
    return user.active && !user.deleted && user.email;
  });

  const normalizedUsers = activeUsers.map(user => {
    return {
      ...user,
      normalized: user.email.toLowerCase().trim()
    };
  });

  const usersById = {};
  for (const user of normalizedUsers) {
    usersById[user.id] = user;
  }

  return usersById;
}
Enter fullscreen mode Exit fullscreen mode

Is it longer? Yes. Does it use more lines? Absolutely. But you know what? When someone needs to modify this code at 11 p.m. because there's a production bug, they can understand each step independently. They can add logging between steps. They can modify one part without having to reason about the entire pipeline. And that's worth way more than saving a few lines.

The difference between clean and clever shows up in naming too. Clever code has variable names like d for data or ctx for context. Clean code spells things out: userAccountData, validationContext. Clever code uses abbreviations and inside jokes. Clean code uses the same terminology the business uses, even if that means longer names.

I worked with a developer once who was obsessed with making everything as terse as possible. He'd write functions like pUsr(u) instead of processUser(user). When I asked him about it, he said typing extra characters was a waste of time. But you know what's a bigger waste of time? The thirty minutes I spent trying to understand what pUsr did, multiplied by every developer who ever touched that code.

The interesting thing about clean code is that it often looks simple enough that people think it was easy to write. And sometimes it was. But often, writing truly clean code is harder than writing clever code because it requires you to deeply understand the problem and find the simplest possible solution. Anyone can make something complicated—that's the default state of software. Making something simple requires discipline and taste.

How Overengineering Murders Momentum and Destroys Teams

There's a cost to overengineering that goes way beyond the time you spend writing code that doesn't need to exist. It kills momentum, and in software, momentum is everything. When a team is moving fast, shipping features, getting feedback from users, iterating quickly—that's when the magic happens. That's when you figure out what you're actually building and who it's for. Overengineering is like throwing sand in the gears of that process.

I've seen this destroy teams. You start with a small group of developers who are excited about building something new. Everyone's energized, ideas are flowing, you're making visible progress every day. Then someone decides that you need to "do this right" before you can ship. Maybe it's the tech lead who wants to build a solid foundation. Maybe it's the CTO who's worried about scalability. Maybe it's just a developer who read too many Medium posts about architecture.

So you stop shipping features and start building infrastructure. You need a proper deployment pipeline. You need service discovery. You need distributed tracing. You need a comprehensive testing strategy. You need to standardize your API patterns. And all of this sounds really responsible and professional, so everybody agrees that it's necessary.

Three months go by. You've built a lot of infrastructure. You've got services and pipelines and configuration management and all the trappings of a mature engineering organization. But you haven't shipped anything users can see. The marketing team is getting antsy because they don't have anything to show investors. The sales team is frustrated because they're losing deals to competitors who have working products. And the development team is starting to feel the weight of all this infrastructure they've built—every new feature now requires changes to multiple services, configuration updates, coordination between teams.

What started as an effort to build things "the right way" has become a bureaucracy. The energy drains out of the room. Developers who were excited about building a product are now just maintaining infrastructure. The speed at which you can ship new features has slowed to a crawl because every change touches so many systems. And worst of all, you still don't really know if anyone wants what you're building because you haven't put it in front of users yet.

This is the hidden cost of overengineering: it doesn't just slow you down, it changes what you're optimizing for. Instead of optimizing for learning—for figuring out what users actually need—you're optimizing for theoretical correctness. Instead of optimizing for flexibility—for being able to quickly pivot when you learn something new—you're optimizing for consistency within your overly complex system.

I watched a startup die because of this. They spent a year building a platform that was supposed to revolutionize how people did X. (I'm being vague on purpose.) The architecture was beautiful. They had clean separation between the frontend, the API layer, the business logic services, and the data layer. They had comprehensive testing at every level. They had monitoring and alerting and feature flags and A/B testing infrastructure. They had everything you could possibly want in a modern software system.

They also had exactly three customers when they ran out of money. Turns out the problem they were solving wasn't actually the problem their target users had. But they'd invested so much in their architecture, and they were so proud of how "right" they'd built everything, that pivoting would have meant throwing away most of their work. So they doubled down instead of adapting, and they died with a beautiful codebase that solved the wrong problem.

Compare that to a different startup I know. They built their first version in two weeks. It was a mess—one giant file, no tests, hard-coded configuration, all the best practices violated. But it worked, and they got it in front of users immediately. Those users told them what was wrong, what was missing, what they actually needed. So they fixed those things. Then they got more users, and those users told them different things. And they kept iterating, shipping new versions every few days, learning constantly.

Six months in, they had thousands of active users and a clear understanding of what they were building. At that point, with actual revenue coming in and actual problems to solve, they started refactoring. They broke apart the monolith where it made sense. They added tests for the parts that kept breaking. They built infrastructure for the specific scaling problems they were actually encountering. And because they did all of this in response to real problems, every piece of complexity they added was justified.

The momentum thing works at an individual level too. When you're building something and you're in flow—you know exactly what you need to build next, you're making visible progress, you can see the finish line—that's when you do your best work. But when you're stuck in abstraction hell, trying to design the perfect system before you've written a single line of code that actually does anything, that flow evaporates. You spend your days in architecture discussions and design documents, and at the end of the week you've shipped nothing. And that feeling of shipping nothing, week after week, is soul-crushing.

Writing Code for Today's Problems, Not Tomorrow's Fantasies

This might be the most important lesson I've learned in my career: you cannot predict the future, so stop trying to build for it. Every hour you spend adding flexibility for requirements that don't exist yet is an hour you're not spending on the requirements that do exist. And when those future requirements eventually arrive—if they ever do—they're never quite what you imagined anyway.

I used to be really bad about this. Whenever I built a feature, I'd think about all the ways it might need to evolve in the future. What if we need to support multiple currencies? What if we need to localize for different regions? What if we need to customize this per customer? And I'd build all of that flexibility in upfront, just in case.

The result was code that was way more complex than it needed to be for the actual current requirements. Simple things required configuration. Straightforward logic got wrapped in abstractions. And when the future I'd imagined never came to pass—or came to pass in a completely different way than I'd predicted—all that extra complexity was just dead weight that made the real changes harder.

Here's a specific example that still makes me wince. I was building a payment processing system for a SaaS product. At the time, we only accepted credit cards through Stripe. But I thought, "We might want to support other payment providers in the future. Better make this generic!" So I built a whole payment gateway abstraction layer. I had interfaces for PaymentProcessor, PaymentMethod, TransactionResult, all of it. I had a factory that selected the right processor based on configuration. I had adapters and mappers and all the patterns.

The actual Stripe integration code was maybe 100 lines. The abstraction layer around it was 500 lines. And you know what? We never added another payment processor. Not in the three years I worked on that product. The one time we did need to modify the payment flow was to add support for subscription pausing, and my beautiful abstraction layer didn't help with that at all because I hadn't predicted that requirement. So we ended up hacking around it anyway.

If I could go back and do it again, I'd just write the Stripe integration directly. No abstractions, no interfaces, just the straightforward code to charge a credit card. And then when—if—we ever needed to add PayPal or Apple Pay or whatever, I'd refactor at that point. With the benefit of knowing what the second payment processor actually looked like, I'd be able to build an abstraction that actually made sense instead of one based on my imagination.

This principle applies to everything. Don't build a configuration system until you need to configure something and changing code is actually painful. Don't build a plugin architecture until you have actual plugins you want to support. Don't build for scale until you have something worth scaling. Don't build for internationalization until you have international users.

There's a counterargument to this that I hear a lot: "But if we don't build this flexibility now, it'll be so much harder to add later! We'll have to rewrite everything!" And okay, yes, sometimes refactoring is harder than getting it right the first time. But here's what that argument misses: maybe you never need that flexibility. Maybe your assumptions about the future are wrong. Maybe the company pivots. Maybe the feature gets killed. Maybe the requirement changes in a way that makes your abstraction useless anyway.

And even if you do eventually need to refactor, so what? Refactoring is a normal part of software development. It's not a failure—it's a sign that you learned something you didn't know before. The alternative is building flexibility you don't need, maintaining it forever, and having every developer who touches the code wonder why this simple thing is so complicated.

Let me put it this way: the cost of building something you don't need is paid upfront and then every single day after as people maintain that unnecessary code. The cost of refactoring when you need it is paid once, at the point where you have maximum information about what you actually need. Which one sounds like a better deal?

There's also something liberating about just solving today's problem. You don't have to imagine every possible future. You don't have to predict how requirements might evolve. You just have to look at what's in front of you and ask: what's the simplest thing that could possibly work? And then you build that. And if it turns out you need something different later, you'll build that then, with the benefit of everything you've learned in the meantime.

The Real Cost of Premature Abstraction

Abstractions are supposed to make our lives easier. They're supposed to hide complexity, provide clean interfaces, make code more reusable. And when you have the right abstractions, they do all of that. The problem is that figuring out what the right abstractions are is really hard, and you almost never know enough to get them right on the first try.

Premature abstraction—building abstractions before you understand the problem well enough to know what should be abstracted—is one of the most expensive mistakes you can make in software development. It's expensive because abstractions add indirection, and indirection makes code harder to understand. It's expensive because wrong abstractions are harder to work with than no abstractions at all. And it's expensive because once you've built an abstraction, there's pressure to keep using it even when it doesn't quite fit.

I learned this lesson from a project where we were building a CRM system. Early on, someone on the team noticed that we were going to have several different types of entities: Companies, Contacts, Deals, and Tasks. And they reasoned that all of these entities had some things in common—they all had names, they all had timestamps, they all needed CRUD operations. So they built a generic Entity base class that all of these would inherit from.

On paper, this seemed smart. We'd reuse code, maintain consistency, make things easier to extend. In practice, it was a nightmare. Companies and Contacts did have some overlap, but Deals worked differently—they had stages and probabilities and value amounts that didn't map to the Entity abstraction. Tasks were even worse—they had due dates and assignments and completion states that were completely unique.

So we started adding more and more fields to the Entity base class, most of which only applied to some entity types. We added type checking to skip validation for fields that didn't apply. We added special cases in the UI code to hide fields that weren't relevant. The abstraction that was supposed to simplify things was now the source of most of our complexity.

Eventually we ripped it all out and just made each entity type its own thing. Companies had the code for companies. Contacts had the code for contacts. Yes, there was some duplication. Yes, each one implemented its own CRUD operations. But you know what? The code was way easier to understand and modify. When someone needed to add a field to Companies, they didn't have to worry about breaking Deals. When we needed to change how Tasks were displayed, we didn't have to add special cases to a generic renderer.

The hard thing about premature abstraction is that it often seems like good engineering at the time. You're following DRY (Don't Repeat Yourself). You're thinking ahead. You're building reusable components. All the programming books tell you this is what you should do. But DRY is about eliminating duplication of knowledge, not duplication of code. If two things happen to look similar right now but represent fundamentally different concepts, abstracting them together is wrong.

There's a rule of thumb I try to follow now: I don't build abstractions until I have at least three concrete examples that would benefit from them. One example is a special case. Two examples might be a coincidence. Three examples is a pattern worth extracting. This forces me to wait until I actually understand the problem domain well enough to abstract properly.

And even then, I try to build the simplest abstraction that could possibly work. No fancy design patterns. No clever type hierarchies. Just a function or a class that captures the thing that's actually repeated, with a clear interface and a clear purpose. If I need more later, I can always refactor. But starting simple means I'm way less likely to build the wrong abstraction.

The other thing about abstractions is that they're not free. Every layer of abstraction is something developers have to understand and keep in their heads. When someone wants to figure out how a feature works, they have to trace through all the abstraction layers to find the actual code that does the thing. And if your abstractions are deep and your indirection is complex, that tracing becomes a significant cognitive burden.

I once worked on a codebase where a simple "send an email" operation went through seven layers of abstraction. There was an EmailService that used an EmailProvider that wrapped an EmailClient that called an EmailAdapter that used a MessageSender that invoked a TransportLayer that finally called the actual email API. Each layer added some functionality—logging, error handling, retries, whatever—but the net effect was that nobody could understand how emails actually got sent. When email delivery started failing, it took us hours to figure out which layer had the problem.

Contrast that with another project where sending an email was just a function that called the email API directly. Everything it did—formatting the message, handling errors, logging the result—was right there in one place. When something went wrong, you knew exactly where to look. And when we needed to add functionality like retry logic, we added it right there where we could see it, rather than in some abstract layer three levels up.

Simple Design Principles That Work in the Real World

After years of building things the hard way, I've settled on a few principles that actually seem to work in practice. These aren't the kind of principles you find in architecture books—they're more like heuristics for staying sane and shipping things that don't collapse under their own weight.

Make it work, then make it right, then make it fast. This is old advice, but most people ignore the order. They try to make it right and fast from the start, which means they never get to the making-it-work part. Start with the simplest thing that could possibly solve the problem. Get it working. Ship it if you can. Then, with the benefit of seeing it actually work, improve it. And only optimize performance when you have real performance problems, not imaginary ones.

I can't tell you how many times I've seen teams spend weeks optimizing code that runs once a day and takes five seconds. Meanwhile, the critical path that users hit a thousand times per minute is slow as molasses because nobody bothered to measure where the actual bottlenecks were. When you make it work first, you can actually measure where your time is being spent, and you can optimize the things that matter instead of the things you imagine might matter.

Start with the data. Before you write any code, before you think about abstractions, figure out what data you're actually dealing with. What comes in? What goes out? What needs to be stored? What needs to be transformed? Once you understand the data flow, the code practically writes itself. But if you start with abstractions and patterns and try to figure out the data later, you end up with architecture that doesn't match the problem.

I worked on a project once where we spent two weeks designing a system of services and queues and workers before we'd actually looked at the data we'd be processing. When we finally did look at it, we realized that 90% of the records were a single type that could be processed in a completely straightforward way, and only 10% needed special handling. If we'd started with the data, we would have built a simple processor with a special-case handler. Instead, we'd built this complex event-driven architecture that was massive overkill for what we actually needed.

Duplication is better than the wrong abstraction. I mentioned this earlier, but it's worth repeating because it's so counterintuitive for developers. We're taught that duplication is bad, that we should always DRY our code. But duplicated code that's easy to understand is way better than an abstraction that's hard to modify. If you see duplication, resist the urge to immediately abstract it. Wait until you have enough examples that the right abstraction is obvious. And if you're not sure, leave it duplicated.

Keep related things together. This sounds obvious, but you'd be amazed how often codebases violate this principle in the name of "separation of concerns." They'll put all the controllers in one directory, all the models in another, all the views in a third, and then when you want to understand how a feature works, you're jumping between five different directories. Instead, organize your code around features or domains. Put everything related to user authentication in one place. Put everything related to billing in another place. This makes it way easier to understand how things work and modify them safely.

Write functions that do one thing. Not "one thing at one level of abstraction" or "one thing following the Single Responsibility Principle"—just literally one thing. A function that validates an email and sends a welcome message is doing two things. Split it into two functions. A function that fetches data, transforms it, and saves it is doing three things. Split it into three functions. When functions do one thing, they're easy to name, easy to test, and easy to reuse. When they do multiple things, they're hard to name (because the name has to capture multiple concepts), hard to test (because you have to test multiple paths), and hard to reuse (because you rarely need all the things the function does).

Avoid clever code. I've said this before, but it's worth repeating. Clever code is code that makes you feel smart. Obvious code is code that makes other people feel smart. Choose obvious every time. Use standard patterns instead of inventing your own. Use familiar language features instead of obscure ones. Write code that looks like code other people have seen before. There are very few situations where being clever is actually worth the cost in comprehension.

Don't build frameworks, build applications. I've seen so many teams get caught in the trap of building their own framework because the existing ones don't quite do what they want. And maybe those frameworks don't quite fit, but you know what? They're good enough, and they're maintained by people who work on them full-time, and using them means you can focus on building your actual application instead of rebuilding Rails or Django or React from scratch.

The Trade-Off Between Scalability and Simplicity

One of the most common justifications for overengineering is scalability. "We need to build this right because what if we have a million users?" And okay, yes, scalability is important. But here's the thing: most products never get to the point where scalability is their primary problem. Most products die because they couldn't ship fast enough to find product-market fit, not because they couldn't scale to handle their massive user base.

I've built systems that handled millions of requests per day, and I've built systems that handled dozens of requests per day. And I can tell you that the architecture that makes sense for millions of requests is wildly different from the architecture that makes sense for dozens. But more importantly, you can't predict which architecture you'll need until you know what kind of load you're actually dealing with.

There's this assumption that scalability has to be built in from the start, that if you don't design for scale upfront, you'll never be able to scale later. But that's just not true. Twitter started as a Rails monolith. Facebook started as a PHP application. Instagram was a Django app running on a handful of servers when they got bought for a billion dollars. These companies scaled by adding infrastructure and refactoring as they grew, not by predicting their scale problems in advance.

What kills companies isn't starting with a simple architecture. What kills companies is never shipping because they're too busy building a scalable architecture for users they don't have yet. Or shipping late because every feature requires updating multiple services and coordinating database migrations across distributed systems. Or burning out the team because the complexity of the architecture makes every change take three times longer than it should.

So here's my advice on scalability: don't design for scale until you have scale problems. Start with the simplest thing that could work. One server, one database, one application. When that stops being sufficient—when you actually have performance problems or reliability problems or whatever—then you scale. And you scale in response to specific, measurable problems that you're actually experiencing.

When your database starts getting slow, add indexes. When your server starts getting overloaded, add a caching layer. When your monolith gets too big to deploy easily, extract a service or two for the parts that need independent scaling. But do all of this in response to real problems, with real data about where your bottlenecks actually are.

The beautiful thing about scaling in response to actual problems is that you're way more likely to scale the right things in the right ways. When you build for imaginary scale problems, you're just guessing. You might build a distributed system when what you actually needed was better database indexes. You might split everything into microservices when what you actually needed was a better deployment pipeline for your monolith. You might build a complex caching layer when what you actually needed was to fix one slow query.

This doesn't mean you should write code that's actively hostile to scaling. Don't hard-code server URLs. Don't put state in memory if it needs to be shared across requests. Don't build in assumptions that will be obviously wrong at scale. But there's a huge difference between not being stupid and trying to build for theoretical future scale.

The trade-off between scalability and simplicity is real, and it's one you have to make consciously. Every architectural decision that makes your system more scalable also makes it more complex. Distributed systems are more scalable than monoliths, but they're also way harder to debug and test. Microservices give you independent deployability, but they also give you network calls and eventual consistency and distributed transactions. Message queues give you decoupling and resilience, but they also give you debugging nightmares and eventual consistency issues.

The question you have to ask yourself is: is the scalability benefit worth the complexity cost right now, for the problems I actually have? Often the answer is no. And when the answer is no, choose simplicity. You can always add complexity later when you need it. But removing complexity is way harder than adding it.

When Abstraction Is Actually Justified

I've spent a lot of time talking about the dangers of premature abstraction, but I don't want to give the impression that abstraction is always bad. It's not. Good abstractions are one of the most powerful tools we have for managing complexity. The trick is knowing when you've found a good abstraction versus when you're just making things complicated.

So when is abstraction actually justified? The clearest sign is when you have the same concept appearing in multiple places, and you can see that it's genuinely the same thing, not just coincidentally similar code. I'm talking about real duplication of knowledge and behavior, not just code that looks similar on the surface.

Here's an example from a project I worked on. We were building a system that needed to validate different types of user input—email addresses, phone numbers, credit card numbers, postal codes. Initially, each validation was just inline in the code where it was needed. But after a while, we noticed a pattern: every validation had the same structure. Check if the input is empty, check if it matches a format, check if it's in a list of known-bad values, return a structured error message if anything's wrong.

At that point, extracting a validation abstraction made sense. Not because we were following some principle about abstraction, but because we had concrete examples showing us what the abstraction should look like. We built a simple Validator class that took a value and a set of rules, ran the rules in order, and returned either a success or a detailed error. It wasn't clever, it wasn't over-engineered, it was just a straightforward way to capture the pattern we'd seen repeated a dozen times.

That abstraction saved us time. When we needed to add a new type of validation, we just defined the rules—we didn't have to rebuild the validation logic. When we needed to change how validation errors were displayed, we changed it in one place instead of hunting through the codebase. And when someone new joined the team and needed to understand how validation worked, they could look at the Validator class and immediately get it.

Compare that to an abstraction I built on a different project where we had two different forms that both collected user information. I thought, "These are similar, I should abstract them!" So I built a generic FormBuilder that could handle different field types and validation rules and submission handlers. It was configurable and flexible and seemed really smart at the time.

The problem was that those two forms were only superficially similar. One was for user registration, the other was for updating profile information. They had different fields, different validation requirements, different submission flows, different error handling. Every time we needed to modify one form, we had to add configuration options to the FormBuilder to support the change without breaking the other form. Eventually, the FormBuilder was so complex that it was easier to just build forms from scratch than to figure out how to configure it correctly.

The difference between these two cases? In the first, I had clear evidence that I was dealing with the same concept. Multiple examples of the same pattern, all following the same structure. In the second, I was abstracting based on surface similarity without understanding whether the underlying concepts were actually the same.

Another time abstraction is justified is when you need to hide complexity that's inherent to the problem, not complexity you created. If you're working with a third-party API that has a complicated authentication flow or a weird data format, wrapping it in a simple interface makes sense. You're not adding complexity—you're isolating the existing complexity so it doesn't spread throughout your codebase.

I worked on a project that integrated with a legacy SOAP API that required XML schemas and WSDL files and all kinds of 2005-era enterprise nonsense. We wrapped it in a simple adapter that exposed clean JavaScript objects and async functions. When you used it, you didn't have to think about XML or SOAP or any of that—you just called functions and got data back. That wrapper added a layer of indirection, but it was worth it because it prevented the complexity of that legacy API from infecting our entire codebase.

The key difference is that we were hiding necessary complexity, not creating unnecessary complexity. The SOAP stuff had to exist because that's how the API worked. The wrapper just made sure our team didn't have to think about it except in the one place where we actually talked to the API.

Abstractions are also justified when you need to support multiple implementations of genuinely different things. Not hypothetical future implementations—actual, real implementations that exist right now. If you're building a payment system and you need to support both Stripe and PayPal, then yes, you probably want a payment abstraction. If you're building a storage system and you need to support both S3 and local filesystem, then yes, you want a storage abstraction.

But notice the key word there: need. Not "might someday need" or "could theoretically need" or "would be cool to support." You need to support it right now, you have actual users who require it, and building it is not negotiable. In those cases, abstraction is your friend. But even then, I'd argue for building the first implementation directly, then building the second implementation directly, and only then extracting the abstraction once you can see what they actually have in common.

The pattern I've settled on is what I call the Rule of Three. When I see something once, I write it inline. When I see it twice, I watch carefully to see if it's actually the same thing or just a coincidence. When I see it three times, and I'm confident it's the same concept, then I extract an abstraction. This forces me to wait until I have enough information to build the right abstraction, and it prevents me from abstracting things that just happen to look similar.

One more sign that abstraction is justified: when the cost of not abstracting is higher than the cost of getting the abstraction wrong. If duplicating code means duplicating complex business logic that's likely to change, and changes need to happen consistently everywhere, then abstracting might be worth it even if you're not 100% sure you have the right abstraction. But this is rare. Most of the time, duplicating code is safer than abstracting prematurely.

How Great Engineers Actually Think About Trade-Offs

The thing that separates great engineers from mediocre ones isn't technical knowledge or years of experience or even raw intelligence. It's the ability to think clearly about trade-offs. Every technical decision you make has costs and benefits, and the hard part isn't knowing what the best practice is—it's knowing when to follow best practices and when to deliberately violate them because the costs outweigh the benefits.

I see junior engineers approach problems like there's a right answer and a wrong answer. Should I use a microservices architecture? Should I write unit tests? Should I use TypeScript or JavaScript? And they're looking for someone to tell them the correct answer so they can do the correct thing. But that's not how it works. The answer is always "it depends," and what it depends on is the specific context you're working in.

Great engineers ask different questions. Instead of "Should I build this as a service?" they ask "What problems would building this as a service solve? What problems would it create? Are the problems it solves more important than the problems it creates, given where we are right now?" They think in terms of costs and benefits, not rules and best practices.

Let me give you a concrete example. A few years ago, I was working on a feature that needed to send email notifications when certain events happened in the system. The "right" way to build this would have been to use a message queue. Publish events to the queue, have a worker process consume them and send emails, get guaranteed delivery and retry logic and all that good stuff.

But we didn't have a message queue set up yet. Setting one up would have meant choosing a technology, deploying it, learning how to operate it, and building all the infrastructure around it. That would have taken at least a week, probably two. Meanwhile, the feature needed to ship by the end of the sprint, and delaying it meant delaying revenue.

So instead, I just sent the emails directly in the request handler. I wrapped the email sending in a try-catch and logged failures so we could retry them manually if needed. It wasn't robust, it wasn't scalable, it wasn't what you'd read about in a blog post about event-driven architectures. But it worked, it shipped on time, and we could monitor whether emails were failing to see if we needed to invest in something more robust.

Six months later, we still hadn't had a problem with email delivery. We'd sent tens of thousands of emails, and maybe a handful had failed due to temporary API errors, which we'd caught in the logs and retried manually. Eventually we did implement a proper queue system, but by that point we had better reasons for it—we needed guaranteed delivery for payment notifications, not just nice-to-have marketing emails.

That's trade-off thinking. The "right" architecture would have delayed shipping and used engineering time we didn't have. The "wrong" architecture shipped on time and was good enough for the actual problem we were solving. A junior engineer might feel guilty about the technical debt. A great engineer recognizes that technical debt is a tool—you can deliberately take it on when the benefits outweigh the costs, and pay it down later when it starts causing problems.

Trade-off thinking also means being honest about what you don't know. When you're making architectural decisions, you're making predictions about the future. You're predicting how the system will be used, how it will need to scale, what features will be added. And predictions are often wrong, especially early in a product's life when you're still figuring out what you're building.

The more uncertain you are about the future, the more you should optimize for flexibility over perfection. Don't lock yourself into decisions that are hard to change. Don't build abstractions that assume you know how things will evolve. Build things that are easy to modify when you learn new information.

I've worked with engineers who were so focused on building the "right" architecture that they couldn't ship anything because they were paralyzed by uncertainty about future requirements. I've also worked with engineers who were so focused on shipping that they never thought about tomorrow and created such a mess that every new feature took longer and longer to build. Great engineers find the balance. They ship quickly, but they ship things that can be modified when needed.

Another aspect of trade-off thinking is understanding that different parts of your system have different requirements. Your authentication code needs to be rock-solid and well-tested because if it breaks, it's a security issue. Your admin dashboard that three people use internally can be hacky and rough around the edges because the cost of it breaking is minimal. Treating all code as equally important is a rookie mistake.

I worked on a system once where we had the same code review standards for everything. Core business logic that handled money had to go through the same rigorous review as internal tools that generated reports. We had tests for everything, documentation for everything, performance optimization for everything. It was exhausting, and it slowed us down enormously.

Eventually we wised up and started treating different parts of the system differently. Critical path code got heavy review and comprehensive testing. Internal tools got a quick glance and maybe some smoke tests. Prototype code got no review at all—we just shipped it and fixed issues as they came up. This let us move way faster without actually increasing our bug rate, because we were putting our effort where it mattered most.

Trade-off thinking also means being willing to change your mind. The right decision today might be the wrong decision in six months when the context has changed. And that's okay. Great engineers aren't attached to their architectural decisions—they're attached to solving problems. If a decision stops working, they change it.

I built a service-oriented architecture for a project once, and it made sense at the time—we had multiple teams working on different parts of the product, and services gave us clear ownership boundaries. But then the team got smaller, the product matured, and suddenly the overhead of maintaining multiple services wasn't worth it anymore. So we merged some of them back into a monolith. Some people might see that as admitting failure, but I see it as responding appropriately to changing circumstances.

What This Actually Looks Like for Different Team Sizes

The advice I've been giving so far is general, but the reality is that what makes sense for a solo developer is different from what makes sense for a ten-person team, which is different from what makes sense for a hundred-person company. Let me break down what I've learned about each.

Solo developer or very small team (1-3 people): This is where you have the most freedom to move fast and the most need to avoid overengineering. You don't have time to build infrastructure because you're doing everything—writing code, talking to users, fixing bugs, deploying, marketing, everything. The only thing that matters is shipping features that solve problems for users.

Keep everything in a monolith. Use boring technology that you already know. Don't build abstractions until you've written the same code at least three times. Don't write tests unless something keeps breaking or you're making changes that scare you. Don't think about scale until you have users. Don't build admin panels until you need them—use database queries instead. Don't build deployment automation until you're deploying often enough that it's painful. Do everything the simplest way possible.

I've shipped entire products as single files, with all the HTML and CSS and JavaScript and backend logic jumbled together, because I was the only one working on it and I could move way faster without the overhead of proper separation. Was it ugly? Absolutely. Did it work? Yes. Did it make money? Also yes. And once it was making money and I had proof that the idea worked, then I could take time to refactor it into something maintainable.

The trap solo developers fall into is thinking they need to build like a big company. They read about how Google does things and try to copy it, even though Google has thousands of engineers and they have one. Don't do this. Build the scrappiest thing that could possibly work, ship it, and iterate based on what you learn.

Small team (4-10 people): This is where you start needing some structure, but not too much. You can't keep everything in your head anymore because there are other people who need to understand the code. You need some conventions so you're not constantly having the same arguments about code style. You need some tests so you're not constantly breaking each other's work.

But you're still small enough that you don't need heavy process. You don't need microservices—a well-organized monolith with clear modules is way easier to work with. You don't need sophisticated deployment pipelines—a script that runs your tests and pushes to production is fine. You don't need formal architecture reviews—just have someone else look at your code before merging.

This is the stage where you should start thinking about code organization and maintainability, but you should still bias toward simplicity. Extract functions when you see duplication. Write tests for stuff that keeps breaking. Document the stuff that's not obvious. But don't build infrastructure for problems you don't have yet.

The trap small teams fall into is premature scaling. They're growing, they're hiring, and someone reads about how Netflix does things and decides they need to reorganize everything into microservices before they scale. But they're not Netflix, they're ten people, and splitting into microservices will slow them down by a factor of three. Wait until the monolith is actually causing problems—until deployment coordination is a mess, until different parts of the system need different scaling characteristics, until teams are stepping on each other's toes. Then split thoughtfully, not preemptively.

Growing company (10-50 people): This is where things get interesting. You're big enough that coordination becomes a real problem. You can't have everyone in the same codebase anymore because merge conflicts are constant and deploy coordination is a nightmare. You need to think about team boundaries and ownership.

This is where architectural decisions start mattering more. You might actually need to split your monolith into services, but do it based on team boundaries, not technical boundaries. If you have a team working on billing and a team working on the core product, give each team their own service. If you have a component that needs to scale differently from everything else, split it out.

But even at this size, simpler is still better. Don't build a service mesh unless you're actually having networking problems. Don't build a complex CI/CD pipeline unless simple deployment is causing real pain. Don't standardize everything across teams—let teams make local decisions about their own stuff as long as they integrate cleanly with everyone else.

The trap growing companies fall into is adopting big-company practices too early. They start requiring design docs for every change, implementing formal RFC processes, standardizing on specific technologies across all teams. This creates overhead that slows everything down without actually solving problems they have yet. The goal at this stage should be enabling teams to move independently, not creating consistency for its own sake.

Larger organization (50+ people): I have less experience here, but from what I've seen, this is where you actually need the practices that smaller teams think they need. You need architecture reviews because bad decisions affect too many teams. You need standardization because supporting fifty different technology choices is impossible. You need formal processes because informal communication doesn't scale to this size.

But even here, the principle still applies: build for the problems you actually have. If your services are stable and rarely change, you don't need sophisticated deployment automation. If your traffic is predictable, you don't need complex auto-scaling. If your teams aren't stepping on each other, you don't need heavy coordination overhead.

Refactoring After Shipping Instead of Before

This might be the most important shift in mindset: stop trying to get it perfect before you ship, and get comfortable with refactoring after you ship. I wasted years of my career trying to build perfect systems upfront, and I wish someone had told me earlier that refactoring is a normal, healthy part of the development process.

Here's the thing about refactoring after shipping: you have way more information. You know what users actually do with your feature. You know which parts of the code change frequently and which parts are stable. You know where the performance bottlenecks actually are. You know which abstractions would actually be useful instead of which ones you imagined might be useful.

I shipped a feature once that I thought was going to be used primarily by power users doing complex workflows. So I built a flexible configuration system and lots of options and keyboard shortcuts and all this stuff. Turns out, 90% of users just wanted to click one button to do the most common thing. All my flexibility was just confusing them. If I'd waited to refactor until after shipping, I would have seen this immediately and built something much simpler.

The fear, of course, is that if you ship something simple and messy, you'll never have time to come back and clean it up. And sometimes that's true. But you know what? If you never need to come back and modify that code, then the messiness doesn't matter. It works, it's solving a problem, and you're free to work on other things. The only code that needs to be clean is code that you're going to change.

I have code in production right now that I wrote three years ago and haven't touched since. It's ugly. It has hard-coded values. It has nested conditionals six levels deep. But you know what? It works perfectly, it hasn't had a bug in three years, and nobody needs to understand it or modify it. That ugly code has been more successful than a lot of beautiful code I've written that got rewritten three times because I was trying to abstract things I didn't understand yet.

The key to making refactoring-after-shipping work is having good test coverage for the stuff you're going to change. Notice I said "stuff you're going to change," not "everything." You don't need 100% test coverage of your entire codebase. You need good coverage of the parts that change frequently or are critical to your business. For everything else, manual testing is fine.

When you do refactor, do it incrementally. Don't rewrite the whole system—refactor one piece at a time, deploy it, make sure it works, then move on to the next piece. Big-bang rewrites almost always fail because you're changing too much at once and you lose the ability to isolate problems. Small, incremental refactoring is safer and you can stop at any point if priorities change.

I refactored a gnarly piece of authentication code once by writing a new version alongside the old version, gradually moving users over with a feature flag, monitoring for errors, and only removing the old version once I was confident the new version worked. It took three weeks instead of the three days a direct rewrite would have taken, but I never had a production outage and I could have rolled back at any point.

The other advantage of refactoring after shipping is that you can measure whether the refactoring actually helped. If you refactored to improve performance, you can measure the performance improvement. If you refactored to make the code easier to change, you can measure whether new features actually get built faster. If the refactoring didn't help, you learned something about what doesn't work. If you refactor before shipping, you have no baseline to compare against.

This doesn't mean you should ship complete garbage and fix it later. You should still write code that works, that you understand, that you can debug. But there's a huge difference between "code that works and is somewhat messy" and "code that's architected for every possible future requirement." Ship the former, refactor to the latter only when you know which future requirements actually matter.

The Courage to Ship Something Simple

I want to close with maybe the hardest part of all this: finding the courage to ship something simple when your instinct is telling you to make it perfect first. This is psychological, not technical. It's about overcoming the fear that if you ship something imperfect, people will think you're a bad engineer.

Here's what I've learned: nobody cares how elegant your code is except other developers, and most of them are too busy with their own work to judge you. Users care whether the thing works and solves their problem. Your boss cares whether you're shipping features that help the business. Your future self cares whether the code is easy to modify when requirements change.

None of these stakeholders benefit from you spending an extra week building abstractions for requirements that don't exist yet. None of them benefit from you architecting a system that could theoretically scale to millions of users when you have hundreds. None of them benefit from you implementing every design pattern you learned in that book.

I used to think that shipping simple code was taking a shortcut, cutting corners, being unprofessional. Now I understand that shipping simple code is showing good judgment. It's understanding the constraints you're working under and making smart trade-offs. It's having the discipline to solve the problem in front of you instead of the imaginary problems you might face someday.

The best code I've ever written is code that felt almost too simple when I wrote it. Code where I thought, "This can't be right, it's too straightforward." Code where I had to resist the urge to make it more sophisticated because surely the problem was more complex than this. And then I shipped it, and it worked, and I moved on to the next thing.

That's the mindset shift that changed my career: stop optimizing for how impressive your code looks and start optimizing for how quickly you can learn whether you're building the right thing. Because in the end, the only code that matters is code that solves real problems for real people. Everything else is just self-indulgence.

So ship your simple solutions. Ship your monoliths. Ship your hardcoded values and your inline code and your missing abstractions. Ship them, learn from them, and refactor when you actually need to. Because the perfect system you're imagining doesn't exist, the future you're building for won't arrive the way you expect, and the only way to actually figure out what to build is to build something and see what happens.

And when someone tells you that your code isn't enterprise-ready or doesn't follow best practices or won't scale or needs more abstraction—smile, nod, and remember that their code probably isn't shipped yet either.

Follow Me On:

Top comments (2)

Collapse
 
hadil profile image
Hadil Ben Abdallah

I loved how you framed momentum as the real casualty. I’ve seen more products die from never shipping than from shipping something imperfect. Simple code that ships teaches you more than a perfect architecture built in isolation.
Great post! 👏🏻

Collapse
 
thebitforge profile image
TheBitForge

Thanks Hadil 😊