Skip to main content

Split a Monolithic Express Router into Domain Routes

Devin breaks a 2,000-line Express router into domain-specific route files with shared middleware — then updates every import and verifies all tests pass.
AuthorCognition
CategoryCode Quality
1

Show Devin the monolith

You know the file — one Express router that grew for eighteen months. Every endpoint for every domain lives in src/routes/index.ts: user registration next to payment webhooks next to product search. Inline auth checks are copy-pasted across 40 handlers. Nobody wants to touch it because a change to the orders logic might break the user endpoints three hundred lines above.Here’s what the top of the file typically looks like:
src/routes/index.ts (before — 2,000 lines)
import { Router } from "express";
import { db } from "../db";
import { stripe } from "../lib/stripe";
import { sendEmail } from "../lib/email";
import { logger } from "../lib/logger";

const router = Router();

// ---- Auth middleware (copy-pasted everywhere) ----
const requireAuth = (req, res, next) => {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ error: "Unauthorized" });
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    res.status(401).json({ error: "Invalid token" });
  }
};

// ---- User routes ----
router.post("/users/register", async (req, res) => { /* 45 lines */ });
router.post("/users/login", async (req, res) => { /* 30 lines */ });
router.get("/users/:id", requireAuth, async (req, res) => { /* 25 lines */ });
router.put("/users/:id", requireAuth, async (req, res) => { /* 40 lines */ });

// ---- Product routes ----
router.get("/products", async (req, res) => { /* 60 lines */ });
router.get("/products/:id", async (req, res) => { /* 35 lines */ });
router.post("/products", requireAuth, async (req, res) => { /* 50 lines */ });

// ---- Order routes ----
router.post("/orders", requireAuth, async (req, res) => { /* 80 lines */ });
router.get("/orders/:id", requireAuth, async (req, res) => { /* 40 lines */ });
router.post("/orders/:id/refund", requireAuth, async (req, res) => { /* 55 lines */ });

// ---- Payment routes ----
router.post("/payments/webhook", async (req, res) => { /* 90 lines */ });
router.get("/payments/:id/status", requireAuth, async (req, res) => { /* 30 lines */ });

// ... 1,400 more lines of mixed handlers, inline validation,
//     and duplicated auth checks

export default router;
Tell Devin exactly what you want the target structure to look like.
2

Guide Devin with conventions

Devin reads your codebase to infer patterns, but refactoring is where Knowledge entries pay off the most. Add entries for conventions Devin should follow:
  • Router patterns — “Each domain router uses Router() and is mounted with app.use('/domain', domainRouter) in the root”
  • Middleware — “Auth middleware lives in src/middleware/ and is always imported, never defined inline”
  • Error handling — “All route handlers use our asyncHandler wrapper from src/lib/asyncHandler.ts — never raw try/catch”
Pointing Devin at an already well-structured router in your codebase often produces better results than describing conventions from scratch. Add a line like “Follow the pattern in src/routes/admin.ts, which is already cleanly separated” to your prompt.You can also use Advanced Devin to generate Knowledge entries for you — just describe your conventions and it will create well-structured entries you can review and save.
3

Review Devin's PR

Devin maps every endpoint, traces the import graph, extracts shared logic, creates the domain files, rewires the root router, and runs your test suite. Here’s what the PR typically looks like:
refactor: Split monolithic router into domain-specific route files

Files changed (8):
  src/routes/users.ts        — 4 endpoints, auth middleware imported
  src/routes/products.ts     — 3 endpoints, public + auth-protected
  src/routes/orders.ts       — 3 endpoints, all auth-protected
  src/routes/payments.ts     — 2 endpoints, webhook + status check
  src/routes/index.ts        — root router mounting all domains
  src/middleware/auth.ts      — requireAuth, requireAdmin (extracted)
  src/middleware/validate.ts  — validateBody schema middleware
  Old src/routes/index.ts     — 2,000-line monolith replaced

All 112 API tests pass. No URL changes.
Here’s what the clean root router looks like after the split:
src/routes/index.ts (after — 15 lines)
import { Router } from "express";
import usersRouter from "./users";
import productsRouter from "./products";
import ordersRouter from "./orders";
import paymentsRouter from "./payments";

const router = Router();

router.use("/users", usersRouter);
router.use("/products", productsRouter);
router.use("/orders", ordersRouter);
router.use("/payments", paymentsRouter);

export default router;
And a domain route file with the shared middleware properly imported:
src/routes/orders.ts (after — excerpt)
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { validateBody } from "../middleware/validate";
import { createOrderSchema, refundSchema } from "../schemas/orders";
import { db } from "../db";

const router = Router();

router.post("/", requireAuth, validateBody(createOrderSchema),
  async (req, res) => {
    const order = await db.orders.create({
      userId: req.user.id,
      items: req.body.items,
      total: req.body.total,
    });
    res.status(201).json(order);
  }
);

router.get("/:id", requireAuth, async (req, res) => {
  const order = await db.orders.findByPk(req.params.id);
  if (!order) return res.status(404).json({ error: "Order not found" });
  res.json(order);
});

router.post("/:id/refund", requireAuth, validateBody(refundSchema),
  async (req, res) => {
    // refund logic extracted cleanly from the monolith
  }
);

export default router;
Every URL path stays identical — /orders is now handled by ordersRouter mounted at /orders, so existing clients and tests work without changes.
4

(Optional) Check out the branch and test locally

For a structural refactor like this, it’s worth pulling the branch and verifying locally before merging. Check it out in Windsurf or your preferred IDE, boot the app, and hit a few endpoints to confirm routing, middleware, and error handling all behave the same as before.
git fetch origin && git checkout devin/refactor-router-split
npm install && npm test
npm run dev
# Hit a few endpoints: curl http://localhost:3000/users, /orders, /payments
If anything looks off, leave a comment on the PR — Devin will pick it up and push a fix.
5

Continue the cleanup

Once the router is split, use follow-up prompts to extend the refactoring: