Skip to main content

モノリシックな Express ルーターをドメイン別ルートに分割する

Devin は 2,000 行の Express ルーターをドメインごとのルートファイルに分割し、共通のミドルウェアを抽出して共有したうえで、すべての import を更新し、すべてのテストが通ることを検証します。
AuthorCognition
Categoryコード品質
1

Devin にモノリスを見せる

おなじみのあのファイル――18か月かけて肥大化したひとつの Express ルーターです。あらゆるドメインのエンドポイントが src/routes/index.ts にすべて集約されていて、ユーザー登録の隣に決済の Webhook、その隣に商品検索が並んでいます。インラインの認可・認証チェックは 40 個のハンドラーにコピペされていて、誰も触りたがりません。注文ロジックを変更すると、300 行上にあるユーザー用エンドポイントが壊れるかもしれないからです。ファイルの先頭は、たいていこんな感じになっています。
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();

// ---- 認証ミドルウェア(各所にコピー&ペースト)----
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" });
  }
};

// ---- ユーザールート ----
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 */ });

// ---- 商品ルート ----
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 */ });

// ---- 注文ルート ----
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 */ });

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

// ... 混在したハンドラー、インラインバリデーション、
//     重複した認証チェックがさらに1,400行続く

export default router;
ターゲットとする構成をどのような形にしたいのかを、Devin に正確に伝えてください。
2

規約でDevinをガイドする

Devin はコードベースを読み込んでパターンを推論しますが、リファクタリングの場面では Knowledge のエントリが最も効果を発揮します。Devin に従わせたい規約について、エントリを追加します:
  • ルーターパターン — 「各ドメインルーターは Router() を使用し、アプリケーションのルートで app.use('/domain', domainRouter) としてマウントする」
  • ミドルウェア — 「認証ミドルウェアは src/middleware/ に配置し、常にインポートして使用し、その場で定義しない」
  • エラーハンドリング — 「すべてのルートハンドラーは src/lib/asyncHandler.tsasyncHandler ラッパーを使用し、生の try/catch は使わない」
コードベース内ですでによく構造化されたルーターを Devin に参照させる方が、規約を一から説明するよりも良い結果につながることが多いです。プロンプトに「src/routes/admin.ts のパターンに従ってください。このファイルはすでにきれいに分離されています」のような一文を追加してください。Advanced Devin を使って、Knowledge エントリを生成させることもできます。規約を説明するだけで、確認して保存できる、よく構造化されたエントリを作成します。
3

Devin の PR をレビューする

Devin はすべてのエンドポイントをマッピングし、インポートグラフをトレースし、共通ロジックを抽出し、ドメインファイルを作成し、ルートルーターを組み替えたうえで、テストスイートを実行します。一般的な PR は次のようになります。
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.
分割後のクリーンなルートルーターは次のとおりです。
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;
また、共有ミドルウェアを正しくインポートしたドメインルートファイルは次のとおりです:
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) => {
    // モノリスからきれいに抽出された返金ロジック
  }
);

export default router;
すべてのURLパスは同一のままです — /orders/orders にマウントされた ordersRouter によって処理されるため、既存のクライアントやテストは変更なしでそのまま動作します。
4

(任意)ブランチをチェックアウトしてローカル環境でテストする

このような構造的なリファクタリングの場合は、マージする前にブランチをローカルに pull して確認しておくことをおすすめします。Windsurf やお好みの IDE でチェックし、アプリを起動していくつかのエンドポイントにアクセスし、ルーティング、ミドルウェア、エラー処理が以前と同様に動作していることを確認してください。
git fetch origin && git checkout devin/refactor-router-split
npm install && npm test
npm run dev
# いくつかのエンドポイントにアクセス: curl http://localhost:3000/users, /orders, /payments
何か気になる点があれば、PR にコメントを残してください — Devin がそれを拾い上げて修正を反映します。
5

クリーンアップを続行

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