Your Express.js App Is a Mess — Here's the Architecture Pattern That Fixes It

You start a new Express project. It's clean. Routes are tidy, everything makes sense. Three months later you can't change a single thing without breaking five others, your controllers are 200-line monsters, and validation logic is copy-pasted across six different route handlers.
Sound familiar? This isn't a discipline problem. It's an architecture problem — and there's a pattern that fixes it. What follows is the layered structure that actually holds up in a real TypeScript MERN backend: Route → Middleware (Zod) → Controller → Service → Helper. Once you've used it, going back feels physically painful.
The Problem With How Most Express Apps Start
The default Express example looks roughly like this:
// Yep, all of this in one route handler
router.post("/blog", async (req, res) => {
const { title, content, authorId } = req.body;
// Inline validation
if (!title || title.length < 3) return res.status(400).json({ error: "Bad title" });
if (!content) return res.status(400).json({ error: "No content" });
// Business logic
const slug = title.toLowerCase().replace(/\s+/g, "-");
const wordCount = content.split(" ").length;
const readTime = Math.ceil(wordCount / 200);
// DB query
const blog = await Blog.create({ title, content, slug, authorId, readTime });
res.status(201).json(blog);
});
This works. For a while. But that route handler now owns validation, business logic, helper math, and the database call all at once. Adding a new field means touching everything. Writing a test means mocking the entire universe. And when a requirement changes, you're hunting through a blob of logic to find the one thing you need to change.
That's four concerns jammed into one place. Let's separate them.
The Four-Layer Architecture (With Zod Middleware)
Every incoming request travels through four distinct layers. Each one has exactly one job and no knowledge of what's above or below it beyond its immediate neighbour.
blog.route.ts — Wires up the router. Attaches Zod middleware, then hands off to the controller. Nothing else lives here.
blog.middleware.ts — Runs Zod validation before the controller even sees the request. If it fails, the error goes back immediately.
blog.controller.ts — Handles HTTP: reads the validated request, calls the service, sends the response. No business logic, no DB calls.
blog.service.ts — Owns the business logic and the database queries. This is where the real work happens.
blog.helper.ts — Pure utility functions the service uses. No DB, no HTTP, no side effects — just transformations and calculations.
Here's what that same blog post creation looks like refactored across these files:
// blog.route.ts
import { Router } from "express";
import { validateBody } from "../middleware/validate";
import { createBlogSchema } from "./blog.middleware";
import { BlogController } from "./blog.controller";
const router = Router();
router.post("/", validateBody(createBlogSchema), BlogController.create);
export default router;
// blog.middleware.ts
import { z } from "zod";
export const createBlogSchema = z.object({
title: z.string().min(3, "Title must be at least 3 characters"),
content: z.string().min(1, "Content is required"),
authorId: z.string().min(1),
});
export type CreateBlogInput = z.infer<typeof createBlogSchema>;
The Zod schema lives with the feature it validates. Your generic validateBody middleware just runs whatever schema you hand it:
// middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { ZodSchema } from "zod";
export const validateBody = (schema: ZodSchema) =>
(req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten().fieldErrors });
}
req.body = result.data; // overwrite with parsed, typed data
next();
};
By the time the controller runs, req.body is already validated and typed. No guard clauses needed:
// blog.controller.ts
import { Request, Response } from "express";
import { BlogService } from "./blog.service";
import { CreateBlogInput } from "./blog.middleware";
export const BlogController = {
async create(req: Request, res: Response) {
const input: CreateBlogInput = req.body; // already safe
const blog = await BlogService.create(input);
res.status(201).json(blog);
},
};
The controller is nine lines and does exactly one thing. The service is where the work actually happens:
// blog.service.ts
import { Blog } from "../models/Blog";
import { CreateBlogInput } from "./blog.middleware";
import { BlogHelper } from "./blog.helper";
export const BlogService = {
async create(input: CreateBlogInput) {
const slug = BlogHelper.toSlug(input.title);
const readTime = BlogHelper.calcReadTime(input.content);
const blog = await Blog.create({
...input,
slug,
readTime,
});
return blog.toObject();
},
};
And the helpers are plain functions — no imports from Express, no Mongoose, nothing stateful:
// blog.helper.ts
export const BlogHelper = {
toSlug(title: string): string {
return title.toLowerCase().trim().replace(/\s+/g, "-");
},
calcReadTime(content: string): number {
const words = content.trim().split(/\s+/).length;
return Math.ceil(words / 200);
},
};
The Folder Structure
Here's what this looks like on disk for a feature-based MERN backend:
src/
├── features/
│ └── blog/
│ ├── blog.route.ts ← wires routes + middleware
│ ├── blog.middleware.ts ← Zod schemas for this feature
│ ├── blog.controller.ts ← req/res handling
│ ├── blog.service.ts ← business logic + DB
│ └── blog.helper.ts ← pure utility functions
├── models/
│ └── Blog.ts ← Mongoose schema only, no logic
├── middleware/
│ ├── validate.ts ← reusable Zod runner
│ ├── auth.ts
│ └── errorHandler.ts
└── app.ts
Feature-based organisation means when you're working on blogs, everything you need is in one folder. You're not jumping between a routes/ folder, a services/ folder, and a repositories/ folder on the other side of the project.
Why the Zod Middleware Layer Changes Everything
The old pattern was to validate inside the route handler or at the top of the service. Both are worse. Route-level validation clogs up your routing file. Service-level validation means bad data has already made it past your HTTP layer — you're running business logic against garbage.
Zod middleware at the route level means:
Controllers are 100% clean. They receive pre-validated, pre-typed data and don't write a single
if (!field)check.Errors are consistent. Every validation failure in your whole API returns the same structured JSON from one place (
validateBody).Schemas are co-located with the feature they belong to, not buried in some generic validators file.
TypeScript inference works naturally.
z.infer<typeof createBlogSchema>gives you the type for free — no need to define it separately.
How This Connects Back to React
When your backend is structured this way, your React frontend gets a much more predictable API to work with. Consistent Zod error shapes mean you can write one error-display component in React that handles validation failures from every endpoint — because they all return the same { errors: { field: [message] } } structure.
If you're using React Query, this matters even more. You can write a shared onError handler that parses that shape and sets field-level errors in your forms without any endpoint-specific branching.
The One Thing to Keep in Mind
The helper layer earns its existence when a function is genuinely reusable across the service — slug generation, read time, formatting dates, building query filters. If a function is only ever called once and only makes sense in one context, just inline it in the service. Don't create helpers for the sake of having a helpers file.
The same goes for the service: if a method is just one direct DB call with no transformation, that's fine — not everything needs to be orchestrated. The layers exist to separate concerns, not to force you to add files.
Start With One Feature
Pick one feature — your messiest one — and restructure it into feature.route.ts, feature.middleware.ts, feature.controller.ts, feature.service.ts, and feature.helper.ts. Get used to the flow. You'll notice how much easier it becomes to find things, write tests, and hand the code to someone else.
The architecture doesn't need to be everywhere on day one. The value is in having a pattern that the whole team understands — one that scales as features are added rather than rotting into spaghetti.
Got questions or a variation on this structure that works better for you? Drop a comment — genuinely curious how other MERN devs are organising their backends.





