Node.js ヘキサゴナルアーキテクチャ:コアロジックをクリーンに保つ方法

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

レイヤードアーキテクチャからヘキサゴナルな思考への転換

多くの開発者は、標準的なMVCパターンからNode.jsのキャリアをスタートさせます。ルーティング、コントローラー、サービス、モデルが積み重なった構造です。これは週末のプロジェクトには適していますが、コードベースが1万行を超えてくると、ビジネスロジックがデータベースクエリに混じり始めていることに気づくでしょう。突然、サービスがExpressや特定バージョンのTypeORMと密結合(tightly coupled)になってしまうのです。

ヘキサゴナルアーキテクチャ(別名:Ports and Adapters)は、この構造を逆転させます。アプリケーションのコアを、高級ステレオシステムのように考えてみてください。スピーカー(データベース)や入力(Express、CLI、Cronジョブ)は、背面に差し込むコンポーネントに過ぎません。コアはスピーカーのメーカーがどこであるかを気にしません。標準化されたジャックを通じて信号を送るだけです。この考え方を取り入れることで、プロジェクトがリファクタリングを恐れるようなレガシーな悪夢になるのを防ぐことができます。

レイヤード vs ヘキサゴナル:比較

標準的なレイヤードアーキテクチャでは、依存関係の流れは固定されています:Controller -> Service -> Repository -> Database。もしMongoDBをPostgreSQLに入れ替える場合、Serviceレイヤーが特定のデータ形式を期待しているため、その30%を書き直すことになることがよくあります。ビジネスロジックが、実質的にインフラストラクチャの「人質」になっている状態です。

ヘキサゴナルアーキテクチャはこの連鎖を断ち切ります。コア(ドメイン)は、アプリが何をすべきかを記述したシンプルなインターフェースである「Ports(ポート)」を定義します。そして、インフラストラクチャ(Adapters/アダプター)がそれらのインターフェースを実装します。コアロジックは、相手が本物のデータベースなのか、CSVファイルなのか、あるいはテスト用のモックなのかを一切知る必要がありません。

ヘキサゴナルアプローチのメリットとデメリット

アーキテクチャの選択には常にトレードオフが伴います。リポジトリ全体をリファクタリングする前に、特定のユースケースにおいてメリットがオーバーヘッドを上回るかどうかを検討してください。

メリット

  • フレームワークに依存しない: ExpressをFastifyに午後だけで入れ替えることができます。ビジネスロジックを一行も書き換えることなく、同じロジックをAWS Lambda関数で実行することさえ可能です。
  • 極めて高速なテスト: ロジックが隔離されているため、ユニットテストでデータベース接続やサーバーの起動を待つ必要がありません。これらのテストは、数秒ではなく数ミリ秒で完了します。
  • ベンダーロックインの防止: メールプロバイダーが料金を2倍に引き上げたとしても、変更するのは1つのアダプターファイルだけです。システムの残りの部分は全く同じままです。

デメリット(トレードオフ)

  • 初期のオーバーヘッド: 最初に書くコードの量が増えます。以前は2つのファイルで済んでいたシンプルな機能に、5つか6つのファイルが必要になるかもしれません。
  • データマッピング: データベースのエンティティをドメインモデルに変換する必要があります。これによりデータベーススキーマがロジックに漏れ出すのを防げますが、ボイラープレート(定型コード)が増えます。
  • 学習コストと認知負荷: 従来のMVCに慣れている新しいチームメンバーは、この関心の分離(Separation of Concerns)を混乱しやすく感じるかもしれません。

推奨されるフォルダ構造

Node.jsにおいてこれらの境界を強制する最善の方法は、明確なディレクトリ構造を作ることです。以下は、本番環境で堅牢であることが証明されているレイアウトです。


src/
 ├── domain/           # 純粋なロジック (エンティティ, 値オブジェクト, ドメインエラー)
 ├── application/      # ユースケースとポートの定義 (インターフェース)
 ├── infrastructure/   # アダプター (TypeORM, Axios, Express, NestJS)
 └── main.ts           # すべてを結合する「Composition Root(構成の根底)」

ステップバイステップの実装ガイド

ユーザー登録機能を構築してみましょう。インターフェースシステムがポートの定義に最適であるため、TypeScriptを使用します。

1. ドメインエンティティ

ドメインはアプリケーションの心臓部です。外部ライブラリへの依存はゼロであるべきです。このファイルをプレーンなNode.jsスクリプトで実行できない場合、それはおそらく密結合すぎます。


// src/domain/user.ts
export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    public readonly passwordHash: string
  ) {}

  // ビジネスルールはコントローラーではなくここに記述する
  public hasValidEmail(): boolean {
    return this.email.includes('@') && this.email.length > 5;
  }
}

2. ポートの定義(インターフェース)

ポートはアプリケーションレイヤーに存在し、契約(Contract)として機能します。外部に対して「このアプリを動かすには、ユーザーを保存し検索する方法を提供しなければならない」と伝えます。


// src/application/ports/user-repository.port.ts
import { User } from '../../domain/user';

export interface UserRepository {
  save(user: User): Promise<void>;
  findByEmail(email: string): Promise<User | null>;
}

3. アプリケーションユースケース

ユースケースはロジックを調整(オーケストレーション)します。これが UserRepository インターフェースのみを認識している点に注目してください。データが最終的にMongoDBに保存されるのか、JSONファイルに保存されるのかは関知しません。


// src/application/use-cases/register-user.ts
import { User } from '../../domain/user';
import { UserRepository } from '../ports/user-repository.port';

export class RegisterUser {
  constructor(private userRepository: UserRepository) {}

  async execute(email: string, passwordHash: string): Promise<void> {
    const existingUser = await this.userRepository.findByEmail(email);
    
    if (existingUser) {
      throw new Error('このメールアドレスは既に登録されています');
    }

    const user = new User(crypto.randomUUID(), email, passwordHash);
    await this.userRepository.save(user);
  }
}

4. インフラストラクチャアダプター

ここで実際の処理が行われます。好みのツールを使用して、具体的なデータベースロジックを実装します。


// src/infrastructure/adapters/repositories/mongo-user-repository.ts
import { UserRepository } from '../../../application/ports/user-repository.port';
import { User } from '../../../domain/user';

export class MongoUserRepository implements UserRepository {
  async save(user: User): Promise<void> {
    // ここにMongooseのロジックを記述する想定
    console.log(`${user.email} を MongoDB コレクションに保存中...`);
  }

  async findByEmail(email: string): Promise<User | null> {
    // データベースの検索ロジック
    return null;
  }
}

5. すべてを結合する

最後のステップは、アプリのエントリポイントで行われます。具体的なアダプターをユースケースに注入(Inject)します。


// src/main.ts
import { RegisterUser } from './application/use-cases/register-user';
import { MongoUserRepository } from './infrastructure/adapters/repositories/mongo-user-repository';

// 依存性の注入(Dependency Injection)
const userRepository = new MongoUserRepository();
const registerUserUseCase = new RegisterUser(userRepository);

// これはExpressのルート、CLI、またはテストスイートから呼び出すことができます
registerUserUseCase.execute('[email protected]', 'secure_hash_123')
  .then(() => console.log('成功!'))
  .catch(err => console.error('失敗:', err.message));

本番環境からの教訓

このアーキテクチャへの移行には、特有の課題が伴うことがよくあります。混乱せずにそれらに対処する方法を紹介します。

マッピングの罠

よくある間違いは、MongooseやSequelizeのオブジェクトのようなデータベースモデルを、ビジネスロジックに直接渡してしまうことです。これは隠れた依存関係を生み出し、アーキテクチャの目的を台無しにします。アダプター内で必ずデータベースの結果をドメインエンティティにマッピングしてください。テストを壊さずにデータベーススキーマを変更しなければならない日が来るまでは無駄な作業に思えるかもしれませんが、その価値はあります。

依存関係の管理

小規模なアプリでは手動でのインスタンス化で十分ですが、サービスが増えるにつれて煩雑になります。大規模なプロジェクトでは、**InversifyJS** や **Awilix** のような依存性の注入(DI)コンテナを使用してください。これらのツールは結合プロセスを自動化し、環境ごとにアダプターを入れ替えるのを容易にします。

オーバーエンジニアリングを避ける

もし、3ヶ月後には削除されるようなエンドポイントが3つしかないシンプルなCRUD APIを構築しているのであれば、ヘキサゴナルアーキテクチャは過剰(オーバーキル)です。ビジネスロジックが複雑な場合や、チームで数年間メンテナンスすることが予想される場合にこのパターンを採用してください。目標は、ビジネスロジックが非常に純粋で、インフラストラクチャと戦うことなく、テスト、移動、拡張ができる状態にすることです。

Share: