You sit down to build a simple app. A to-do list, maybe. A personal dashboard. A small SaaS.
Three weeks later, you have:
- A Kubernetes cluster
- A microservice architecture with 6 services
- An event-driven message queue
- A custom authentication system with OAuth, SAML, and magic links
- A CI/CD pipeline with 14 stages
- Zero users
Congratulations. You've built a spaceship to go to the grocery store.
I've done this. You've done this. Let's talk about why, and how to stop.
Why We Overengineer
Before the practical stuff, let's be honest about the psychology:
1. It feels productive. Setting up infrastructure feels like building the product. You're writing code, solving problems, seeing progress. But you're solving imaginary problems for imaginary users at imaginary scale.
2. It's more fun. Configuring Kubernetes is more interesting than building a settings page. Writing a custom ORM is more exciting than handling form validation. We gravitate toward interesting problems, not important ones.
3. Resume-Driven Development. Deep down, we sometimes pick technologies because we want them on our resume, not because the project needs them. "Built a distributed event-sourcing system" looks better in an interview than "used SQLite and a cron job."
4. Fear of rebuilding. "But what if we get 100,000 users and need to scale?" You won't. And if you do, that's the best problem you'll ever have. You can rebuild then.
5. Tutorial culture. Every YouTube tutorial and Medium article shows you the "production-ready" setup. Docker, Redis, Nginx, load balancers, monitoring dashboards. For a project with one user: you.
9 Signs You're Overengineering
1. Your infrastructure is more complex than your business logic
If your Docker Compose file has more services than your app has features, something is wrong.
# This is a red flag for a side project
services:
api-gateway:
auth-service:
user-service:
notification-service:
analytics-service:
redis:
rabbitmq:
elasticsearch:
postgres:
pgadmin:
prometheus:
grafana:
12 services. Zero paying customers. Your docker-compose.yml is doing more work than your app.
2. You're solving scale problems at zero scale
"We need Redis for caching because database queries will be slow at scale."
How many users do you have? 3? Your database can handle 3 users. PostgreSQL can handle thousands of queries per second on a $5 VPS. You'll run out of motivation before you run out of database capacity.
Rule of thumb: If you can count your users on your fingers, SQLite is probably enough.
3. You're abstracting before you have two use cases
// You have ONE button in your app
// But you built this:
interface ButtonProps<T extends ButtonVariant> {
variant: T;
size: ButtonSize;
colorScheme: ColorScheme;
isLoading?: boolean;
loadingText?: string;
spinnerPlacement?: 'start' | 'end';
iconSpacing?: string;
leftIcon?: React.ReactElement;
rightIcon?: React.ReactElement;
onClick?: (event: ButtonClickEvent<T>) => void | Promise<void>;
onHoverStart?: () => void;
onHoverEnd?: () => void;
renderAs?: React.ElementType;
// ... 15 more props
}
You don't need a design system. You need a <button>.
The rule is simple: don't abstract until you have at least three concrete use cases. Two similar things are a coincidence. Three are a pattern.
4. You're writing "reusable" utilities for one-time operations
# You used this function exactly once
def transform_data_pipeline(
data: List[Dict],
transformers: List[Callable],
validators: List[Validator],
error_handler: ErrorHandler,
retry_config: RetryConfig,
batch_size: int = 100
) -> TransformResult:
...
If a function is called in one place, it doesn't need to be configurable. Inline it. Write the specific thing. Three lines of "duplicated" code is better than a premature abstraction.
5. Your setup instructions are longer than your README
If a new developer needs 45 minutes and a PhD in DevOps to run your project locally, you've optimized for the wrong thing.
# This is all your side project should need:
git clone repo
cp .env.example .env
docker-compose up
If it takes more than 3 commands, question every additional step.
6. You're handling errors that can't happen
def get_user_name(user: User) -> str:
if user is None:
raise ValueError("User cannot be None")
if not isinstance(user, User):
raise TypeError("Expected User instance")
if user.name is None:
return "Unknown"
if not isinstance(user.name, str):
return str(user.name)
if len(user.name) == 0:
return "Unknown"
if len(user.name) > 10000:
return user.name[:10000]
return user.name
Your type system already guarantees most of this. Your ORM won't return a non-User from a User query. Trust your tools. Validate at boundaries (user input, external APIs), not inside your own code.
7. You picked a technology stack before understanding the problem
"I'm going to build my next project with Go, gRPC, and Kafka."
What's the project?
"I haven't decided yet."
The stack should serve the problem. If you choose the stack first, you'll bend the problem to fit the stack — and end up overcomplicating simple things because your tools expect complexity.
8. You have more config files than source files
Count your root-level config files:
.eslintrc.js
.eslintignore
.prettierrc
.prettierignore
.stylelintrc
.editorconfig
.babelrc
tsconfig.json
tsconfig.build.json
tsconfig.node.json
jest.config.ts
vitest.config.ts
postcss.config.js
tailwind.config.js
vite.config.ts
docker-compose.yml
docker-compose.dev.yml
docker-compose.prod.yml
docker-compose.test.yml
Dockerfile
Dockerfile.dev
Dockerfile.prod
.github/workflows/ci.yml
.github/workflows/cd.yml
.github/workflows/codeql.yml
.husky/pre-commit
.husky/pre-push
commitlint.config.js
lint-staged.config.js
28 config files. You haven't written the app yet. Most of these exist because a "best practices" article told you to add them.
9. You can't explain your architecture in one sentence
If your architecture requires a 15-minute presentation with a diagram, it's too complex for the problem you're solving.
Good: "A Python API with a PostgreSQL database and a React frontend."
Bad: "An event-sourced CQRS architecture with command handlers publishing to a message bus consumed by projection workers that update read models in Elasticsearch, fronted by a BFF gateway layer that aggregates responses for a micro-frontend shell application."
Both could be solving the same problem. One ships. The other gets presented at a meetup and never launches.
The Boring Stack Manifesto
Here's what actually works for 90% of web projects:
| Need | Boring Solution |
|---|---|
| API | One monolithic framework (Django, FastAPI, Rails, Express) |
| Database | PostgreSQL. That's it. |
| Cache | Your database is fast enough. If it's not, add an index. |
| Background jobs | Cron. Literally cron. |
| Auth | JWT + bcrypt. Or use a library. |
| Deployment | A single VPS + Nginx |
| CI/CD | GitHub Actions with 2-3 steps |
| Monitoring | Logs. grep your logs. |
| State management | React Context or useState. Not Redux. |
| Styling | Tailwind or plain CSS |
No microservices. No Kubernetes. No Redis. No message queues. No service mesh.
You can serve thousands of concurrent users with a $20/month VPS running a monolith. Basecamp does billions in revenue on a monolith. Stack Overflow serves millions of developers with a handful of servers.
When Complexity IS Justified
I'm not saying all complexity is bad. It's justified when:
You have evidence. Your database is actually slow (you measured it). Your monolith actually can't handle the load (you profiled it). Your deployment actually needs zero-downtime (users complained).
You have the team. Microservices require a team per service. If you're a solo developer or a team of 3, a monolith is not a compromise — it's the correct architecture.
The domain is genuinely complex. Payment processing, real-time collaboration, distributed systems — some problems require sophisticated solutions. But most CRUD apps are not these problems.
You'll maintain it. The fanciest architecture in the world is technical debt if nobody understands it in 6 months.
The Simplicity Checklist
Before adding any technology, library, or architectural pattern, ask:
- Can I ship without this? If yes, don't add it.
- Am I solving a current problem or a future one? If future, don't add it.
- Will a junior developer understand this? If not, simplify it.
- Am I adding this because I want to learn it? That's fine — just be honest about it. Call it a learning project, not a product.
- Can I explain why I need this in one sentence? If not, you probably don't need it.
The Best Code I Ever Wrote
The best code I ever wrote wasn't clever. It wasn't scalable. It wasn't "production-ready" by any blog post standard.
It was simple. It worked. It shipped.
A monolithic Python API. A PostgreSQL database. A React frontend. Deployed on a single VPS with Nginx. Background jobs running on cron. Plain text emails sent with smtplib.
No one has ever complained about the architecture. Users complain about missing features, confusing UI, and slow pages. They never complain about your infrastructure choices.
Ship the bicycle. You can build the spaceship later — when you actually need to go to space.
What's the most overengineered thing you've ever built? I want to hear your confessions in the comments. No judgment — we've all been there.
Top comments (0)