[코드분석] Hexagonal Architecture in Nestjs

whythis·2022년 7월 22일
0

코드분석

목록 보기
1/1
post-thumbnail

Github에서 Hexagonal Architecture를 nestjs에서 어떻게 구현하는가에 대해 찾아보던 중 잘 짜여져있다고 생각하는 github 코드를 찾아서 그중 특정 부분을 분석해보았다.

집중해서 본 부분은 nestjs에서 다형성을 어떻게 구현하는가이다.

깃허브 주소 : https://github.dev/YaroslavTaranenko/nest-hexagonal


// account-persistence.module.ts

@Global()
@Module({
  imports: [TypeOrmModule.forFeature([AccountOrmEntity, ActivityOrmEntity])],
  providers: [
    AccountPersistenceAdapter,
    {
      provide: SendMoneyUseCaseSymbol,
      // Inject할 객체의 이름을 생성해주는 것. Token이라고 부르지만 Dictionary의 key와 같은 역할을 함.
      // 여기선 유일성을 보장하기 위해 Symbol을 사용했지만 아래와 같이 스트링으로 넣어도 무관함.
      useFactory: (accountPersistenceAdatper) => {
        return new SendMoneyService(
          accountPersistenceAdapter,
          accountPersistenceAdapter,
        );
      },
      inject: [AccountPersistenceAdapter],
    },
  ],
  exports: [SendMoneyUseCaseSymbol],
})
export class AccountPersistenceModule {}

	{     
 	 provide: 'SendMoneyUseCase',
      // 만약 이 부분이 위처럼 어떠한 스트링이라면
      // @Inject('SendMoneyUseCase') 로 Inject해줄 수 있다.
      useFactory: (accountPersistenceAdatper) => {
        return new SendMoneyService(
          accountPersistenceAdapter,
          accountPersistenceAdapter,
        );
      },
      inject: [AccountPersistenceAdapter],
    },

useFactory를 사용하여 의존성이 주입된 SendMoneyService 인스턴스 자체를 Inject할 객체로 생성할 수 있음.

아래와 같이 DI를 하고자 하는 곳에서 @Inject(SendMoneyUseCaseSymbol) 해주면 됨. 만약 스트링으로 Token을 바꿨다면 @Inject('SendMoneyUseCase') 로 Inject함.


// send-money.controller.ts

@Controller('account/send')
export class SendMoneyController {
  constructor(@Inject(SendMoneyUseCaseSymbol) private readonly _sendMoneyUseCase: SendMoneyUseCase) {}
  // 이와 같이 SendMoneyUseCaseSymbol라는 토큰을 가진 값을 불러와 injection할 수 있다. 
  // 이때 매칭되는 값은 위의 useFactory를 통해 만들었던 SendMoneyService의 인스턴스이다.
  @Get('/')
  async sendMoney(
    @Query('sourceAccountId') sourceAccountId: string,
    @Query('targetAccountId') targetAccountId: string,
    @Query('amount') amount: number,
  ) {
    const command: SendMoneyCommand = new SendMoneyCommand(
      sourceAccountId,
      targetAccountId,
      MoneyEntity.of(amount),
    );
    const result = await this._sendMoneyUseCase.sendMoney(command);
    return result;
  }
}

// account-persistence.adapter.ts

@Injectable()
export class AccountPersistenceAdapter
  implements LoadAccountPort, UpdateAccountStatePort // A
{ 
  constructor(
    @InjectRepository(AccountOrmEntity)
    private readonly accountRepository: Repository<AccountOrmEntity>,
    @InjectRepository(ActivityOrmEntity)
    private readonly activityRepository: Repository<ActivityOrmEntity>,
  ) {}

  async loadAccount(accountId: AccountId): Promise<AccountEntity> {
    const account: AccountOrmEntity = await this.accountRepository.findOne({
      userId: accountId,
    });
    if (account) {
      throw new Error('Account not found');
    }
    const activities = await this.activityRepository.find({
      ownerAccountId: accountId,
    });
    return AccountMapper.mapToDomain(account, activities);
  }
  async updateActivities(account: AccountEntity) {
    account.activityWindow.activities.forEach((activity: ActivityEntity) => {
      if (activity.id === null) {
        this.activityRepository.save(
          AccountMapper.mapToActivityOrmEntity(activity),
        );
      }

    });
  }
}

A 부분을 보면 LoadAccountPort와 UpdateAccountStatePort같은 interface를 implement(구현)하고 있음을 알 수 있다.

// load-account.port.ts
import { AccountEntity, AccountId } from '../../entities/account.entity';

export interface LoadAccountPort {
  loadAccount(accountId: AccountId): Promise<AccountEntity>;
}

// update-account-state.port.ts
import { AccountEntity } from '../../entities/account.entity';

export interface UpdateAccountStatePort {
  updateActivities(account: AccountEntity);
}

지금은 AccountPersistenceAdatper 라는 클래스에 해당 인터페이스들이 구현되어 있지만 만약 버전이 바뀌어 AccountPersistenceAdatperV2 가 생겨났다고 하자. 이를 구현할 때 똑같이 LoadAccountPort와 UpdateAccountStatePort의 interface를 implements 해주기만 한다면 구체적인 내부 구현이 달라지더라도 관계 없다. 처음 버전이든 V2이든 관계없이 같은 interface를 implements 했으므로 내가 원하는 메소드들(여기선 updateActivities()와 LoadAccountPort() 라는 메소드)은 구현이 되어있을 것이기 때문이다.


이렇게 되면 account-persistence.module.ts의 useFactory 부분을 아래와같이 바꿔줌으로서 다른 파일에 손을 대지 않고도 V1에서 V2 로 갈아끼울 수 있다.

 useFactory: (accountPersistenceAdatper) => {
        return new SendMoneyService(
          accountPersistenceAdapterV2,
          accountPersistenceAdapterV2,
        );
      }

Why?

처음에는 왜 굳이 interface를 따로 만들고 이를 구현하는 방식을 취하는지 궁금했었다. 그냥 accountPersistenceAdapter 라는 class를 구현하고 이용하면 되지 왜 굳이 interface를 만들어 한층 더 복잡하게 하는가 하눈 생각이 들었었다.

하지만 이렇게 interface를 바탕으로 설계를 하면 수정에 용이하다. 수정사항이 생겼을때 다른 부분은 건드릴 필요 없이 이 어떠한 interface를 구현한 구현체(여기선 accountPersistenceAdapter, accountPersistenceAdapterV2와 같은 것)를 갈아 끼워주기만 하면 제대로 동작함을 알 수 있다. 이를 통해 코드 변화로 인한 파급효과를 막아주어 수정에 용이하게 한다는 점이 매우 중요하다.

profile
why this?

0개의 댓글