본문 바로가기
아키텍처

DDD(Domain Driven Design) in Nest.js

by nacjji 2024. 6. 25.

express만 사용하다 nest.js를 도입하기로 한 후 수박 겉핥기 식으로 공부해서 프로젝트에 적용시켰는데, 

 

그러다보니 이렇다 할 구조도, 체계도 없이 중구난방인 프로젝트가 되어버려서

 

프로젝트 리뉴얼 하는 김에 도메인 주도 개발(이하 DDD)를 도입하기로 했다. 

 

DDD는 복잡한 도메인을 효과적으로 이해하고, 비즈니스 로직과 도메인을 효율적으로 분리할 수 있게 하는 아키텍쳐이다.

 

데이터베이스에서 내 정보를 찾는 간단한 플로우로 예시를 들면서 알아보자

 

DDD의 구조를 요약하면 다음과 같다.

프레젠테이션 계층 (Presentation Layer)
- Controller
- DTO (Data Transfer Object)

애플리케이션 계층 (Application Layer)
- UseCase Class / Service Class: 도메인 로직을 호출하고, 트랜잭션을 관리하며, 여러 도메인 객체를 조정한다.

도메인 계층 (Domain Layer)
- Entity
- Repository Interface
- Value Object (VO)

인프라스트럭쳐 계층 (Infrastructure Layer)
- 외부 API 및 DB Config
- Repository Implementation

 

  1. presentation
    • HTTP 요청 처리, 응답 반환
    • Controller
  • Controller
import { Controller, Get, Query } from '@nestjs/common';

import { UserService } from './user.service';

import { UserIdDto } from './dto/user-id.dto';



@Controller('users')

export class UserController {

  constructor(private readonly userService: UserService) {}



  @Get('my-info')

  async myInfo(@Query('id') id: UserIdDto) {

    return await this.userService.myInfo(id);

  }

}
  • DTO
export class UserIdDto {

  userId: string;

}

 

 

2. application

  • 비즈니스 로직 구현, 프레젠테이션 - 도메인 계층 연결
import { Injectable } from '@nestjs/common';

import { InjectRepository } from '@nestjs/typeorm';

import { User } from 'src/domain/entities/user.entity';

import { UserRepository } from 'src/domain/repositories/user.repository';

import { UserIdDto } from '../dto/user-id.dto';



@Injectable()

export class UserService {

  constructor(

    private readonly userRepository: UserRepository,

  ) {}



  async myInfo(dto: UserIdDto): Promise<User | undefined> {

    return await this.userRepository.findOne(dto.userId);

  }

}

 

3. domain

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';



@Entity()

export class User {

  @PrimaryGeneratedColumn()

  id: number;



  @Column()

  userId: string;

}

 

  • repository 인터페이스 도메인과 인프라 스트럭쳐(구체적인 메서드)를 분리하기 위함, 구체적인 메서드는 인프라스트럭처에서 구현되고, 도메인 계층은 repository interface를 주입받아 사용
import { User } from '../entities/user.entity';



export interface UserRepository {

  findOne(userId: string): Promise<User | undefined>;

}

 

  • VO(View Object) 불변객체, 특정 속성의 집합
    • DTO와는 다르게 고유 식별자가 없고, getter만 존재
// src/domain/value-objects/user-id.vo.ts
export class UserId {
  constructor(private readonly userId: string) {
    if (!this.isValid(userId)) {
      throw new Error('Invalid User ID format');
    }
  }

  get value(): string {
    return this.userId;
  }

  // 동일성 검사
  equals(other: UserId): boolean {
    return this.userId === other.userId;
  }

  // 도메인 논리: 유효성 검사
  private isValid(userId: string): boolean {
    // 여기서 userId의 형식 유효성 검사를 수행할 수 있습니다.
    return /^[a-zA-Z0-9_-]+$/.test(userId);
  }
}
VO를 사용하는 이유
동일성 검사: VO는 단순한 값이 아니라 논리적으로 같은 값을 가지는지 확인할 수 있습니다.
불변성: VO는 한 번 생성되면 값을 변경할 수 없습니다.
도메인 논리: VO는 관련된 도메인 논리를 포함할 수 있습니다. 예를 들어, UserId가 특정 형식을 준수하는지 확인할 수 있습니다.



VO를 사용하지 않는 경우
간단한 데이터 구조: 데이터가 단순하고 복잡한 검증이나 로직이 필요하지 않은 경우.
불변성 및 동일성 검사가 필요 없는 경우: 값 자체의 변경 가능성과 단순한 값 비교로 충분한 경우.

 

4. infrastructure

  • 데이터베이스/외부 시스템과 상호작용
  • 데이터베이스 config
  • 외부 API 연동
  • Repository implement

 

@Injectable() 

export class UserRepositoryImpl implements UserRepository { 

	constructor( 

		@InjectRepository(User) 
        private userRepository: Repository<User> 

	) {} 

	async findOne(userId: string): Promise<User | undefined> { 

		return await this.userRepository.findOne({ where: { userId } }); 

	} 

}