Back to home

How We Should Write Backend

June 30, 2026Himangshu

Backend development is full of trade-offs. There’s no perfect architecture, but after building several APIs - some that aged beautifully and some that I’d rather not talk about - these are the patterns that have genuinely made my life easier.

Modular Layered Architecture

The single best decision you can make early on is how you organize your code. The default instinct is to group by technical role: controllers/, services/, repositories/, middleware/. This works when you have five endpoints. When you have fifty, it becomes a scavenger hunt - you’re jumping between six directories just to understand one feature.

Instead, group by business domain:

src/
  modules/
    users/
      users.controller.ts
      users.service.ts
      users.repository.ts
      users.routes.ts
      users.validator.ts
      users.types.ts
    auth/
      auth.controller.ts
      auth.service.ts
      ...
  shared/
    middleware/
    errors/
    logging/
    database/

Each module owns its entire vertical slice. Want to add a new feature? Create a new module. Want to remove one? Delete a folder. Need to understand how bookings work? Open one directory, not eight. It sounds trivial, but this structure alone has saved me hours of context switching on larger projects.

The shared folder is for cross-cutting concerns - middleware, error handling, logging, database abstractions. Things that every module needs but no single module owns.

Consistent Error Handling

Nothing screams “amateur” louder than an API that returns different error shapes for different endpoints. One route gives you { error: "not found" }, another gives you { message: "Validation failed", errors: [...] }, and a third just crashes with a 500 and an HTML stack trace.

Pick a consistent error format and enforce it. I like defining a base error class that extends the native Error, with a subclass for every HTTP status I commonly use:

Give each error a machine-readable code and a human-readable message. Then write a global error handler middleware that catches everything, logs it properly, and spits out a consistent JSON response. Wrap your async route handlers so you never have to write try/catch in every controller - let errors bubble up naturally.

Validation as a First-Class Concern

Here’s a mistake I made more than once: validating data loosely at the controller, then doing “real” validation deeper in the service layer. The problem is you end up with validation scattered everywhere, sometimes running twice, sometimes not at all.

Validate everything at the boundary. The moment a request enters your system, check the body, query params, and URL params against a schema. Reject bad data immediately. Don’t let it travel three layers deep before someone notices a field is missing.

Use a schema library (Zod, Joi, Valibot, whatever suits your stack). Define reusable validators for common things - phone numbers, emails, coordinates, pagination params. Compose them. Keep your validators close to your routes so it’s obvious what each endpoint expects.

Best practice I’ve come across: your validation schemas should double as your API documentation. Tools like zod-to-openapi let you generate OpenAPI specs directly from your schemas. No more maintaining a separate Swagger file that’s always six months out of date.

The Repository Pattern

ORMs are convenient until they aren’t. Direct SQL gives you full control but ties you to a specific database. I’ve found that abstracting database access behind a repository interface gives you the best of both worlds.

The idea is simple: each entity or aggregate gets a repository class that handles all data access. The rest of your code talks to the repository, never to the database directly. This gives you:

Keep repositories focused. They fetch and store data - no business logic. That belongs in the service layer. If a query needs to join across module boundaries, expose it through a shared repository or a dedicated query object.

Event-Driven Cross-Module Communication

This is one of those patterns you don’t appreciate until you need it. Early on, it’s tempting to import a service from another module directly. “I just need to send a notification when the user signs up, what’s the harm?” The harm is you’ve now tied the auth module to the notifications module. Next month someone adds analytics, and now auth imports that too. Before you know it, you have circular dependencies and a module that breaks if any of its five dependents change.

Instead, use an event bus. When a user registers, emit user.created. The notifications module listens and sends a welcome email. The analytics module listens and records an event. Auth doesn’t know or care about any of this.

Start simple - an in-process event emitter works fine for most apps. But design it so you can swap it for Kafka or RabbitMQ later if you outgrow it. The interface shouldn’t change.

This pattern also helps with testing. You can test each module in isolation without pulling in a dozen dependencies.

Structured Logging

Console.log works for local development. In production, you need structured logs. JSON format with proper fields - timestamp, level, request ID, message, context.

The request ID is the unsung hero here. Generate one for every incoming request, attach it to the logger, and pass it along. When something goes wrong, you can grep for that ID and see exactly what happened - every database query, every external API call, every error - all in order.

Don’t log sensitive data. Do log enough context to debug without reproducing the issue. Log request start and end with duration (so you can spot slow endpoints). Log database query times. Log external API calls and their responses (at debug level). Log errors with full stack traces.

A good logger is invisible until you need it, and then it’s the most valuable tool you have.

Dependency Injection Done Simply

DI frameworks exist. They solve real problems. But for most backend apps, you don’t need one. Just pass dependencies through constructors. A controller takes a service. A service takes repositories and an event bus. A repository takes a database client.

class UsersController {
  constructor(private usersService: UsersService) {}
}

class UsersService {
  constructor(
    private usersRepo: UsersRepository,
    private eventBus: EventBus
  ) {}
}

That’s it. Testing is trivial - pass mocks or in-memory implementations. No decorators, no DI containers, no magic. If you later find that wiring things manually hurts, reach for a library. Don’t start with one.

API Documentation from Code

Writing documentation is a chore. Maintaining it is worse. The best API docs I’ve ever worked with were the ones that generated themselves.

If you’re validating requests with schemas and defining your routes with types, you’re already 80% of the way there. Use a tool that reads your schemas and generates an OpenAPI spec. Serve it alongside your app. Now every change to your validation or routing is automatically reflected in the docs.

It’s not just about saving effort. Generated docs are trustworthy. Hand-written docs look good on day one and lie to you by day thirty.

Background Jobs for Async Work

Not everything needs to happen during the request. Sending emails, processing images, hitting external APIs, running reports - these belong in a queue. The request should return quickly, and the heavy lifting happens in the background.

Redis-backed job libraries are mature and easy to set up. Define a worker for each job type. Handle errors gracefully. Implement retries with exponential backoff. Set concurrency limits so you don’t overwhelm your database or external services.

This also makes your system more resilient. If Redis goes down, requests still succeed - the jobs just queue up and process when it’s back. If a job fails, it retries. If it keeps failing, it goes to a dead-letter queue for manual inspection.

Graceful Shutdown

This is the kind of thing nobody thinks about until it bites them. When your server receives a SIGTERM (which happens every time you deploy in a containerized environment), what happens to the in-flight requests? To the database connections? To the jobs in progress?

Handle it. Listen for SIGTERM and SIGINT. Stop accepting new requests. Wait for active requests to finish (with a timeout). Close database connections gracefully. Let BullMQ or your job library know to stop picking up new jobs. Then exit.

Without this, you get corrupted state, incomplete jobs, and angry users who lost data during a deploy. It takes an afternoon to implement and saves you weeks of debugging.

Testing Without the Pain

Don’t fight your architecture when testing. If you’ve separated concerns properly - controllers from services, services from repositories - testing becomes straightforward.

Unit test your services. They contain the business logic and they’re where most bugs live. Mock the repositories. Test edge cases, error conditions, happy paths.

Integration test your repositories against a real database (in a container or testcontainers). This catches the subtle SQL bugs that unit tests miss.

Write a handful of end-to-end tests for critical user journeys - signup, create a booking, make a payment. These are slow and flaky, so keep the number small. They’re insurance, not your primary testing strategy.

Test files should live next to the code they test. __tests__ directories inside each module. If I’m working on the auth module, I want to see the tests right there, not in a mirror directory tree somewhere else.

Configuration Management

Environment variables are the standard, but they’re painful to manage without a schema. Use a library that validates your env vars at startup and fails fast if something is missing. Nothing worse than deploying to production and discovering six hours later that DATABASE_URL wasn’t set because you renamed it in the code but forgot to update the deployment config.

Define sensible defaults for development. Provide a .env.example that’s actually kept up to date. Group related settings into typed config objects.

Wrap Up

None of these patterns are revolutionary. They’re boring, well-established practices that work. But knowing them and actually applying them consistently are two different things.

The hard part isn’t setting up the perfect architecture on day one. It’s maintaining discipline as the codebase grows, as deadlines loom, and as the temptation to “just this once” skip the validation or log with console.log creeps in.

The best architecture is boring. It gets out of your way, makes the right thing the easy thing, and lets you focus on solving actual problems instead of fighting your own code.

got something
in mind?

Available for work. Whether it's a new system architecture or just saying hi, I'm always open to a chat.

806 commits in the last 95 days

hi@himon.xyz

© 2026 Himangshu. All rights reserved.