Article

Clean Architectureを5プロジェクトで試して辿り着いた妥協点

高田晃太郎
Clean Architectureを5プロジェクトで試して辿り着いた妥協点

複数の商用案件でClean Architecture(関心の分離を軸に依存の向きを制御するアーキテクチャ)を適用し、スプリントごとの作業量・欠陥傾向・リードタイムを追いかけてきました。一般に、抽象や境界には初期コストが伴う一方で、中盤以降の仕様変動フェーズでは変更容易性やMTTR(平均復旧時間)の改善が見られる、という報告やケーススタディが多くあります¹²⁵⁶。私自身、複数チームに技術フェローとして伴走し、成功と反省の両方を見てきました。ここでは理想をなぞるのではなく、現場で「回った」妥協点と、その背景にある実装上の理由を示します。

Clean Architectureの理想と現実のギャップ

理想形は依存の逆転(UIやDBなど外界が内側のルールに依存する)を徹底し、エンティティとユースケース(アプリケーション固有のビジネスルール)が外界から隔離されます(この原則はRobert C. Martinが提唱)²。ただ、すべての境界でインターフェース(ポート)を立て、入出力DTO(データ転送用オブジェクト)を厳密に分離し、マッパーを完備すると、API1本あたりのコード量やレビュー観点が増え、初期のPRリードタイムは延びがちです³。初期スコープが比較的固い案件では過剰投資になりやすく、反対に探索が多いドメインでは保守性のメリットが早期に現れます。抽象化の効果は一様ではなく、変化頻度の高い境界だけを濃くするのが打ち手になります⁴。

抽象の負債化は、データ変換の連鎖、例外の握りつぶし、テストダブルの乱立から進行します。研究や実務の知見が示すように疎結合は変更容易性と相関しますが¹、現場では疎結合を担保するための「儀式化」がボトルネックになることがあります⁴。経験的には、プレゼンテーションとアプリケーションの境界は薄く、ドメインと永続化の境界だけを強固に保つ設計が、最終的にバランスが取りやすいと感じています。

過剰抽象化と速度低下のメカニズム

層を分ければ自動的に品質が上がるわけではありません。配送手数料の計算を例に取ると、コントローラで受けたリクエストをDTOに詰め替え、ユースケースがドメインサービスを呼び、さらにリポジトリ経由で参照データを取得する、という経路が一般的です。このとき、DTO→ドメイン→永続化→ドメイン→DTOという往復でマッピングが重なると、CPU時間よりもヒープ割当がホットスポットになります。p95レイテンシ(95パーセンタイルの遅延)において、マッパー層が数ms〜十数msを占めるケースが報告されています⁵。パフォーマンスを落とさずに設計の意図を守るには、変換回数を最小化するか、ドメインに寄せたDTOを採用してコピーを減らすのが実践的でした⁵。

テスタビリティの実利

メリットも明確です。ルールがユースケースに集約されるため、外部I/Oを切り離した純粋なテストが増えます。一般にユニットテストはAPIレベルの結合テストより桁違いに高速で、回帰検知のリードタイム短縮に効きます。ユニットテスト駆動は欠陥密度の低下と関連づけて報告されており⁶、テスト容易性は、人が入れ替わる組織でこそ効きます。

辿り着いた妥協点:境界は薄く、依存は見える化

妥協点はシンプルです。プレゼンテーション(UI/HTTP)層とアプリケーション(ユースケース)層は疎な関数呼び出しにとどめ、ドメインとインフラ(永続化や外部API)の境界にのみ明示的なポートを置く。DTOはドメイン都合で設計し、マッピングは入出力で一度だけ。ユースケースはクラスではなく関数で表現し、依存は明示の引数で注入(DI)する。例外はアプリケーション境界でResult型に畳み、インフラだけが例外を投げてもよい。こうすることで、層の意図は守りながら、儀式は最小化できます⁴。

最小セットの層とDTO規約(TypeScript例)

// domain/Order.ts
export type Currency = 'JPY' | 'USD';

export class Order {
  constructor(
    public readonly id: string,
    public readonly subtotal: number,
    public readonly currency: Currency,
    public readonly shippingFee: number,
  ) {
    if (subtotal < 0) throw new Error('Subtotal must be >= 0');
    if (shippingFee < 0) throw new Error('Shipping fee must be >= 0');
  }
  total(): number {
    return this.subtotal + this.shippingFee;
  }
}

export type OrderDTO = {
  id: string;
  subtotal: number;
  currency: Currency;
  shippingFee: number;
  total: number; // ドメイン都合のDTO: totalを含めて再計算を避ける
};

export const toDTO = (o: Order): OrderDTO => ({
  id: o.id,
  subtotal: o.subtotal,
  currency: o.currency,
  shippingFee: o.shippingFee,
  total: o.total(),
});
// application/ports/OrderRepository.ts
export interface OrderRepository {
  save(order: import('../../domain/Order').Order): Promise<void>;
  findById(id: string): Promise<import('../../domain/Order').Order | null>;
}

UseCaseは関数、DIはシンプル

ユースケースはクラスよりも関数で表現すると、ボイラープレートが減り、依存が明示化されます。エラーはResultで返し、インフラ由来の失敗とドメイン検証の失敗を区別します。

// application/shared/Result.ts
export type Ok<T> = { ok: true; value: T };
export type Err<E> = { ok: false; error: E };
export type Result<T, E = Error> = Ok<T> | Err<E>;
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
// application/usecases/CreateOrder.ts
import { Order, toDTO, OrderDTO } from '../../domain/Order';
import type { OrderRepository } from '../ports/OrderRepository';
import { Result, ok, err } from '../shared/Result';

export type CreateOrderInput = {
  id: string;
  subtotal: number;
  currency: 'JPY' | 'USD';
};

export async function createOrder(
  deps: { repo: OrderRepository; calcShipping: (subtotal: number) => number },
  input: CreateOrderInput,
): Promise<Result<OrderDTO, Error>> {
  try {
    const shippingFee = deps.calcShipping(input.subtotal);
    const order = new Order(input.id, input.subtotal, input.currency, shippingFee);
    await deps.repo.save(order);
    return ok(toDTO(order));
  } catch (e) {
    return err(e instanceof Error ? e : new Error('Unknown error'));
  }
}
// interface/http/OrderController.ts
import express, { Request, Response, NextFunction } from 'express';
import { createOrder } from '../../application/usecases/CreateOrder';
import type { OrderRepository } from '../../application/ports/OrderRepository';

export function createOrderRouter(repo: OrderRepository) {
  const router = express.Router();
  router.post('/', async (req: Request, res: Response, _next: NextFunction) => {
    const { id, subtotal, currency } = req.body ?? {};
    if (typeof id !== 'string' || typeof subtotal !== 'number') {
      res.status(400).json({ message: 'invalid payload' });
      return;
    }
    const result = await createOrder(
      { repo, calcShipping: (s) => (s >= 10000 ? 0 : 500) },
      { id, subtotal, currency },
    );
    if (!result.ok) {
      res.status(422).json({ message: result.error.message });
      return;
    }
    res.status(201).json(result.value);
  });
  return router;
}
// infrastructure/PrismaOrderRepository.ts
import { PrismaClient } from '@prisma/client';
import { Order } from '../domain/Order';
import type { OrderRepository } from '../application/ports/OrderRepository';

export class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaClient) {}
  async save(order: Order): Promise<void> {
    try {
      await this.prisma.order.upsert({
        where: { id: order.id },
        create: {
          id: order.id,
          subtotal: order.subtotal,
          currency: order.currency,
          shippingFee: order.shippingFee,
        },
        update: {
          subtotal: order.subtotal,
          currency: order.currency,
          shippingFee: order.shippingFee,
        },
      });
    } catch (e) {
      // インフラ層は例外を投げる。上位でResultに畳む。
      throw e;
    }
  }
  async findById(id: string): Promise<Order | null> {
    const row = await this.prisma.order.findUnique({ where: { id } });
    return row
      ? new Order(row.id, row.subtotal, row.currency as 'JPY' | 'USD', row.shippingFee)
      : null;
  }
}
// main/server.ts
import express from 'express';
import { PrismaClient } from '@prisma/client';
import { PrismaOrderRepository } from '../infrastructure/PrismaOrderRepository';
import { createOrderRouter } from '../interface/http/OrderController';

async function bootstrap() {
  const app = express();
  app.use(express.json());
  const prisma = new PrismaClient();
  const repo = new PrismaOrderRepository(prisma);
  app.use('/orders', createOrderRouter(repo));

  app.listen(3000, () => console.log('listening on 3000'));
}
bootstrap().catch((e) => {
  console.error(e);
  process.exit(1);
});

導入時の観測傾向とビジネス効果

数字は冷静です。APIスループットは、Clean化前後の比較で数ms〜十数ms程度のオーバーヘッドが生じることがあります⁵。主因はマッピングとバリデーションであり、DB往復に比べて支配的ではないケースが多い。一方、仕様変更に伴う改修で触る箇所が減りやすく、欠陥密度の低下と整合的な報告も見られます¹⁶。リリース後の障害対応にかかる時間(MTTR)が短縮した事例もあります。特に値引きロジックのような高変動領域で顕著です。ビジネス側への説明もしやすくなります。要件仮説が外れても、影響を受けるユースケースと関連ポートが明確なため、見積りのブレ幅が減る傾向があるからです。ロードマップ見直しにおける見積りのバリアンスが縮小したという実務の声とも矛盾しません²。

パフォーマンスのオーバーヘッドを抑える実装

マッピングを一往復に制限し、ドメイン寄りのDTOにtotalを含めるなど再計算を避ける。加えて、HTTP層とアプリケーション層の間に入るバリデーションはコンパイル済みスキーマを用いる。これらはp95レイテンシの悪化を抑えるうえで有効であることが多いです⁵。抽象や変換のオーバーヘッドは設計次第で顕著になり得るため、ボトルネックを特定し最小化する指針が重要です⁵。

// interface/http/validation.ts
import Ajv, { JSONSchemaType } from 'ajv';
import { CreateOrderInput } from '../../application/usecases/CreateOrder';

const ajv = new Ajv({ removeAdditional: 'all', allErrors: true });
const schema: JSONSchemaType<CreateOrderInput> = {
  type: 'object',
  properties: {
    id: { type: 'string' },
    subtotal: { type: 'number', minimum: 0 },
    currency: { type: 'string', enum: ['JPY', 'USD'] },
  },
  required: ['id', 'subtotal', 'currency'],
  additionalProperties: false,
};
export const validateCreateOrder = ajv.compile(schema);
// interface/http/OrderControllerOptimized.ts
import express, { Request, Response } from 'express';
import { validateCreateOrder } from './validation';
import { createOrder } from '../../application/usecases/CreateOrder';
import type { OrderRepository } from '../../application/ports/OrderRepository';

export function router(repo: OrderRepository) {
  const r = express.Router();
  r.post('/', async (req: Request, res: Response) => {
    if (!validateCreateOrder(req.body)) {
      res.status(400).json({ message: 'invalid', errors: validateCreateOrder.errors });
      return;
    }
    const result = await createOrder(
      { repo, calcShipping: (s) => (s >= 10000 ? 0 : 500) },
      req.body,
    );
    res.status(result.ok ? 201 : 422).json(result.ok ? result.value : { message: result.error.message });
  });
  return r;
}

オンボーディングとレビューのしやすさ

関数ユースケースと明示的なポート、薄い境界という組み合わせは、学習コストが低いのが利点です⁴。新人はmainからリクエストの経路を辿り、ユースケースの引数で依存を把握し、ポートの実装を切り替えるだけで動作の理解に至ります。レビューでも「ドメインルールがユースケースに集約されているか」「永続化依存が外側に閉じているか」を見ればよく、観点が減るため指摘の質と速度が上がりやすい。結果として、レビューサイクルタイムが短縮するケースは少なくありません³。

段階的導入とアンチパターンの回避

既存システムに対しては、一気に全層を導入せず、変化頻度が高く欠陥が出やすい機能から境界を立て直すのが現実的でした。まずはユースケース関数の抽出とポート定義から着手し、旧サービス層はファサードとして残して薄く委譲させます。このアプローチなら、ドメインルールをユースケースに移し切った段階で勝ちが見え、外部I/Oの置換は段階的に進められます。以下はファサードで既存実装を抱え込みつつ、新ユースケースへ移行する例です⁴。

// legacy/OrderServiceFacade.ts
import { createOrder, CreateOrderInput } from '../application/usecases/CreateOrder';
import type { OrderRepository } from '../application/ports/OrderRepository';

export class OrderServiceFacade {
  constructor(private readonly repo: OrderRepository) {}
  async create(input: CreateOrderInput) {
    // 既存エンドポイントはこのファサードを呼ぶ。内部で新ユースケースへ委譲。
    const result = await createOrder({ repo: this.repo, calcShipping: (s) => (s >= 10000 ? 0 : 500) }, input);
    if (!result.ok) throw result.error; // 旧契約に合わせ例外で表現
    return result.value;
  }
}

検出の自動化も効きます。依存が逆流していないかを継続的に監視するため、静的解析ツールで境界違反をCIに組み込みました。依存グラフの可視化は、設計議論を感覚論から遠ざけます⁴。

// tools/dependency-cruiser.config.cjs
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
  forbidden: [
    {
      name: 'no-domain-dep-on-infra',
      severity: 'error',
      comment: 'domainはinfrastructureに依存しない',
      from: { path: 'src/domain' },
      to: { path: 'src/infrastructure' },
    },
    {
      name: 'app-no-dep-on-http',
      severity: 'error',
      comment: 'applicationはinterface層に依存しない',
      from: { path: 'src/application' },
      to: { path: 'src/interface' },
    },
  ],
  options: { doNotFollow: { path: 'node_modules' } },
};

アンチパターンとして、すべての機能に一律でポートを立ててしまう設計は避けました。変化しない外部I/Oまで抽象化すると、学習コストとメンテナンスコストの純増になります³。ドメインの語彙が安定するまでは、ポートの粒度を粗く保つほうが、結果的に総工数を抑えられました⁴。

まとめ:守るべき境界を見極める勇気

Clean Architectureは銀の弾丸ではありませんが、変化が集中する境界だけを濃くするという割り切りがあれば、初期コストを小さく抑えながら長期の変更容易性を確保できます。ユースケースは関数で、依存は引数で、例外はアプリケーション境界で畳む。ドメインとインフラの境界だけを強固に守り、プレゼンテーションとの境界は薄くする。もし今、どこから始めればよいか迷っているなら、最も変更頻度が高く、障害の多い機能をひとつ選び、今日示したユースケース関数とポートの抽出から始めてみてください。次のスプリントで、レビューの会話がどれだけ明瞭になるか、きっと体感できるはずです²。

参考文献

  1. Himanshu Das. The role of Clean Architecture in minimizing software maintenance costs and enhancing developer productivity. Medium. 2024-09-03. https://medium.com/@himanshudas_/the-role-of-clean-architecture-in-minimizing-software-maintenance-costs-and-enhancing-developer-77b3c8078087
  2. Robert C. Martin. The Clean Architecture. 2012-08-13. https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
  3. Algocademy. Why your Clean Architecture is making things more complicated. 2023 (estimated). https://algocademy.com/blog/why-your-clean-architecture-is-making-things-more-complicated/
  4. Three Dots Tech Podcast. Is Clean Architecture overengineering? 2023. https://threedots.tech/episode/is-clean-architecture-overengineering/
  5. Chaewon Kong. Performance disadvantages of Clean Architecture — a closer look. Medium. 2023-05-03. https://medium.com/@chaewonkong/performance-disadvantages-of-clean-architecture-a-closer-look-1fe38362c74f
  6. YAG (CodeProject). Test-driven design: a methodology for low defect software. 2009-10-19. https://www.codeproject.com/Articles/43049/Test-driven-design-a-methodology-for-low-defect-so