웹 계층에서는 요청을 받아 도메인 혹은 비즈니스 계층에 있는 서비스로 요청을 보낸다. 서비스에서는 필요한 비즈니스 로직을 수행하고, 도메인 엔티티의 현재 상태를 조회하거나 변경하기 위해 영속성 계층의 컴포넌트를 호출 한다.
데이터베이스 구조 생각 후 → 도메인 로직 구현
데이터베이스 중심적인 아키텍처가 만들어지는 가장 큰 원인은 ORM
영속성 코드가 도메인 코드에 녹아들어가서 둘 중 하나만 바꾸는 것이 어려워진다.
계층형 아키텍처는 사실상 강제하는 규칙이 없기에 각 계층의 침범이 일어 날수 있고 사실상 침범할 것이고 지름길을 택할수록 우리는 "덜" Objective 한 코드를 생산하고 있을 것이다.
영속성 계층의 엔티티 하나의 필드를 조작하는 것에 불과해도
도메인 로직을 웹계층에 구현 할 것이다.
웹 계층 테스트에서 도메인 계층 뿐만아니라 영속성 계층도 모킹(mocking)해야 한다.
계층형 아키텍처는 도메인 서비스의 '너비'에 관한 규칙을 강제하지 않는다.
넓은 서비스는 영속성 계층에 많은 의존성을 갖게 된다.
테스트하기 어려워지고, 책임지는 서비스를 찾기도 어려워진다.
하나의 컴포넌트는 오로지 한 가지 일만 해야 하고, 그것을 올바르게 수행해야 한다.
= 단일 변경 이유 원칙
컴포넌트(클래스)를 변경해야 하는 이유는 오직 하나뿐이어야 한다.
- 로버트 C. 마틴 -
만약 컴포넌트를 변경할 이유가 한 가지라면 우리가 어떤 다른 이유로 소프트웨어를 변경하더라도 이 컴포넌트에 대해서는 전혀 신경 쓸 필요가 없다. 소프트웨어가 변경 되더라도 여전히 우리가 기대한 대로 동작할 것이기 때문이다.
의존성을 어떻게 제거할까?
코드상의 어떤 의존성이든 그 방향을 바꿀 수(역전시킬 수) 있다.
도메인 계층에 리포지토리에 대한 이넡페이스를 만들고, 실제 리포지토리는 영속성 계층에서 구현하게 하는 것이다.
클린 아키텍처의 주요한 규칙: 계층 간의 모든 의존성이 안쪽으로 향해야 한다
도메인 계층이 영속성이나 UI같은 외부 계층과 철저하게 분리돼야 하므로 얘플리케이션의 엔티티에 대한 모델을 각계층에서 유지보수해야 한다.
코드를 보는 것만으로 어떤 아키텍처인지 파악할 수 있다면?
buckpal
|--- domain
| |--- Acount
| |--- Activity
| |--- AccountRepository
| |--- AccountService
|
|--- persistence
| |--- AccountRepositoryImpl
|
|--- web
|--- AccountController
- 계층으로 코드를 구성하면 기능적인 측면들이 섞이기 쉽다.
계층형이 최적의 구조가 아닌 이유
1. 애플리케이션의 기능조각이나 특성을 구분 짓는 패키지 경계가 없다.
2. 애플리케이션이 어떤 유스케이스들을 제공하는지 파악할 수 없다.
3. 패키지 구조를 통해서는 우리가 목표로 하는 아키텍처를 파악할 수 없다.
buckpal
|--- account
|--- Acount
|--- AccountController
|--- AccountRepositoryImpl
|--- AccountRepository
|--- SendMoneyService
- 기능을 기준으로 코드를 구성하면 기반 아키텍처가 명확하지 않다.
기능에 의한 패키징방식의 문제
1. 가시성을 훨씬 더 떨어뜨린다.
buckpal
|--- account
|--- adapter
| |--- in
| | |--- web
| | |--- AccountController
| |--- out
| |--- persistence
| |--- AcountPersistenceAdapter
| |--- SpringDataAccountRepository
|
|--- domain
| |--- Acount
| |--- Activity
|
|--- application
|--- SendMoneyService
|
|--- port
|--- in
| |--- SendMoneyUseCase
|
|--- out
|--- LoadAccountPort
|--- UpdateAccountStatePort
결국에는 기술적으로 '헥사고날 아키텍처'를 알아야 보이는 구조이다.
아키텍처 크드 갭 혹은 모델 코드 갭을 효과적으로 다룰 수 있다.
package buckpal.domain
@AllArgsConstructor
public class Account {
private AccountId id;
private Money baselineBalance; // activityWindow의 첫번째 활동 바로 전의 잔고
private ActivityWindow activityWindow; // 최근 모든 입출금 활동
public boolean withdraw(Money money, AccountId targetAccountId) {
if (!mayWithdraw(money)) return false;
Activity withdrawal = new Activity(id, id, targetAccountId, now(), money);
activityWindow.addActivity(withdrawal); // 새로운 활동을 활동창에 추가
return true;
}
public boolean deposit(Money money, AccountId sourceAccountId) {
Activity deposit = new Activity(id, sourceAccountId, id, now(), money);
activityWindow.addActivity(deposit);
return true;
}
public Money calculateBalance() {
// 현재 잔고 = 기준 잔고 + 최근 활동의 입출금 내역
return Money.add(
baselineBalance,
activityWindow.calculateBalance(id)
);
}
}
1. 입력을 받는다
2. 비즈니스 규칙을 검증한다
3. 모델상태를 조작한다
4. 출력을 반환한다.
유스케이스는 인커밍 어뎁터로부터 입력을 받는다.
유스케이스 코드는 도메인 로직에만 신경 써야한다.
비즈니스 규칙 을 검증할 책임이 있다.
모델의 상태 변경
영속성 어댑터를 통해 포트로 이 상태를 전달
@RequiredArgsConstructor
@Transactional
public class SendMoneyService implements SendMoneyUseCase {
private final LoadAccountPort loadAccountPort;
private final AccountLock accountLock;
private final UpdateAccountStatePort updateAccountStatePort;
@Override
public boolean sendMoney(SendMoneyCommand command) {
//TODO: 비즈니스 규칙 검증
//TODO: 모델 상태 조작
//TODO: 출력 값 반환
}
}
입력모델이 이문제를 다루도록 해보자
SendMoneyCommand라고 하는 DTO에서 검증 할거고
생성자 내에서 입력 유효성을 검증할 것이다.
출금 계좌, 입금 계좌 ID, 송금할 금액
모든 파라미터는 null X
송금할 금액은 0보다 커야한다.
Bean Validation API