ヘキサゴナル アーキテクチャ: ポートとアダプター
L'六角形の建築、としても知られています ポートとアダプター、 によって策定されました アリスター・コックバーン 2000年代初頭には1つの目標を掲げて クリア: アプリケーションのコアを周囲のインフラストラクチャから分離します。このパターン アーキテクチャにより、ビジネス ロジックを完全に分離して開発およびテストできます。 データベース、フレームワーク、外部 API、その他の技術的な詳細から。
この記事では、六角形アーキテクチャの基本概念を詳しく説明します。 それを実装する方法を見ていきます TypeScript e 角度のある、そして クリーン アーキテクチャとオニオン アーキテクチャを比較して、いつそれを優先するかを理解します。
何を学ぶか
- 基本概念: 六角形、ポート、アダプター
- プライマリ (駆動) ポートとセカンダリ (駆動) ポートの違い
- 依存関係インジェクションを使用した TypeScript での実用的な実装
- Clean ArchitectureとOnion Architectureとの詳細な比較
- 完全な例: 通知システム
- テストと保守性における利点
ヘキサゴン: 建築の中心
六角形が表すのは、 アプリケーションドメインつまり、すべてのビジネス ロジック 純粋で、外部依存性がありません。六角形は純粋に比喩的なものであり、便利です。 アプリケーションが持つアイデアを伝えるため たくさんの顔 それを通じて彼は通信します 従来のレイヤー モデルの 2 つの古典的なもの (UI とデータベース) だけではなく、外部の世界。
六角形の中に次のものがあります。
- ドメインエンティティ: ビジネスの基本概念を表すオブジェクト
- ドメインサービス: エンティティを操作し、ビジネス ルールを実装するロジック
- ポート: ドメインが外部と通信する方法を定義するインターフェース
ポート (ポート): ドメインのインターフェース
Le ドア これらは、コントラクトを確立するドメイン内で定義されたインターフェイスです。 それらは 2 つの基本的なカテゴリに分類されます。
-
プライマリポート (駆動ポート): これらはアプリケーションの使用例を定義します。
それらは外の世界が通過するためのインターフェースです。 ガイド アプリケーション。例:
インターフェース
OrderServiceREST API または CLI に公開されます。 -
セカンダリ ポート (駆動ポート): アプリケーションの依存関係を定義します
外側に。これらはドメインが接続するインターフェイスです。 アメリカ合衆国 しかし、それは実装されません。例:
インターフェース
OrderRepository持続性のため、またはNotificationSender通知を送信するため。
アダプター: 具体的な実装
Gli アダプター これらはポートの具体的な実装です。彼らは通話を翻訳します 外部世界のフォーマットとドメインのフォーマットの間:
- プライマリアダプター (駆動アダプター): 外部から入力を受け取り、呼び出します。 主要なドア。例: REST コントローラー、GraphQL ハンドラー、メッセージ コンシューマー、CLI インターフェイス。
- セカンダリ アダプター (駆動アダプター): セカンダリ ポートを実装します。 例: SQL リポジトリ、外部 API への HTTP クライアント、電子メール サービス アダプタ、ファイル システム アダプタ。
TypeScriptでの実装
TypeScript でヘキサゴナル アーキテクチャを実装する方法を定義から見てみましょう ポートの数からアダプターの構成まで。
ドメインエンティティの定義
export type NotificationChannel = 'email' | 'sms' | 'push';
export type NotificationStatus = 'pending' | 'sent' | 'failed';
export interface Notification {
readonly id: string;
readonly recipientId: string;
readonly channel: NotificationChannel;
readonly subject: string;
readonly body: string;
readonly status: NotificationStatus;
readonly createdAt: Date;
readonly sentAt?: Date;
}
export interface NotificationRequest {
recipientId: string;
channel: NotificationChannel;
subject: string;
body: string;
}
ドアの定義
// Porta Primaria: definisce i casi d'uso
export interface NotificationUseCases {
send(request: NotificationRequest): Promise<Notification>;
getById(id: string): Promise<Notification | null>;
getByRecipient(recipientId: string): Promise<Notification[]>;
retry(id: string): Promise<Notification>;
}
// Porta Secondaria: il dominio dipende da questa interfaccia
export interface NotificationRepository {
save(notification: Notification): Promise<Notification>;
findById(id: string): Promise<Notification | null>;
findByRecipientId(recipientId: string): Promise<Notification[]>;
update(notification: Notification): Promise<Notification>;
}
// Porta Secondaria: astrae l'invio effettivo
export interface NotificationSender {
send(
channel: NotificationChannel,
recipient: string,
subject: string,
body: string
): Promise<boolean>;
}
ドメイン サービス: ロジックの中心
import { v4 as uuidv4 } from 'uuid';
export class NotificationDomainService implements NotificationUseCases {
constructor(
private readonly repository: NotificationRepository,
private readonly sender: NotificationSender,
private readonly recipientResolver: RecipientResolver
) {}
async send(request: NotificationRequest): Promise<Notification> {
// Logica di dominio pura: nessuna dipendenza da infrastruttura
const recipient = await this.recipientResolver.resolve(request.recipientId);
if (!recipient) {
throw new DomainError('Destinatario non trovato');
}
const notification: Notification = {
id: uuidv4(),
recipientId: request.recipientId,
channel: request.channel,
subject: request.subject,
body: request.body,
status: 'pending',
createdAt: new Date()
};
// Salva come pending
const saved = await this.repository.save(notification);
// Tenta l'invio
try {
const success = await this.sender.send(
request.channel,
recipient.contactInfo[request.channel],
request.subject,
request.body
);
const updated = {
...saved,
status: success ? 'sent' as const : 'failed' as const,
sentAt: success ? new Date() : undefined
};
return this.repository.update(updated);
} catch (error) {
const failed = { ...saved, status: 'failed' as const };
return this.repository.update(failed);
}
}
async getById(id: string): Promise<Notification | null> {
return this.repository.findById(id);
}
async getByRecipient(recipientId: string): Promise<Notification[]> {
return this.repository.findByRecipientId(recipientId);
}
async retry(id: string): Promise<Notification> {
const notification = await this.repository.findById(id);
if (!notification) {
throw new DomainError('Notifica non trovata');
}
if (notification.status !== 'failed') {
throw new DomainError('Solo le notifiche fallite possono essere ritentate');
}
return this.send({
recipientId: notification.recipientId,
channel: notification.channel,
subject: notification.subject,
body: notification.body
});
}
}
二次アダプター: 具体的な実装
// Adattatore Secondario: implementa la porta del repository
export class FirestoreNotificationRepository implements NotificationRepository {
constructor(private readonly firestore: Firestore) {}
async save(notification: Notification): Promise<Notification> {
const docRef = doc(this.firestore, 'notifications', notification.id);
await setDoc(docRef, {
...notification,
createdAt: Timestamp.fromDate(notification.createdAt)
});
return notification;
}
async findById(id: string): Promise<Notification | null> {
const docRef = doc(this.firestore, 'notifications', id);
const snapshot = await getDoc(docRef);
return snapshot.exists() ? this.toDomain(snapshot.data()) : null;
}
async findByRecipientId(recipientId: string): Promise<Notification[]> {
const q = query(
collection(this.firestore, 'notifications'),
where('recipientId', '==', recipientId),
orderBy('createdAt', 'desc')
);
const snapshot = await getDocs(q);
return snapshot.docs.map(d => this.toDomain(d.data()));
}
async update(notification: Notification): Promise<Notification> {
const docRef = doc(this.firestore, 'notifications', notification.id);
await updateDoc(docRef, { ...notification });
return notification;
}
private toDomain(data: any): Notification {
return {
...data,
createdAt: data.createdAt.toDate(),
sentAt: data.sentAt?.toDate()
} as Notification;
}
}
// Adattatore Secondario: gestisce l'invio multicanale
export class MultiChannelNotificationSender implements NotificationSender {
private readonly channels: Map<NotificationChannel, ChannelProvider>;
constructor(
emailProvider: EmailProvider,
smsProvider: SmsProvider,
pushProvider: PushProvider
) {
this.channels = new Map([
['email', emailProvider],
['sms', smsProvider],
['push', pushProvider]
]);
}
async send(
channel: NotificationChannel,
recipient: string,
subject: string,
body: string
): Promise<boolean> {
const provider = this.channels.get(channel);
if (!provider) {
throw new Error('Canale non supportato: ' + channel);
}
return provider.deliver(recipient, subject, body);
}
}
プライマリアダプター: RESTコントローラー
// Adattatore Primario: traduce HTTP in chiamate al dominio
@Controller('/api/notifications')
export class NotificationController {
constructor(
private readonly notificationUseCases: NotificationUseCases
) {}
@Post('/')
async sendNotification(
@Body() dto: SendNotificationDto
): Promise<NotificationResponseDto> {
const notification = await this.notificationUseCases.send({
recipientId: dto.recipientId,
channel: dto.channel,
subject: dto.subject,
body: dto.body
});
return NotificationMapper.toDto(notification);
}
@Get('/:id')
async getNotification(
@Param('id') id: string
): Promise<NotificationResponseDto> {
const notification = await this.notificationUseCases.getById(id);
if (!notification) {
throw new NotFoundException('Notifica non trovata');
}
return NotificationMapper.toDto(notification);
}
@Post('/:id/retry')
async retryNotification(
@Param('id') id: string
): Promise<NotificationResponseDto> {
const notification = await this.notificationUseCases.retry(id);
return NotificationMapper.toDto(notification);
}
}
Angular での依存性注入を使用した構成
// Injection tokens per le porte secondarie
export const NOTIFICATION_REPOSITORY =
new InjectionToken<NotificationRepository>('NotificationRepository');
export const NOTIFICATION_SENDER =
new InjectionToken<NotificationSender>('NotificationSender');
export const RECIPIENT_RESOLVER =
new InjectionToken<RecipientResolver>('RecipientResolver');
// Configurazione providers in app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
// Adattatori secondari: facilmente sostituibili
{
provide: NOTIFICATION_REPOSITORY,
useClass: FirestoreNotificationRepository
},
{
provide: NOTIFICATION_SENDER,
useClass: MultiChannelNotificationSender
},
{
provide: RECIPIENT_RESOLVER,
useClass: FirestoreRecipientResolver
},
// Servizio di dominio composto con le porte
{
provide: NotificationDomainService,
useFactory: (repo, sender, resolver) =>
new NotificationDomainService(repo, sender, resolver),
deps: [
NOTIFICATION_REPOSITORY,
NOTIFICATION_SENDER,
RECIPIENT_RESOLVER
]
}
]
};
比較: 六角形 vs クリーン vs オニオン
六角形のアーキテクチャは基本原理を共有しています。 クリーンなアーキテクチャ ロバート・C・マーティン著 オニオン建築 ジェフリー・パレルモ著。 3つとも それらはドメインを分離することを目的としていますが、ニュアンスは異なります。
比較表
| 待ってます | 六角 | クリーンなアーキテクチャ | オニオン建築 |
|---|---|---|---|
| 著者 | アリスター・コックバーン | ロバート・C・マーティン | ジェフリー・パレルモ |
| 比喩 | ドア付き六角形 | 同心円 | 玉ねぎの層 |
| 集中 | 社内/社外のインタラクション | 依存関係ルール | ドメイン層 |
| インターフェース | ポート (駆動/駆動) | ユースケース + ゲートウェイ | ドメインサービス |
| 実装 | アダプター | インターフェースアダプター + フレームワーク | インフラストラクチャ層 |
| テスト容易性 | 優れています: アダプターを交換してください | 優れた: ゲートウェイ モック | 優れもの: 外側のレイヤーをモック化する |
| 複雑 | 平均 | 高(多層) | 中~高 |
| に最適 | 多くの統合が行われたシステム | エンタープライズアプリケーション | DDD を使用した複雑なドメイン |
主な違いは次のとおりです 視点: 六角形のアーキテクチャ という観点から考える 内側と外側 (ドメインとその周辺)、クリーンなアーキテクチャ という点で 依存関係ルールのある同心円状のレイヤー、オニオン アーキテクチャ を強調します ドメインの階層化 モデル、ドメイン サービス、およびアプリケーション サービスを使用します。
テストにおける利点
六角形アーキテクチャの最も重要な利点は、テストが容易であることです。なぜなら ドメインはインターフェイス (ポート) のみに依存するため、アダプターを次のように置き換えることができます。 ビジネスコードを変更せずに実装をテストします。
describe('NotificationDomainService', () => {
let service: NotificationDomainService;
let mockRepository: jest.Mocked<NotificationRepository>;
let mockSender: jest.Mocked<NotificationSender>;
let mockResolver: jest.Mocked<RecipientResolver>;
beforeEach(() => {
// Adattatori mock: implementano le stesse porte
mockRepository = {
save: jest.fn().mockImplementation(n => Promise.resolve(n)),
findById: jest.fn(),
findByRecipientId: jest.fn(),
update: jest.fn().mockImplementation(n => Promise.resolve(n))
};
mockSender = {
send: jest.fn().mockResolvedValue(true)
};
mockResolver = {
resolve: jest.fn().mockResolvedValue({
id: 'user-1',
contactInfo: { email: 'user@example.com' }
})
};
service = new NotificationDomainService(
mockRepository, mockSender, mockResolver
);
});
it('deve inviare una notifica con successo', async () => {
const request: NotificationRequest = {
recipientId: 'user-1',
channel: 'email',
subject: 'Test',
body: 'Messaggio di test'
};
const result = await service.send(request);
expect(result.status).toBe('sent');
expect(mockSender.send).toHaveBeenCalledWith(
'email', 'user@example.com', 'Test', 'Messaggio di test'
);
expect(mockRepository.save).toHaveBeenCalled();
expect(mockRepository.update).toHaveBeenCalled();
});
it('deve gestire il fallimento dell\'invio', async () => {
mockSender.send.mockResolvedValue(false);
const result = await service.send({
recipientId: 'user-1',
channel: 'email',
subject: 'Test',
body: 'Body'
});
expect(result.status).toBe('failed');
});
it('non deve ritentare notifiche non fallite', async () => {
mockRepository.findById.mockResolvedValue({
id: 'notif-1',
recipientId: 'user-1',
channel: 'email',
subject: 'Test',
body: 'Body',
status: 'sent',
createdAt: new Date()
});
await expect(service.retry('notif-1'))
.rejects.toThrow('Solo le notifiche fallite possono essere ritentate');
});
});
テストが簡単なので
Firestore、SMTP サーバー、その他のサービスが必要ないことに注目してください。 ビジネス ロジックをテストするための外部。各ポートはモックに置き換えられます 同じインターフェースを実装しています。これにより、次のテストが行われます。
- 速い: ネットワークまたは I/O 接続がない
- 決定論的: 外部状態に依存しない
- 孤立: 各テストはドメイン ロジックのみをテストします
- 保守可能: データベースを変更してもテストを変更する必要はありません
ヘキサゴナル アーキテクチャを使用する場合
理想的なシナリオ
- 複数の統合: アプリケーションは多くの外部システム (DB、API、メッセージ ブローカー、電子メール サービス) と通信します。
- 交換可能性が必要: サービス プロバイダーは時間の経過とともに変更される可能性があります (AWS から Azure への移行)
- クリティカルなテスト可能性: ビジネス ロジックは複雑であり、広範なテストが必要です
- 分散チーム: さまざまなチームがさまざまなアダプターを並行して作業します
- プロジェクトの寿命: プロジェクトは何年も続くし、枠組みも変わるかもしれない
いつ避けるべきか
- 単純な CRUD: ビジネス ロジックがほとんどないアプリケーションでは、追加の複雑さが正当化されません
- プロトタイプと MVP: アーキテクチャよりも配信速度が重要
- 小規模で経験の浅いチーム: 学習曲線により開発が遅れる可能性がある
- 最小限のマイクロサービス: 統合が 1 つだけの非常に小規模なサービス
推奨されるフォルダー構成
src/
domain/
entities/
notification.ts
recipient.ts
ports/
primary/
notification-use-cases.port.ts
secondary/
notification-repository.port.ts
notification-sender.port.ts
recipient-resolver.port.ts
services/
notification-domain.service.ts
errors/
domain-error.ts
adapters/
primary/
rest/
notification.controller.ts
dto/
send-notification.dto.ts
graphql/
notification.resolver.ts
secondary/
persistence/
firestore-notification.repository.ts
in-memory-notification.repository.ts
messaging/
multi-channel-notification.sender.ts
email/
sendgrid-email.provider.ts
sms/
twilio-sms.provider.ts
config/
dependency-injection.ts
結論
六角形のアーキテクチャは、優位性とロー性を重視した強力なパターンです。その中心にある インフラストラクチャの依存関係から保護します。ポートとアダプターのメカニズムを通じて、 時間の経過とともに、テスト可能性、柔軟性、保守性の高いアプリケーションが得られます。
覚えておくべき重要なポイントは次のとおりです。
- Le 主要なドア アプリケーションで何ができるかを定義する (ユースケース)
- Le 補助ドア アプリケーションに必要なもの (依存関係) を定義する
- Gli アダプター これらは交換可能であり、ドメインに触れることなく置き換えることができます。
- La 依存関係の注入 それはすべてを繋ぐ仕組みです
- I テスト 自然にシンプルかつ高速になります
覚えておくべき重要なポイント
- 中央のドメイン: ビジネス ロジックがインフラストラクチャに依存することはありません
- 契約としてのドア: インターフェースが境界を定義する
- 交換可能なアダプター: ドメインに触れることなくデータベース、フレームワーク、プロバイダーを変更
- 分離されたテスト: 高速かつ決定論的なテストのためのモックバックポート
- 接着剤としての DI: Angular のトークンインジェクションにより構成が明示的になる







