This article is published in English.
Backend Anti-Patterns: 7 Costly Mistakes and Their Practical Fixes
Learn how to spot and fix seven common backend engineering mistakes, from bloated controllers to unhandled errors, before they cause production issues.
When you're getting started with backend development, it's easy to assume the real challenge is mastering more tools.
Node.js.
Express.
PostgreSQL.
Redis.
Docker.
Message queues.
System design.
But after shipping a number of applications, a different truth becomes clear.
Knowing more tools doesn't automatically turn you into a stronger backend engineer.
Most of the real growth comes from making mistakes, figuring out why they happened, and making sure they don't happen again.
Here are seven backend mistakes worth learning from, along with the approach that works better.
1. Putting Everything Inside the Controller
This tends to be one of the first mistakes people run into.
An endpoint might start out looking like this:
app.post("/orders", async (req, res) => {
const { userId, productId, quantity } = req.body;
const user = await db.users.findUnique({
where: { id: userId }
}); if (!user) {
return res.status(404).json({
message: "User not found"
});
} const product = await db.products.findUnique({
where: { id: productId }
}); if (!product) {
return res.status(404).json({
message: "Product not found"
});
} if (product.stock < quantity) {
return res.status(400).json({
message: "Not enough stock"
});
} const order = await db.orders.create({
data: {
userId,
productId,
quantity
}
}); await sendEmail(user.email); return res.status(201).json(order);
});
It works.
But look at everything this one function now owns:
- Validation
- Database queries
- Business rules
- Stock checking
- Order creation
- HTTP responses
Once a project keeps growing, functions carrying this many responsibilities balloon into unmanageable blocks of logic.
The fix is to split up responsibilities.
Request
↓
Controller
↓
Service
↓
Repository
↓
Databas
The controller's job is HTTP.
The service layer owns the business logic.
The repository layer owns data access.
This doesn't mean a tiny app needs six layers of abstraction.
It means each part of the system should have a clearly defined job.
2. Trusting the Frontend
This mistake can introduce bugs and, worse, security holes.
Say the frontend sends this payload:
{
"price": 10,
"quantity": 2
}
It's tempting to just use the price the client sent to calculate the order total.
Don't.
Nothing stops a malicious client from sending:
{
"price": 1,
"quantity": 100
}
The frontend is under the user's control, not yours.
Your backend needs to validate and enforce the rules that actually matter.
For instance:
const product = await productRepository.findById(
productId
);
const total = product.price * quantity;
The backend, not the client, should be the source of truth for pricing.
This same caution needs to extend to several other areas a client might try to influence:
- User roles
- Permissions
- Discounts
- Inventory
- Payment amounts
- Account status
- Resource ownership
Think of the frontend as a tool for shaping how users experience your product.
It is not a security boundary.
3. Bad Error Handling
Early on, error handling often looked like this:
try {
// something
} catch (error) {
console.log(error);
return res.status(500).json({
message: "Something went wrong"
});
}
There's nothing inherently wrong with having a catch-all fallback.
The issue is relying on it for every situation.
A missing user record isn't necessarily a 500-level error.
A malformed request isn't necessarily a 500-level error.
A duplicate email isn't necessarily a 500-level error.
Your backend needs to tell different failure types apart.
For example:
400 → Invalid request
401 → Authentication required
403 → Not allowed
404 → Resource not found
409 → Conflict
422 → Validation failure
500 → Unexpected server error
The specific status codes you pick depend on your API's conventions, but staying consistent matters more than the exact scheme.
Structured error responses also help.
For example:
{
"success": false,
"message": "User already exists",
"code": "USER_ALREADY_EXISTS"
}
With this format, the frontend doesn't have to guess what went wrong.
4. Hardcoding Configuration
This mistake seems harmless right up until you try to deploy.
Something like:
const databaseUrl =
"postgresql://user:password@localhost:5432/app";
Or:
const jwtSecret = "my-secret";
Avoid this pattern entirely.
Each environment you run in needs its own settings.
You might have:
Development
↓
localhost
Staging
↓
staging databaseProduction
↓
production database
Instead, rely on environment-based configuration:
DATABASE_URL=
REDIS_URL=
JWT_SECRET=
PAYMENT_API_KEY=
EMAIL_API_KEY=
And never commit secrets into version control.
A .env file is fine for local development, but production environments call for proper secret and configuration management tooling.
The core principle here is:
Your code shouldn't be tightly bound to environment-specific configuration values.
5. Scaling Before You Actually Need To
This is a trap plenty of developers fall into at some point, and it's easy to justify at the time.
A team kicks off a new project and immediately jumps to:
"What happens if 10 million people show up tomorrow?"
So they throw in:
Microservices
Kafka
Redis
Kubernetes
Multiple databases
API Gateway
Event-driven architecture
Now, on paper, the system can handle massive scale.
Except there are only five actual users.
That's not solid architecture. That's complexity nobody needed yet.
For most projects, it makes more sense to start simple:
Client
↓
Node.js Application
↓
PostgreSQL
And only bring in new pieces once there's a concrete reason to:
Need caching?
→ Redis
Need background jobs?
→ Queue + WorkerNeed more API capacity?
→ Multiple instances + Load BalancerDatabase becoming a bottleneck?
→ Optimize queries / indexes / architecture
Let the architecture grow alongside real, demonstrated needs.
Don't reach for a distributed system just because some video claims that's what "real" senior engineers do.
6. Blocking the Request on Slow Work
This one can quietly wreck the experience of using an API.
Picture this setup:
app.post("/order", async (req, res) => {
const order = await createOrder(); await sendEmail(); await generateInvoice(); await notifyWarehouse(); await updateAnalytics(); return res.json(order);
});
Here, the user sits there waiting on all five steps to finish in sequence.
If even one external call takes five extra seconds, the entire response is now five seconds slower.
A better approach is to only do the work inside the request that genuinely has to happen right away.
Everything else can be pushed into a queue for background processing.
Client
↓
API
↓
Create Order
↓
Queue Jobs
↓
Response
Followed by:
Queue
↓
Worker
├── Send Email
├── Generate Invoice
├── Notification
└── Analytics
This is exactly the kind of scenario where something like BullMQ paired with Redis earns its keep.
But there's a second, easy-to-miss lesson here:
Background jobs must be idempotent and handle failure gracefully.
If a job accidentally executes twice, the risks include:
- Billing a customer more than once
- Firing off duplicate notifications
- Writing duplicate records into the database
Simply offloading work to a queue doesn't solve this on its own.
The job itself needs to be built with that in mind.
7. Flying Blind in Production
This mistake usually stays invisible right up until something actually breaks.
Imagine a live API suddenly starts throwing errors.
The server looks fine at first glance.
The code looks fine too.
But here's what's missing:
No useful logs
No request IDs
No metrics
No error tracking
No database monitoring
At this point, the only option left is guessing.
Debugging turns into a string of shrugs:
"Maybe Redis went down?" "Maybe the database slowed to a crawl?" "Maybe the payment provider is having issues?"
That's an uncomfortable place to be as an engineer.
At the very least, useful logs are essential.
For instance:
{
"level": "error",
"requestId": "req_123",
"route": "/orders",
"userId": "user_456",
"message": "Payment provider timeout"
}
With output like that, it becomes possible to trace exactly what went wrong and where.
As systems grow, observability typically expands to cover:
- Application logs
- Error tracking
- CPU and memory usage
- Database-level metrics
- API response times
- Queue backlog size
- Failures from external APIs
- Health check endpoints
A system can't stay healthy if there's no visibility into how it behaves.
The Bigger Lesson
Stepping back, nearly all of these issues trace back to the same underlying habit.
The question guiding many decisions tends to be:
"Does this feature work?"
when it should really be:
"Is this feature easy to change, debug, and run in production?"
That shift in framing changes almost everything about how software gets built.
What to Verify Before Calling an API Done
Before marking a backend feature as complete, it helps to run through the following checks.
Code
- Does each layer have a clear, single responsibility?
- Can the business logic be tested in isolation?
- Are the controllers kept reasonably lean?
Security
- Is all incoming input validated?
- Are permission checks enforced server-side?
- Are secrets kept out of reach?
- Is authentication and authorization implemented properly?
Database
- Are the queries efficient?
- Do the right indexes exist?
- Are transactions used where they're needed?
- Is there a hidden N+1 query issue?
Performance
- Are avoidable sequential calls eliminated?
- Is heavy work deferred to a background process?
- Would caching improve things here?
Reliability
- What's the fallback if an external API fails?
- Are retries configured sensibly?
- Are background jobs safe to run more than once?
- What happens if Redis or the database becomes unreachable?
Operations
- Can you tell what happened when something fails?
- Are the logs actually useful?
- Do health checks exist?
- Can API performance be measured?
A flawless system isn't the goal.
But knowing what happens the moment something goes sideways is essential.
The 7 Mistakes at a Glance
| Mistake | Better Approach |
|---|---|
| Everything crammed into controllers | Split responsibilities across layers |
| Trusting data from the frontend | Validate everything server-side |
| One-size-fits-all error handling | Use a consistent error strategy |
| Hardcoded secrets | Proper environment and config management |
| Scaling too early | Scale in response to real bottlenecks |
| Slow work blocking requests | Push it to background jobs |
| No insight into production | Add logging and monitoring |
Final Takeaway
There's a common belief that leveling up as a backend developer just means collecting more tools and technologies.
A more accurate view is that it's really about grasping trade-offs.
Should this operation be synchronous or asynchronous?
Is this data worth caching?
Does this query need an index?
Should this be its own service?
What's the plan if Redis goes down?
What's the plan if the database slows to a crawl?
What's the plan if a job accidentally fires twice?
What's the plan if the external API being relied on fails?
Knowing the answers to these questions matters far more than simply knowing how to install another package.
Because production systems aren't judged by how they behave when everything goes right.
Real engineering shows up the moment things go wrong.
And every mistake understood today is one less production fire to put out later.