육각형 아키텍처: 포트 및 어댑터
L'육각형 건축,라고도 함 포트 및 어댑터, 에 의해 공식화되었습니다 알리스테어 콕번 2000년대 초반 하나의 목표를 가지고 명확함: 애플리케이션 코어를 주변 인프라와 분리합니다. 이 패턴 아키텍처를 사용하면 완전히 격리된 상태에서 비즈니스 로직을 개발하고 테스트할 수 있습니다. 데이터베이스, 프레임워크, 외부 API 및 기타 기술 세부 사항에서.
이 글에서는 육각형 건축의 기본 개념을 심도 있게 탐구할 것입니다. 우리는 그것을 구현하는 방법을 볼 것입니다 타입스크립트 e 모난, 그리고 언제 선호하는지 이해하기 위해 Clean Architecture와 Onion Architecture를 비교해보겠습니다.
무엇을 배울 것인가
- 기본 개념: 육각형, 포트 및 어댑터
- 기본(구동) 포트와 보조(구동) 포트의 차이점
- 종속성 주입을 사용한 TypeScript의 실제 구현
- 클린 아키텍처와 어니언 아키텍처와의 상세 비교
- 완전한 예: 알림 시스템
- 테스트 및 유지 관리의 장점
육각형: 건축의 심장
육각형은 다음을 나타냅니다. 애플리케이션 도메인즉, 모든 비즈니스 로직 순수하고 외부 의존성이 없습니다. 육각형 모양은 순전히 은유적입니다. 유용합니다. 응용 프로그램이 갖고 있는 아이디어를 전달하기 위해 많은 얼굴 이를 통해 그는 상대방과 소통한다. 기존 레이어 모델의 두 가지 고전적인 것(UI 및 데이터베이스)뿐만 아니라 외부 세계입니다.
육각형 내부에는 다음이 있습니다.
- 도메인 엔터티: 비즈니스의 기본 개념을 나타내는 객체
- 도메인 서비스: 엔터티에서 작동하고 비즈니스 규칙을 구현하는 논리
- 포트: 도메인이 외부 세계와 통신하는 방법을 정의하는 인터페이스
포트(Ports): 도메인의 인터페이스
Le porte 이는 계약을 설정하는 도메인에 정의된 인터페이스입니다. 이는 두 가지 기본 범주로 나뉩니다.
-
기본 포트(구동 포트): 이는 애플리케이션의 사용 사례를 정의합니다.
외부 세계를 연결하는 인터페이스입니다. 가이드 응용 프로그램. 예:
인터페이스
OrderServiceREST API 또는 CLI에 노출됩니다. -
보조 포트(구동 포트): 애플리케이션 종속성을 정의합니다.
바깥쪽으로. 이는 도메인이 사용하는 인터페이스입니다. 미국 하지만 구현하지 않습니다. 예:
인터페이스
OrderRepository지속성을 위해 또는NotificationSender알림을 보내기 위해.
어댑터: 구체적인 구현
그만큼 어댑터 이는 포트의 구체적인 구현입니다. 그들은 통화를 번역합니다 외부 세계의 형식과 영역의 형식 사이:
- 기본 어댑터(구동 어댑터): 외부로부터 입력을 받아 호출합니다. 기본 문. 예: 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. 마틴(Robert C. Martin)과 양파 아키텍처 제프리 팔레르모. 세 가지 모두 그들은 도메인을 분리하는 것을 목표로 하지만 뉘앙스가 다릅니다.
비교표
| 나는 기다린다 | 육각형 | 클린 아키텍처 | 양파 아키텍처 |
|---|---|---|---|
| 작가 | 알리스테어 콕번 | 로버트 C. 마틴 | 제프리 팔레르모 |
| 은유 | 문이 있는 육각형 | 동심원 | 양파층 |
| 집중하다 | 내부/외부 상호작용 | 종속성 규칙 | 도메인 레이어 |
| 인터페이스 | 포트(구동/구동) | 사용 사례 + 게이트웨이 | 도메인 서비스 |
| 구현 | 어댑터 | 인터페이스 어댑터 + 프레임워크 | 인프라 계층 |
| 테스트 가능성 | 훌륭함: 어댑터 교체 | 우수: 게이트웨이 모의 | 우수: 외부 레이어를 모의합니다. |
| 복잡성 | 평균 | 높음(다중 레이어) | 중간 높음 |
| 다음에 이상적입니다. | 많은 통합이 가능한 시스템 | 엔터프라이즈 애플리케이션 | DDD를 사용한 복잡한 도메인 |
주요 차이점은 관점: 육각형 건축 관점에서 생각하다 내부와 외부 (도메인과 주변), Clean Architecture 측면에서 종속성 규칙이 있는 동심 레이어및 양파 아키텍처 강조한다 도메인 계층화 모델, 도메인 서비스 및 애플리케이션 서비스를 제공합니다.
테스트의 장점
육각형 아키텍처의 가장 큰 장점은 테스트 용이성입니다. 왜냐하면 도메인은 인터페이스(포트)에만 의존하므로 어댑터를 다음으로 대체할 수 있습니다. 비즈니스 코드를 변경하지 않고 구현을 테스트합니다.
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: 아키텍처보다 전달 속도가 더 중요
- 소규모이고 경험이 부족한 팀: 학습 곡선으로 인해 개발 속도가 느려질 수 있습니다.
- 최소한의 마이크로서비스: 단 하나의 통합으로 이루어진 매우 작은 서비스
권장 폴더 구조
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 보조 문 애플리케이션에 필요한 것(종속성) 정의
- 그만큼 어댑터 상호 교환이 가능하며 도메인을 건드리지 않고도 교체할 수 있습니다.
- La 의존성 주입 모든 것을 연결하는 메커니즘이다
- I 시험 자연스럽게 단순해지고 빨라지죠
기억해야 할 핵심 사항
- 중앙의 도메인: 비즈니스 로직은 인프라에 의존하지 않습니다.
- 계약으로서의 문: 인터페이스는 경계를 정의합니다
- 교체 가능한 어댑터: 도메인을 건드리지 않고 데이터베이스, 프레임워크 또는 공급자 변경
- 격리된 테스트: 빠르고 결정적인 테스트를 위한 모의 백포트
- 접착제로서의 DI: Angular의 토큰 주입은 구성을 명시적으로 만듭니다.







