Kodokon kodokon.com

APIの設計:ルート、コントローラー、サービス

APIを単一責任のレイヤーに構造化し、軽量な注入でそれらをつなぎ合わせて、テスト可能にしましょう。

10 分 · 3 問

このレッスンを Kodokon で開く

ルーティング、ビジネスロジック、データアクセスを一つのファイルに混ぜると、必ず高くつきます。サーバーを立ち上げずにビジネスルールをテストすることはできず、すべてを書き直さずにインフラを差し替えることもできません。三つのレイヤーに分割すれば、この二つの問題が解決します。ルートはURLを宣言し、委譲します。コントローラーはHTTPをビジネス呼び出しに翻訳します。リクエストを読み取り、ステータスコードを選び、レスポンスをシリアライズするのです。サービスはビジネスルールを保持し、Expressについては何一つ知りません。依存のルールは厳格です。各レイヤーは自分のすぐ下のレイヤーだけを知り、決してその逆はありません。

JAVASCRIPT
import { Router } from 'express';

export function createUserRouter(controller) {
  const router = Router();
  router.get('/', controller.list);
  router.post('/', controller.create);
  return router;
}
ルートは宣言し、コントローラーは動く。ここにロジックはありません。

コントローラーはサービスを引数として受け取るファクトリーです。ビジネスルールは一切持ちません。その仕事はreqからデータを取り出し、サービスを呼び出し、その結果をHTTPレスポンスに変えることに限られます。あらゆるエラーはnextに送られ、中央のエラーミドルウェアによって処理されます。コントローラーのあちこちに散らばったres.status(500)は決してありません。

JAVASCRIPT
export function createUserController(service) {
  return {
    async list(req, res, next) {
      try {
        const users = await service.listUsers();
        res.json(users);
      } catch (err) {
        next(err);
      }
    },
    async create(req, res, next) {
      try {
        const user = await service.createUser(req.body);
        res.status(201).json(user);
      } catch (err) {
        next(err);
      }
    },
  };
}
純粋なHTTPの翻訳。ステータスコード、シリアライズ、委譲です。

サービスは判断を集中させます。メールアドレスの一意性、作成のルールなどです。Expressについては何も知らないまま、ビジネスエラーを投げます。せいぜいstatusフィールドで補強される程度です。サービスもまた、依存であるデータリポジトリを引数として受け取ります。これが軽量な注入です。起動時に一度だけ呼び出される素朴なファクトリー関数が、重量級の注入コンテナに取って代わり、すべてのレイヤーをテストで差し替え可能にします。

JAVASCRIPT
export function createUserService(repository) {
  return {
    listUsers() {
      return repository.findAll();
    },
    createUser(input) {
      const found = repository.findByEmail(input.email);
      if (found) {
        const err = new Error('Email already used');
        err.status = 409;
        throw err;
      }
      return repository.insert(input);
    },
  };
}
ビジネスロジックはここに宿ります。req、res、SQLは一切ありません。

残るはコンポジションルートです。ファクトリーが組み合わさる唯一の場所です。createAppはインフラの依存(ここではデータベース。次のレッスンで扱います)を受け取り、まだリッスンしていない完全なExpressアプリケーションを返します。構築とリッスンを分けるのは些細なことに見えますが、これこそが、本番サーバーを三行に保ったまま、テストにAPIを一時的なポートでマウントさせるものです。データベースを作り、アプリを作り、リッスンする、それだけです。

JAVASCRIPT
import express from 'express';
import { createUserRepository } from './db/users.js';
import { createUserService } from './services/users.js';
import {
  createUserController,
} from './controllers/users.js';
import { createUserRouter } from './routes/users.js';

export function createApp({ db }) {
  const app = express();
  app.use(express.json());
  const repository = createUserRepository(db);
  const service = createUserService(repository);
  const controller = createUserController(service);
  app.use('/api/users', createUserRouter(controller));
  return app;
}
app.js:アプリケーションの中で全員を知る唯一の部分。

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. このレイヤー構造で、「メールアドレスは一度しか使えない」というルールはどこに置くべきですか?
    • URLにできるだけ近い、ルートの中
    • req.bodyにアクセスできるコントローラーの中
    • ビジネスルールを保持するサービスの中
    • グローバルなExpressミドルウェアの中
  2. 依存を各モジュールに直接インポートするのではなく、ファクトリーの引数として渡すことの主な利点は何ですか?
    • ファクトリーはモジュールの読み込みを速くする
    • それを使うモジュールに触れずに、依存(インメモリのデータベース、偽のリポジトリ)を差し替えられる
    • Expressがミドルウェアにこのスタイルを要求する
    • 別々のファイルを書かずに済む
  3. なぜcreateAppは自分でネットワークのリッスンを始めないのですか?
    • Expressが関数の中でlistenを呼ぶことを禁じているから
    • テストが固定ポートを開かずにアプリケーションをインスタンス化でき、サーバーの入口を些細なものに保てるから
    • listenは非同期で、ファクトリーをブロックしてしまうから
    • 起動時のメモリ使用量を減らすため