疑問
ヘキサゴナルアーキテクチャとは何で、どのようにドメインロジックを外部の詳細から切り離すのでしょうか?ポート&アダプターパターンについて一緒に学んでいきましょう。
導入
ヘキサゴナルアーキテクチャ(Hexagonal Architecture)、別名ポート&アダプターアーキテクチャは、アプリケーションのコア(ドメインロジック)を外部の詳細から完全に切り離す設計パターンです。Alistair Cockburnによって提唱され、クリーンアーキテクチャの基盤となっています。
このアーキテクチャにより、データベース、UI、外部サービスなどの変更がドメインロジックに影響を与えなくなります。本記事では、ヘキサゴナルアーキテクチャの基本から実践的な実装まで詳しく解説します。
解説
1. ヘキサゴナルアーキテクチャとは
ヘキサゴナルアーキテクチャは、アプリケーションを六角形(ヘキサゴン)として表現し、中心にドメインロジックを配置し、周囲にアダプターを配置する設計パターンです。
アーキテクチャの構造
ヘキサゴナルアーキテクチャは、アプリケーションを六角形として表現します。
構造の特徴:
- 中心(コア): ドメインロジックとビジネスルール
- 周囲(アダプター): 外部とのインターフェース
- ポート: アプリケーションの境界を定義するインターフェース
- アダプター: ポートを実装し、外部システムと接続
六角形の意味:
- 六角形は特定の数の側面を意味するものではない
- 複数の側面(入力・出力)を持つことを表現
- 実際には任意の数のポートとアダプターを持つことができる
主な目的:
- ドメインロジックを外部の詳細から完全に切り離す
- データベース、UI、外部サービスの変更がドメインに影響しない
- テストを容易にする(モックアダプターを使用)
- 複数のUIやデータソースに対応可能
2. ポートとアダプター
ポートとアダプターは、ヘキサゴナルアーキテクチャの中核となる概念です。ポートはインターフェースを定義し、アダプターがそれを実装します。
ポート(Port)
ポートは、アプリケーションの境界を定義するインターフェースです。ドメインコアが外部とどのように通信するかを定義します。
ポートの種類:
1. 入力ポート(Driving Port / Primary Port):
- アプリケーションが外部から受け取る操作を定義
- ユースケースのインターフェース
- 例: UserService、OrderServiceなどのインターフェース
2. 出力ポート(Driven Port / Secondary Port):
- アプリケーションが外部に提供する操作を定義
- リポジトリや外部サービスのインターフェース
- 例: UserRepository、EmailServiceなどのインターフェース
ポートの特徴:
- ドメインコアに定義される
- 外部の実装詳細に依存しない
- インターフェースのみを定義(実装は含まない)
- テスト時にモックに置き換え可能
アダプター(Adapter)
アダプターは、ポートを実装し、外部システムとドメインコアを接続するコンポーネントです。
アダプターの種類:
1. 入力アダプター(Driving Adapter / Primary Adapter):
- 外部からの入力をドメインコアに伝える
- HTTPコントローラー、CLI、メッセージハンドラーなど
- 入力ポートを呼び出す
2. 出力アダプター(Driven Adapter / Secondary Adapter):
- ドメインコアからの出力を外部システムに伝える
- データベースリポジトリ、外部APIクライアント、ファイルシステムなど
- 出力ポートを実装する
アダプターの特徴:
- 外部システムの詳細をドメインから隠蔽
- 複数のアダプターで同じポートを実装可能
- 変更や交換が容易
- テスト時にモックアダプターに置き換え可能
3. ドメインコアの実装
ドメインコアは、ビジネスロジックとルールを含むアプリケーションの中心部分です。外部の詳細に一切依存しない純粋なドメインロジックを実装します。
ドメインコアは、ヘキサゴナルアーキテクチャの中心に位置し、ビジネスロジックとルールを含みます。外部の詳細(データベース、UI、外部サービス)に一切依存しない純粋なドメインロジックを実装します。
ドメインコアの構成要素:
1. エンティティ(Entity):
- ビジネスオブジェクト
- 一意の識別子を持つ
- ビジネスルールを含む
2. 値オブジェクト(Value Object):
- 値によって識別されるオブジェクト
- 不変(イミュータブル)
- ビジネスルールを含む
3. ドメインサービス(Domain Service):
- エンティティに属さないビジネスロジック
- 複数のエンティティにまたがる操作
4. ユースケース(Use Case / Application Service):
- アプリケーションのユースケースを実装
- 入力ポートとして定義
- ドメインエンティティとリポジトリを調整
実装の原則:
- 外部のフレームワークやライブラリに依存しない
- データベースの詳細を知らない
- UIの詳細を知らない
- 純粋なビジネスロジックのみを含む
ドメインエンティティ
ドメインエンティティの例です。ビジネスロジックを含み、外部に依存しません。
// ドメインエンティティ: domain/User.js
class User {
constructor(id, email, name) {
this.id = id;
this.email = email;
this.name = name;
}
// ビジネスルール: メールアドレスの検証
static isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// ビジネスロジック: ユーザー名の変更
changeName(newName) {
if (!newName || newName.trim().length === 0) {
throw new Error('Name cannot be empty');
}
this.name = newName.trim();
}
}
module.exports = User;入力ポート(インターフェース)
入力ポートの例です。インターフェースのみを定義します。
// 入力ポート: domain/ports/UserService.js(インターフェース)
class UserService {
async createUser(userData) {
throw new Error('Not implemented');
}
async getUserById(id) {
throw new Error('Not implemented');
}
}
module.exports = UserService;ユースケースの実装
ユースケースの実装例です。ドメインロジックを実装し、リポジトリのインターフェースに依存します。
// ユースケースの実装: domain/services/CreateUserUseCase.js
const User = require('../User');
class CreateUserUseCase {
constructor(userRepository) {
this.userRepository = userRepository;
}
async execute(userData) {
// ビジネスルールの検証
if (!User.isValidEmail(userData.email)) {
throw new Error('Invalid email address');
}
// 重複チェック(リポジトリを通じて)
const existingUser = await this.userRepository.findByEmail(userData.email);
if (existingUser) {
throw new Error('Email already exists');
}
// ドメインエンティティの作成
const user = new User(
null, // IDはリポジトリが生成
userData.email,
userData.name
);
// リポジトリを通じて保存
return await this.userRepository.save(user);
}
}
module.exports = CreateUserUseCase;4. 依存性の方向
ヘキサゴナルアーキテクチャでは、依存性の方向が重要です。すべての依存関係は外側から内側(ドメインコア)に向かいます。これにより、ドメインロジックが外部の詳細から完全に独立します。
依存性の方向:
アダプター(外側)
↓
ポート(インターフェース)
↑
ドメインコア(内側)重要な原則:
1. ドメインコアは外部に依存しない:
- ドメインコアはポート(インターフェース)のみに依存
- アダプターの実装に依存しない
- フレームワークやライブラリに依存しない
2. アダプターはドメインコアに依存する:
- アダプターはポートを実装または使用
- ドメインエンティティやユースケースを使用
- ドメインのインターフェースに従う
3. 依存性逆転の原則:
- 高レベルのモジュール(ドメイン)が低レベルのモジュール(アダプター)に依存しない
- 両方が抽象(ポート)に依存
メリット:
- ドメインロジックの変更が外部に影響しない
- 外部の変更がドメインロジックに影響しない
- テストが容易(モックアダプターを使用)
- 複数の実装を簡単に切り替え可能
依存性の方向
依存性の方向を示す図です。
依存関係の方向:
ドメインコア(内側)
↓ 依存
ポート(インターフェース)
↑ 実装
アダプター(外側)
例:
- UserService(ポート)← CreateUserUseCase(ドメイン)が依存
- UserRepository(ポート)← CreateUserUseCase(ドメイン)が依存
- UserController(アダプター)→ UserService(ポート)を実装
- DatabaseUserRepository(アダプター)→ UserRepository(ポート)を実装5. 実践的な実装例
Node.jsを使用したヘキサゴナルアーキテクチャの実装例を示します。ディレクトリ構造と依存性注入の方法を説明します。
実践的な実装例として、Node.jsを使用したヘキサゴナルアーキテクチャを示します。ディレクトリ構造と依存性注入の方法を理解することで、実際のプロジェクトに適用できます。
ディレクトリ構造
ヘキサゴナルアーキテクチャでは、ドメインコアとアダプターを明確に分離したディレクトリ構造が推奨されます。
推奨される構造:
src/
├── domain/ # ドメインコア(内側)
│ ├── entities/ # エンティティ
│ │ └── User.js
│ ├── valueObjects/ # 値オブジェクト
│ │ └── Email.js
│ ├── ports/ # ポート(インターフェース)
│ │ ├── input/ # 入力ポート
│ │ │ └── UserService.js
│ │ └── output/ # 出力ポート
│ │ └── UserRepository.js
│ └── services/ # ユースケース
│ └── CreateUserUseCase.js
├── adapters/ # アダプター(外側)
│ ├── input/ # 入力アダプター
│ │ └── web/ # Webアダプター
│ │ └── UserController.js
│ │ └── cli/ # CLIアダプター
│ │ └── UserCLI.js
│ └── output/ # 出力アダプター
│ ├── persistence/ # データベースアダプター
│ │ └── DatabaseUserRepository.js
│ └── external/ # 外部サービスアダプター
│ └── EmailServiceAdapter.js
└── infrastructure/ # インフラストラクチャ
├── database/
└── config/構造の利点:
- ドメインとアダプターが明確に分離される
- 新しいアダプターを追加しやすい
- 依存関係が明確になる
依存性の注入
依存性注入により、アダプターをドメインコアに接続します。
実装方法:
1. アプリケーションの起動時に依存関係を解決:
- アダプターのインスタンスを作成
- ドメインサービスに注入
- アダプターをドメインに接続
2. DIコンテナの使用(オプション):
- InversifyJS、AwilixなどのDIコンテナを使用
- 依存関係を自動的に解決
- 設定を一元管理
実装例:
- アプリケーションのエントリーポイントで依存関係を構築
- 各アダプターを初期化
- ドメインサービスにアダプターを注入
出力ポート
出力ポートの定義例です。インターフェースのみを定義します。
// 出力ポート: domain/ports/output/UserRepository.js
class UserRepository {
async save(user) {
throw new Error('Not implemented');
}
async findById(id) {
throw new Error('Not implemented');
}
async findByEmail(email) {
throw new Error('Not implemented');
}
}
module.exports = UserRepository;データベースアダプター
データベースアダプターの実装例です。出力ポートを実装します。
// 出力アダプター: adapters/output/persistence/DatabaseUserRepository.js
const UserRepository = require('../../../domain/ports/output/UserRepository');
const User = require('../../../domain/entities/User');
class DatabaseUserRepository extends UserRepository {
constructor(db) {
super();
this.db = db;
}
async save(user) {
const result = await this.db.query(
'INSERT INTO users (email, name) VALUES (?, ?)',
[user.email, user.name]
);
return new User(result.insertId, user.email, user.name);
}
async findById(id) {
const rows = await this.db.query('SELECT * FROM users WHERE id = ?', [id]);
if (rows.length === 0) return null;
const row = rows[0];
return new User(row.id, row.email, row.name);
}
async findByEmail(email) {
const rows = await this.db.query('SELECT * FROM users WHERE email = ?', [email]);
if (rows.length === 0) return null;
const row = rows[0];
return new User(row.id, row.email, row.name);
}
}
module.exports = DatabaseUserRepository;Webアダプター
Webアダプター(コントローラー)の実装例です。ユースケースを呼び出します。
// 入力アダプター: adapters/input/web/UserController.js
const CreateUserUseCase = require('../../../domain/services/CreateUserUseCase');
class UserController {
constructor(userRepository) {
// ユースケースにリポジトリを注入
this.createUserUseCase = new CreateUserUseCase(userRepository);
}
async createUser(req, res) {
try {
const userData = req.body;
// ユースケースを実行
const user = await this.createUserUseCase.execute(userData);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
}
module.exports = UserController;アプリケーションの起動
アプリケーションの起動時に依存関係を構築する例です。
// アプリケーションの起動: app.js
const express = require('express');
const DatabaseUserRepository = require('./adapters/output/persistence/DatabaseUserRepository');
const UserController = require('./adapters/input/web/UserController');
const db = require('./infrastructure/database');
const app = express();
app.use(express.json());
// アダプターの作成
const userRepository = new DatabaseUserRepository(db);
const userController = new UserController(userRepository);
// ルーティング
app.post('/users', (req, res) => userController.createUser(req, res));
app.listen(3000, () => {
console.log('Server running on port 3000');
});6. テストの容易性
ヘキサゴナルアーキテクチャの大きなメリットの1つは、テストの容易性です。ポートがインターフェースとして定義されているため、モックアダプターを簡単に作成でき、ドメインロジックを独立してテストできます。
テストの種類:
1. ドメインロジックの単体テスト:
- モックリポジトリを使用
- データベース不要
- 高速で実行可能
2. ユースケースのテスト:
- モックリポジトリを注入
- ビジネスロジックを検証
- エッジケースをテスト
3. アダプターのテスト:
- モックユースケースを使用
- HTTPリクエスト/レスポンスの処理をテスト
- データベースアダプターは統合テストで検証
テストの利点:
- ドメインロジックを外部に依存せずにテスト可能
- テストの実行速度が速い
- テストの安定性が高い(外部依存がない)
- テストの作成が容易
ユースケースのテスト
モックリポジトリを使ったユースケースのテスト例です。データベース不要で高速にテストできます。
// モックリポジトリ: テスト用のアダプター
class MockUserRepository {
constructor() {
this.users = [];
}
async save(user) {
const newUser = { ...user, id: this.users.length + 1 };
this.users.push(newUser);
return newUser;
}
async findById(id) {
return this.users.find(u => u.id === id) || null;
}
async findByEmail(email) {
return this.users.find(u => u.email === email) || null;
}
}
// ユースケースのテスト
const CreateUserUseCase = require('../domain/services/CreateUserUseCase');
const User = require('../domain/entities/User');
describe('CreateUserUseCase', () => {
let useCase;
let mockRepository;
beforeEach(() => {
mockRepository = new MockUserRepository();
useCase = new CreateUserUseCase(mockRepository);
});
it('should create user with valid data', async () => {
const userData = {
email: 'test@example.com',
name: 'Test User'
};
const user = await useCase.execute(userData);
expect(user.email).toBe('test@example.com');
expect(user.name).toBe('Test User');
expect(user.id).toBeDefined();
});
it('should throw error if email already exists', async () => {
const userData = {
email: 'test@example.com',
name: 'Test User'
};
await useCase.execute(userData);
await expect(useCase.execute(userData)).rejects.toThrow('Email already exists');
});
it('should throw error if email is invalid', async () => {
const userData = {
email: 'invalid-email',
name: 'Test User'
};
await expect(useCase.execute(userData)).rejects.toThrow('Invalid email address');
});
});7. 複数のアダプター
ヘキサゴナルアーキテクチャの大きな利点の1つは、同じポートに対して複数のアダプターを実装できることです。これにより、異なるUIやデータソースに対応でき、柔軟性と拡張性が向上します。
複数の入力アダプター:
1. Webアダプター:
- REST API、GraphQL API
- HTTPリクエストを処理
- ブラウザやモバイルアプリからアクセス
2. CLIアダプター:
- コマンドラインインターフェース
- バッチ処理や管理タスク
- スクリプトからアクセス
3. メッセージアダプター:
- メッセージキューからのイベント処理
- 非同期処理
- マイクロサービス間通信
複数の出力アダプター:
1. データベースアダプター:
- MySQL、PostgreSQL、MongoDBなど
- データの永続化
2. メモリアダプター:
- テスト用
- 開発時のプロトタイピング
3. 外部APIアダプター:
- サードパーティサービスとの連携
- メール送信、決済処理など
メリット:
- 同じドメインロジックを複数のインターフェースで利用可能
- 新しいUIやデータソースを追加しやすい
- テスト時にモックアダプターを使用可能
- 段階的な移行が可能
Webアダプター
Webアダプターの例です。HTTPリクエストを処理します。
// Webアダプター: adapters/input/web/UserController.js
const CreateUserUseCase = require('../../../domain/services/CreateUserUseCase');
class UserController {
constructor(userRepository) {
this.createUserUseCase = new CreateUserUseCase(userRepository);
}
async createUser(req, res) {
try {
const user = await this.createUserUseCase.execute(req.body);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
}
module.exports = UserController;CLIアダプター
CLIアダプターの例です。コマンドラインから同じユースケースを使用します。
// CLIアダプター: adapters/input/cli/UserCLI.js
const CreateUserUseCase = require('../../../domain/services/CreateUserUseCase');
class UserCLI {
constructor(userRepository) {
this.createUserUseCase = new CreateUserUseCase(userRepository);
}
async createUser(email, name) {
try {
const user = await this.createUserUseCase.execute({ email, name });
console.log(`User created: ${user.id} - ${user.email}`);
return user;
} catch (error) {
console.error(`Error: ${error.message}`);
throw error;
}
}
}
module.exports = UserCLI;メモリアダプター
メモリアダプターの例です。テストやプロトタイピングに使用できます。
// メモリアダプター: adapters/output/memory/MemoryUserRepository.js
const UserRepository = require('../../../domain/ports/output/UserRepository');
const User = require('../../../domain/entities/User');
class MemoryUserRepository extends UserRepository {
constructor() {
super();
this.users = new Map();
}
async save(user) {
const id = this.users.size + 1;
const newUser = new User(id, user.email, user.name);
this.users.set(id, newUser);
return newUser;
}
async findById(id) {
return this.users.get(id) || null;
}
async findByEmail(email) {
for (const user of this.users.values()) {
if (user.email === email) {
return user;
}
}
return null;
}
}
module.exports = MemoryUserRepository;8. イベント駆動との組み合わせ
ヘキサゴナルアーキテクチャは、イベント駆動アーキテクチャと組み合わせることで、より強力で柔軟なシステムを構築できます。イベントをアダプターとして実装することで、非同期処理と疎結合を実現できます。
組み合わせの方法:
1. イベントアダプター:
- メッセージキューからのイベントを受信
- ドメインイベントを発行
- 非同期処理を実現
2. ドメインイベント:
- ドメインコアでイベントを定義
- ビジネスイベントを表現
- イベントハンドラーをアダプターとして実装
3. イベントハンドラー:
- 出力アダプターとして実装
- ドメインイベントを処理
- 外部システムに通知
メリット:
- 非同期処理によるパフォーマンス向上
- システム間の疎結合
- イベントソーシングとの親和性
- スケーラビリティの向上
ドメインイベント
ドメインイベントの定義例です。
// ドメインイベント: domain/events/UserCreated.js
class UserCreated {
constructor(userId, email) {
this.userId = userId;
this.email = email;
this.occurredAt = new Date();
}
}
module.exports = UserCreated;イベント発行ポート
イベント発行のポート定義です。
// イベント発行ポート: domain/ports/output/EventPublisher.js
class EventPublisher {
async publish(event) {
throw new Error('Not implemented');
}
}
module.exports = EventPublisher;イベントの発行
ユースケースでイベントを発行する例です。
// ユースケースでイベントを発行
const UserCreated = require('../events/UserCreated');
class CreateUserUseCase {
constructor(userRepository, eventPublisher) {
this.userRepository = userRepository;
this.eventPublisher = eventPublisher;
}
async execute(userData) {
// ユーザーを作成
const user = await this.userRepository.save(new User(null, userData.email, userData.name));
// イベントを発行
await this.eventPublisher.publish(new UserCreated(user.id, user.email));
return user;
}
}メッセージキューアダプター
メッセージキューを使ったイベント発行アダプターの例です。
// メッセージキューアダプター: adapters/output/messaging/MessageQueueEventPublisher.js
const EventPublisher = require('../../../domain/ports/output/EventPublisher');
class MessageQueueEventPublisher extends EventPublisher {
constructor(messageQueue) {
super();
this.messageQueue = messageQueue;
}
async publish(event) {
await this.messageQueue.publish(event.constructor.name, event);
}
}
module.exports = MessageQueueEventPublisher;9. クリーンアーキテクチャとの関係
ヘキサゴナルアーキテクチャは、Robert C. Martin(Uncle Bob)が提唱したクリーンアーキテクチャの基盤となっています。両者は同じ原則に基づいており、ドメインロジックの独立性を重視します。
共通の原則:
1. 依存性の方向:
- すべての依存関係は外側から内側に向かう
- ドメインコアは外部に依存しない
- アダプターがドメインに依存する
2. 関心の分離:
- ビジネスロジックと技術的な詳細を分離
- ドメインロジックを純粋に保つ
- フレームワークやライブラリに依存しない
3. テスタビリティ:
- ドメインロジックを独立してテスト可能
- モックアダプターを使用
- 外部依存なしでテスト
クリーンアーキテクチャの層:
1. エンティティ(Entities):
- ビジネスオブジェクト
- 最も内側の層
- ヘキサゴナルのドメインコアに対応
2. ユースケース(Use Cases):
- アプリケーションのロジック
- エンティティを使用
- ヘキサゴナルのユースケースに対応
3. インターフェースアダプター(Interface Adapters):
- データの変換
- コントローラー、プレゼンター、ゲートウェイ
- ヘキサゴナルのアダプターに対応
4. フレームワークとドライバー(Frameworks & Drivers):
- 外部の詳細
- データベース、Webフレームワーク、UI
- ヘキサゴナルのアダプター実装に対応
違い:
- ヘキサゴナル: ポート&アダプターの概念を強調
- クリーン: より詳細な層の定義と依存性のルール
実践:
- 両者は補完的な関係
- ヘキサゴナルはクリーンアーキテクチャの実装方法の1つ
- 同じ目標(ドメインの独立性)を共有
10. ベストプラクティス
ヘキサゴナルアーキテクチャを効果的に実装するためのベストプラクティスをまとめます。
ヘキサゴナルアーキテクチャを成功させるためには、以下のベストプラクティスに従うことが重要です。
1. ドメインコアの純粋性:
- ドメインコアは外部の詳細に一切依存しない
- フレームワークやライブラリを直接使用しない
- 純粋なビジネスロジックのみを含む
- ポート(インターフェース)のみに依存
2. ポートの設計:
- ポートはドメインの観点から設計
- 技術的な詳細を含めない
- 明確で一貫性のあるインターフェース
- 単一責任の原則に従う
3. アダプターの実装:
- アダプターは薄く保つ
- 変換とマッピングのみを担当
- ビジネスロジックを含めない
- エラーハンドリングを適切に実装
4. 依存性の注入:
- コンストラクタインジェクションを推奨
- DIコンテナの活用を検討
- 依存関係を明確にする
- テスト容易性を向上
5. ディレクトリ構造:
- ドメインとアダプターを明確に分離
- ポートを明確に配置
- 一貫性のある命名規則
- 新しいアダプターを追加しやすい構造
6. テスト戦略:
- ドメインロジックを独立してテスト
- モックアダプターを使用
- 統合テストでアダプターを検証
- テストの実行速度を重視
7. エラーハンドリング:
- ドメイン例外を定義
- アダプターで適切に変換
- ユーザーフレンドリーなエラーメッセージ
- エラーの種類に応じた処理
8. ドキュメント:
- ポートの責任を文書化
- アダプターの実装を説明
- 依存関係を図示
- アーキテクチャの決定を記録
9. 段階的な導入:
- 既存のコードに段階的に適用
- 新機能からヘキサゴナルアーキテクチャを適用
- リファクタリングを継続的に実施
- チームの学習曲線を考慮
10. 過剰な抽象化を避ける:
- 必要以上に複雑にしない
- シンプルな解決策を優先
- プロジェクトの規模に応じた設計
- 実用性を重視
まとめ
ヘキサゴナルアーキテクチャは、ドメインロジックを外部の詳細から完全に切り離す強力なパターンです。ポート&アダプターパターンにより、データベースやUIの変更がドメインロジックに影響を与えなくなります。
依存性の方向を適切に管理し、ドメインコアを純粋に保つことで、テスタビリティと保守性が大幅に向上します。実践的なプロジェクトでヘキサゴナルアーキテクチャを適用し、段階的に改善することで、より柔軟で拡張性の高いシステムを構築できます。