Hexagonal Architecture - Mapping

웹, 애플리케이션, 도메인, 영속성 각 계층의 모델을 매핑하는 전략들을 정리하자.

  • 매핑하지 않기
  • 양방향 매핑
  • 완전 매핑
  • 단방향 매핑

매핑하지 않기

포트 인터페이스가 도메인 모델을 입출력 모델로 사용하면, 계층 간 매핑이 필요없다.

하지만, 웹 계층과 영속성 계층의 모델에서는 특별한 요구사항이 있을 수 있다.
웹 계층에서는 JSON 으로 직렬화하기 위한 annotation 을 모델 클래스의 특정 필드에 붙여야할 수 있다.
영속성 계층에서는, DB 매핑을 위해 특정 annotation 이 필요할 수 있다.

또한, Account 클래스는 각 계층과 관련된 이유로 변경되어야해서 단일 책임 원칙을 위반한다.
각 계층이 Account 클래스에 특정 커스텀 필드를 두도록 요구할 수도 있다.

양방향 매핑

각 어뎁터가 전용 모델을 가지고 있다.
그래서, 해당 모델을 도메인 모델로, 도메인 모델을 해당 모델로 매핑할 책임이 있다.
안쪽 계층은 해당 계층의 모델만 알면되고 도메인 로직에만 집중한다.

Read more

Hexagonal Architecture - Persistence Adapter

육각형 아키텍처에서 영속성 어뎁터를 구현해보자.

의존성 역전

다음은 영속성 어뎁터가 애플리케이션 서비스에 영속성 기능을 제공하기 위해, 의존성 역전 원칙을 적용했다.

애플리케이션 서비스에서 영속성 기능을 사용하기 위해, 포트 인터페이스를 호출한다. 이 포트는 영속성 작업을 수행하고 DB 와 통신할 책임을 가진 영속성 어뎁터 클래스에 의해 구현된다.

육각형 아키텍처에서의 영속성 어뎁터는 outgoing 어뎁터이다. 애플리케이션에 의해 호출된 뿐, 호출되지 않기 때문이다.

중요한 것은, 영속성 계층에 대한 의존성을 없애기 위해 간접 계층인 포트를 사용하고 있다는 것이다. 그래서, 영속성 코드를 수정하더라도 코어 코드를 변경하지 않을 수 있다.

영속성 어뎁터의 책임

  1. 입력을 받는다. (포트 인터페이스를 통해)
  2. 입력을 DB 포맷으로 매핑한다. (DB 를 쿼리하거나 변경하는데 사용할 수 있는 포맷으로)
  3. 입력을 DB 에 보낸다.
  4. DB 의 출력을 애플리케이션 포맷으로 매핑한다.
  5. 출력을 반환한다.

포트 인터페이스 나누기

Read more

Hexagonal Architecture - Web Adapter

육각형 아키텍처에서 웹 어뎁터를 구현해보자.

의존성 역전

웹 어뎁터는 incoming adapter 이다. 외부에서 요청을 받아 애플리케이션 코어를 호출해서 무슨 일을 해야하는지 알려준다.

애플리케이션 계층은 웹 어뎁터가 통신할 수 있는 포트를 제공한다. 서비스는 이 포트를 구현하고, 웹 어뎁터는 이 포트를 호출한다.

웹 어뎁터와 유스케이스 사이에 간접 계층 (port) 를 두는 이유는, 애플리케이션 코어가 외부 세계와 통신하는 명세가 포트이기 때문이다. 포트를 적절한 곳에 두면, 외부와 어떤 통신을 하고 있는지 정확히 알 수 있다.

웹 어뎁터의 책임

웹 어뎁터는 다음 일을 한다.

  1. HTTP 요청을 객체로 매핑: HTTP 요청의 컨텐츠와 파라미터를 객체로 직렬화
  2. 권한 검사
  3. 입력 유효성 검증
  4. 입력을 유스케이스 입력 모델로 매핑
  5. 유스케이스 호출
  6. 유스케이스의 출력을 HTTP 로 매핑
  7. HTTP 응답을 반환

컨트롤러 나누기

Read more

Hexagonal Architecture - Usecase

육각형 아키텍처에서 유스케이스를 구현해보자.

Domain Model

육각형 아키텍처는 도메인 모델의 중심 아키텍처이다.
도메인 엔티티를 먼저 만들자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.buckpal.domain

data class Account(
private val id: AccountId,
private val baselineBalance: Money,
private val activityWindow: ActivityWindow
) {

fun calculateBalance(): Money =
Money.add(
this.baselineBalance,
this.activityWindow.calculateBalance(id)
)

fun withdraw(money: Money, targetAccountId: AccountId): Boolean {
if (!mayWithdraw(money)) {
return false
}

val withdrawal = Activity(
id,
id,
targetAccountId,
LocalDateTime.now(),
money
)
activityWindow.addActivity(withdrawal)

return true
}

private fun mayWithdraw(money: Money): Boolean =
Money.add(
calculateBalance(),
money.negate()
)
.isPositiveOrZero()

fun deposit(money: Money, sourceAccountId: AccountId): Boolean {
val deposit = Activity(
id,
sourceAccountId,
id,
LocalDateTime.now(),
money
)
activityWindow.addActivity(deposit)

return true
}
}

Usecase

이제 입금, 출금을 할 수 있는 Account 엔티티가 있으므로, 바깥 방향으로 나가자.
일반적으로 유스케이스는,

  1. 입력을 받음 (from Incoming Adapter)
  2. 비즈니스 규칙을 검증
  3. 모델 상태를 조작
  4. 출력을 반환

유스케이스는 일반적으로 비즈니스 규칙을 충족하면,
도메인 객체의 상태를 바꾼 뒤에 영속성 어뎁터를 통해 구현된 포트로 상태를 전달해서 저장될 수 있게 한다.
또한, 다른 outgoing 어뎁터를 호출할 수도 있다.

마지막으로 outgoing 어뎁터에서 온 출력 값을, 유스케이스를 호출한 어뎁터로 반환할 출력 객체로 변환해서 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.buckpal.application.service

class SendMoneyService(
private val loadAccountPort: LoadAccountPort
private val accountLock: AccountLock
private val updateAccountStatePort: UpdateAccountStatePort
) : SendMoneyUseCase {

fun sendMoney(command: SendMoneyCommand): Boolean {
// TODO: 비즈니스 규칙 검증
// TODO: 모델 상태 조작
// TODO: 출력 값 반환
}
}
Read more

Hexagonal Architecture

육각형 아키텍처의 패키지 구조를 정리한다.
본인의 계좌에서 다른 계좌로 송금을 하는 “송금하기” usecase 를 예로 들어보자.

계층으로 구성한 패키지 구조

우선, 계층으로 코드를 구성해보자.

  • buckpal
    • domain에
      • Account
      • Activity
      • AccountRepository
      • AccountService
    • persistence
      • AccountRepositoryImpl
    • web
      • AccountController

웹, 도메인, 영속성 계층에 대해 전용 패키지를 구성했다.
그리고, 의존성 역전 원칙을 이용해서 의존성이 domain package 에 있는 도메인 코드만을 향한다.
domain package 에 AccountRepository 를, persistence package 에 AccountRepositoryImpl 를 두어서 의존성을 역전시켰다.

계층으로 구성한 패키지 구조의 문제

첫 번째로, application 의 기능 조각을 구분 짓는 package 경계가 없다.
사용자를 관리하는 기능이 추가되면, 서로 연관되지 않은 기능들이 부수효과를 일으킬 수 있다.

  • buckpal
    • domain
      • Account
      • Activity
      • AccountRepository
      • AccountService
      • UserService <—- HERE
      • UserRepository <—- HERE
      • User <—- HERE
    • persistence
      • AccountRepositoryImpl
      • UserRepositoryImpl <—- HERE
    • web
      • AccountController
      • UserController <—- HERE

두 번째로, 애플리케이션이 어떤 usecase 를 제공하는지 파악할 수 없다.
AccountService, AccountController 가 어떤 usecase 를 구현하는지 파악할 수 없다.

세 번째로, 패키지 구조를 통해서 아키텍처를 파악할 수 없다.

Read more

의존성 역전

계층형 아키텍처의 대안에 대해 정리한다.

단일 책임 원칙

단일 책임 원칙이란, 컴포넌트를 변경하는 이유는 오직 하나 뿐이어야한다는 것이다.

변경할 이유는, 컴포넌트 간 의존성을 통해 쉽게 전파된다.
컴포넌트 A 는 다른 컴포넌트 들에 의존하고, 컴포넌트 B 는 의존하는 것이 전혀없다.
컴포넌트 B 를 변경할 유일한 이유는 새로운 요구 사항에 의해 B 의 기능을 바꿔야할 때 뿐이다.
컴포넌트 A 는 다른 컴포넌트가 바뀌면 같이 바뀐다.
하나의 컴포넌트를 바꾸는 것이, 다른 컴포넌트의 실패 원인이 될 수 있다.

의존성 역전 원칙

계층형 아키텍처에서 계층 간 의존성은 항상 다음 계층인 아래 방향을 가리킨다.
그래서, 영속성 계층을 변경할 때마다, 도메인 계층도 변경해야한다.
이 의존성을 제거해보자.

  1. 도메인 코드는 엔티티의 상태를 변경하는 일을 중심으로 하기 때문에 엔티티를 도메인 계층으로 올리자.
  2. 그런데 이렇게 하니, 영속성 계층의 repository 가 도메인 계층의 엔티티에 의존하게 되어 circular dependency 가 발생한다.
  3. 이 부분에 DIP 를 적용하자. 도메인 계층에 repository interface 를 두는 것이다.

클린 아키텍처

클린 아키텍처에서는, 비즈니스 규칙의 테스트를 쉽게 하고, 비즈니스 규칙이 프레임워크, DB, UI, 그 밖의 외부 애플리케이션이나 인터페이스로부터 독립적이다.
이것은, 도메인 코드가 바깥으로 향하는 의존성이 없어야 가능하다.
대신, 의존성 역전 원칙으로 모든 의존성이 도메인 코드를 향한다.

Read more

계층형 아키텍쳐의 문제

위 그림은 일반적인 3 계층 아키텍처를 표현한다.

  1. 웹 계층에서 요청을 받아 도메인 or 비즈니스 계층에 있는 서비스로 요청을 보낸다.
  2. 서비스에서는 비즈니스 로직을 수행하고, 도메인 엔티티의 현재 상태를 조회하거나 변경하기 위해 영속성 계층의 컴포넌트를 호출한다.

이 계층형 아키텍처의 문제가 무엇인지 정리한다.

데이터베이스 주도 설계 유도

계층형 아키텍처의 토대는 데이터베이스이다.
웹 계층은 도메인 계층에, 도메인 계층은 영속성 계층에 의존하기 때문이다.
계층형 아키텍처에서는 데이터베이스의 구조를 먼저 생각하고, 이를 토대로 도메인 로직을 구현한다.

ORM 프레임워크를 계층형 아키텍처와 결합하면, 영속성 계층과 도메인 계층 간 강한 결합이 생긴다.
위와 같이, ORM 에 의해 관리되는 엔티티들은 영속성 계층에 둔다.
계층은 아래 방향으로만 접근 가능해서, 도메인 계층에서는 엔티티에 접근 가능하고 엔티티를 사용한다.
서비스는 영속성 모델을 비즈니스 모델처럼 사용하게 되는 것이다.

지름길을 택하기 쉬움

Read more