영속성 어댑터 구현하기

의존성 역전

코어의 서비스가 영속성 어댑터에 접근하기 위해 포트를 사용한다. 애플리케이션에 의해 호출될 뿐, 애플리케이션을 호출하지 않는다. 여기서 포트는 사실상 애플리케이션 서비스와 영속성 코드 사이의 간접적인 계증이다. 자연스럽게 런타임에도 의존성을 애플리케이션 코어에서 영속성 어댑터로 향한다.

영속성 어댑터의 책임

  1. 입력을 받는다.

  2. 입력을 데이터베이스 포맷으로 매핑한다.

  3. 입력을 데이터베이스로 보낸다.

  4. 데이터베이스 출력을 애플리케이션 포맷으로 매핑한다.

  5. 출력을 반환한다.

핵심은 영속성 어댑터의 입력 모델이 영속성 어댑터 내부에 있는 것이 아니라 애플리케이션 코어에 있기 떄문에 영속성 어댑터 내부를 변경하는 것이 코어에 영향을 미치지 않는다는 것이다.

포트 인터페이스 나누기

하나의 아웃고잉 포트 인터페이스에 모든 데이터베이스 연산을 모아두면 모든 서비스가 실제로는 필요하지 않은 메서드에 의존하게 된다. 인터페이스 분리 원칙(Interface Segregation Principle, ISP)은 이 문제의 답을 제시한다. 이 원칙은 클라이언트가 오로지 자신이 필요로 하는 메서드만 알면 되도록 넓은 인터페이스를 특화된 인터페이스로 분리해야 한다고 설명한다. 인터페이스 분리 원칙을 적용하면 불필요한 의존성을 제거하고 기존 의존성을 눈에 더 잘 띄게 만들 수 있다.

영속성 어댑터 나누기

하나의 애그리거트당 하나의 영속성 어댑터를 만들어서 여러 개의 영속성 어댑터를 만들수 수도 있다. 도메인 코드는 영속성 포트에 의해 정의된 명시를 어떤 클래스가 충족시키는지에 관심이 없다. 모든 포트가 구현돼 있기만 한다면 영속성 계층에서 하고 싶은 어떤 작업이든 해도 된다. 애그리거트당 하나의 영속성 어댑터 접근 방식 또한 나중에 여러 개의 바운디드 컨텍스트의 영속성 요구사항을 분리하기 위한 좋은 토대가 된다. 바운디드 컨텍스트 간의 경계를 명확하게 구분하고 싶다면 각 바운디드 컨텍스트가 영속성 어댑터(들)을 하나씩 가지고 있어야 한다.

스프링 데이터 JPA 예제

package com.book.cleanarchitecture.buckpal.account.adapter.out.persistence;

import javax.persistence.*;

@Entity
@Table(name = "accounts")
class AccountJpaEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    protected AccountJpaEntity() {
    }

    public Long getId() {
        return id;
    }
}
package com.book.cleanarchitecture.buckpal.account.adapter.out.persistence;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "activities")
public class ActivityJpaEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private LocalDateTime timestamp;

    @Column
    private Long ownerAccountId;

    @Column
    private Long sourceAccountId;

    @Column
    private Long targetAccountId;

    @Column
    private Long amount;

    protected ActivityJpaEntity() {
    }

    public ActivityJpaEntity(Long id, LocalDateTime timestamp, Long ownerAccountId, Long sourceAccountId, Long targetAccountId, Long amount) {
        this.id = id;
        this.timestamp = timestamp;
        this.ownerAccountId = ownerAccountId;
        this.sourceAccountId = sourceAccountId;
        this.targetAccountId = targetAccountId;
        this.amount = amount;
    }

    public Long getId() {
        return id;
    }

    public LocalDateTime getTimestamp() {
        return timestamp;
    }

    public Long getOwnerAccountId() {
        return ownerAccountId;
    }

    public Long getSourceAccountId() {
        return sourceAccountId;
    }

    public Long getTargetAccountId() {
        return targetAccountId;
    }

    public Long getAmount() {
        return amount;
    }
}
package com.book.cleanarchitecture.buckpal.account.adapter.out.persistence;

import org.springframework.data.jpa.repository.JpaRepository;

interface SpringDataAccountRepository extends JpaRepository<AccountJpaEntity, Long> {
}
package com.book.cleanarchitecture.buckpal.account.adapter.out.persistence;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDateTime;
import java.util.List;

interface ActivityRepository extends JpaRepository<ActivityJpaEntity, Long> {

    @Query("SELECT activites FROM ActivityJpaEntity activites " +
            "WHERE activites.ownerAccountId = :ownerAccountId " +
            "AND activites.timestamp >= :since")
    List<ActivityJpaEntity> findByOwnerSince(@Param("ownerAccountId") Long ownerAccountId, @Param("since") LocalDateTime since);

    @Query("SELECT SUM(activites.amount) FROM ActivityJpaEntity activites " +
            "WHERE activites.targetAccountId = :accountId " +
            "AND activites.ownerAccountId = :accountId " +
            "AND activites.timestamp < :until")
    Long getDepositBalanceUntil(@Param("accountId") Long accountId, @Param("until") LocalDateTime until);

    @Query("SELECT SUM(activites.amount) FROM ActivityJpaEntity activites " +
            "WHERE activites.sourceAccountId = :accountId " +
            "AND activites.ownerAccountId = :accountId " +
            "AND activites.timestamp < :until")
    Long getWithdrawalBalanceUntil(@Param("accountId") Long accountId, @Param("until") LocalDateTime until);
}
package com.book.cleanarchitecture.buckpal.account.adapter.out.persistence;

import com.book.cleanarchitecture.buckpal.account.application.port.out.LoadAccountPort;
import com.book.cleanarchitecture.buckpal.account.application.port.out.UpdateAccountStatePort;
import com.book.cleanarchitecture.buckpal.account.domain.Account;
import com.book.cleanarchitecture.buckpal.account.domain.Activity;
import com.book.cleanarchitecture.buckpal.account.domain.vo.AccountId;
import com.book.cleanarchitecture.buckpal.shared.PersistenceAdapter;

import javax.persistence.EntityNotFoundException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;

@PersistenceAdapter
public class AccountPersistenceAdapter implements LoadAccountPort, UpdateAccountStatePort {

    private final SpringDataAccountRepository accountRepository;
    private final ActivityRepository activityRepository;
    private final AccountMapper accountMapper;

    public AccountPersistenceAdapter(SpringDataAccountRepository accountRepository, ActivityRepository activityRepository, AccountMapper accountMapper) {
        this.accountRepository = accountRepository;
        this.activityRepository = activityRepository;
        this.accountMapper = accountMapper;
    }

    @Override
    public Account loadAccount(AccountId accountId, LocalDateTime baselineDate) {
        AccountJpaEntity account = accountRepository.findById(accountId.getValue())
                .orElseThrow(EntityNotFoundException::new);

        List<ActivityJpaEntity> activities = activityRepository.findByOwnerSince(accountId.getValue(), baselineDate);

        Long withdrawalBalance = orZero(activityRepository
                .getWithdrawalBalanceUntil(accountId.getValue(), baselineDate));

        Long depositBalance = orZero(activityRepository
                .getDepositBalanceUntil(accountId.getValue(), baselineDate));

        return accountMapper.mapToDomainEntity(account, activities, withdrawalBalance, depositBalance);
    }

    @Override
    public void updateActivities(Account account) {
        List<Activity> activities = account.getActivities();

        activities.stream()
                .filter(activity -> Objects.isNull(activity.getId()))
                .forEach(activity -> activityRepository.save(accountMapper.mapToJpaEntity(activity)));
    }

    private Long orZero(Long value) {
        return value == null ? 0L : value;
    }
}

Last updated