Nest.js CRUD로 알아보는 Clean Architecture


BACKEND
NESTJS

설계 흐름

Input Adapter(ex. Controller - use-case 사용) => Input Port(interface - usecase가 구현해야할 메서드 정의) => usecase/domain - 비즈니스 로직 => output port(interface - output adapter가 구현해야할 메서드 정의) => output adapter(ex) typeorm repository)

코드

Input Adapter

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  ParseIntPipe,
  Patch,
  Post,
} from "@nestjs/common";
import { RBAC } from "../../../auth/decorator/rbac.decorator";
import { Role } from "../../../user/entities/user.entity";
import { ManageTechUseCase } from "../../application/manage-tech.use-case";
import { TechDomainMapper } from "../../dto/mapper/tech.domain.mapper";
import { CreateTechRequestDto } from "../../dto/request/create-tech.request.dto";
import { UpdateTechRequestDto } from "../../dto/request/update-tech.request.dto";
import { TechDomainResponseDto } from "../../dto/response/tech.domain.response.dto";

@Controller("tech")
export class TechRestApiAdapter {
  constructor(
    private readonly manageTechUseCase: ManageTechUseCase,
  ) {}

  @Get()
  async findAll(): Promise<TechDomainResponseDto[]> {
    const techDomains =
      await this.manageTechUseCase.findAll();
    return TechDomainMapper.toDtos(techDomains);
  }

  @Post()
  @RBAC(Role.admin)
  async create(
    @Body()
    createTechRequestDto: CreateTechRequestDto,
  ): Promise<TechDomainResponseDto> {
    const createdTech = await this.manageTechUseCase.create(
      createTechRequestDto,
    );
    return TechDomainMapper.toDto(createdTech);
  }

  @Patch(":id")
  @RBAC(Role.admin)
  async update(
    @Param("id", ParseIntPipe) techId: number,
    @Body()
    updateTechRequestDto: UpdateTechRequestDto,
  ): Promise<TechDomainResponseDto> {
    const updatedTech = await this.manageTechUseCase.update(
      techId,
      updateTechRequestDto,
    );
    return TechDomainMapper.toDto(updatedTech);
  }

  @Delete(":id")
  @RBAC(Role.admin)
  async remove(
    @Param("id", ParseIntPipe) techId: number,
  ): Promise<TechDomainResponseDto> {
    const removedTech =
      await this.manageTechUseCase.remove(techId);
    return TechDomainMapper.toDto(removedTech);
  }
}

Input Port

import { TechDomain } from "../../domain/tech.domain";
import { CreateTechRequestDto } from "../../dto/request/create-tech.request.dto";
import { UpdateTechRequestDto } from "../../dto/request/update-tech.request.dto";

export interface TechUseCasePort {
  findAll(): Promise<TechDomain[]>;
  update(
    techId: number,
    updateTechRequestDto: UpdateTechRequestDto,
  ): Promise<TechDomain>;
  create(
    createTechRequestDto: CreateTechRequestDto,
  ): Promise<TechDomain>;
  remove(techId: number): Promise<TechDomain>;
}

use-case

import {
  BadRequestException,
  Inject,
  Injectable,
} from "@nestjs/common";
import { CreateTechRequestDto } from "../dto/request/create-tech.request.dto";
import { UpdateTechRequestDto } from "../dto/request/update-tech.request.dto";
import { TechUseCasePort } from "../port/input/tech-use-case.port";
import { ManageTechRepositoryPort } from "../port/output/manage-tech.repository.port";

@Injectable()
export class ManageTechUseCase implements TechUseCasePort {
  constructor(
    @Inject("ManageTechRepositoryPort")
    private readonly manageTechRepositoryPort: ManageTechRepositoryPort,
  ) {}

  async findAll() {
    return this.manageTechRepositoryPort.findAll();
  }

  async create(createTechRequestDto: CreateTechRequestDto) {
    const duplicatedOne =
      await this.manageTechRepositoryPort.findOneByContent(
        createTechRequestDto.content,
      );

    if (duplicatedOne) {
      throw new BadRequestException("존재하는 기술입니다.");
    }

    return this.manageTechRepositoryPort.create(
      createTechRequestDto,
    );
  }

  async update(
    techId: number,
    updateTechRequestDto: UpdateTechRequestDto,
  ) {
    const tech =
      await this.manageTechRepositoryPort.findOneById(
        techId,
      );

    if (!tech) {
      throw new BadRequestException(
        "존재하지 않는 기술입니다.",
      );
    }

    await this.manageTechRepositoryPort.update(
      techId,
      updateTechRequestDto,
    );

    return await this.manageTechRepositoryPort.findOneById(
      techId,
    );
  }

  async remove(techId: number) {
    const tech =
      await this.manageTechRepositoryPort.findOneById(
        techId,
      );

    if (!tech) {
      throw new BadRequestException(
        "존재하지 않는 기술입니다.",
      );
    }
    await this.manageTechRepositoryPort.remove(techId);

    return tech;
  }
}

domain

export interface TechDomainProps {
  id: number;
  content: string;
}

export class TechDomain {
  private readonly id: number;
  private readonly content: string;

  constructor({ id, content }: TechDomainProps) {
    this.id = id;
    this.content = content;
  }

  get getId() {
    return this.id;
  }

  get getContent() {
    return this.content;
  }
}

Output Port

import { TechDomain } from "../../domain/tech.domain";
import { CreateTechRequestDto } from "../../dto/request/create-tech.request.dto";
import { UpdateTechRequestDto } from "../../dto/request/update-tech.request.dto";

export interface ManageTechRepositoryPort {
  findOneByContent(content: string): Promise<TechDomain>;

  findOneById(techId: number): Promise<TechDomain>;

  findAll(): Promise<TechDomain[]>;

  create(
    createTechRequestDto: CreateTechRequestDto,
  ): Promise<TechDomain>;
  update(
    techId: number,
    updateTechRequestDto: UpdateTechRequestDto,
  ): Promise<void>;
  remove(id: number): Promise<void>;
}

Output Adapter

import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { TechDomain } from "../../../../domain/tech.domain";
import { CreateTechRequestDto } from "../../../../dto/request/create-tech.request.dto";
import { UpdateTechRequestDto } from "../../../../dto/request/update-tech.request.dto";
import { ManageTechRepositoryPort } from "../../../../port/output/manage-tech.repository.port";
import { Tech } from "../entities/tech.entity";
import { TechMapper } from "../mapper/tech.mapper";

export class ManageTechRepositoryAdapter
  implements ManageTechRepositoryPort
{
  constructor(
    @InjectRepository(Tech)
    private readonly techRepository: Repository<Tech>,
  ) {}

  async findAll(): Promise<TechDomain[]> {
    const techEntities = await this.techRepository.find();
    return techEntities.map(TechMapper.toDomain);
  }

  async findOneById(techId: number): Promise<TechDomain> {
    const techEntity = await this.techRepository.findOne({
      where: {
        id: techId,
      },
    });

    return TechMapper.toDomain(techEntity);
  }

  async findOneByContent(
    content: string,
  ): Promise<TechDomain> {
    const techEntity = await this.techRepository.findOne({
      where: {
        content,
      },
    });

    return TechMapper.toDomain(techEntity);
  }

  async create(
    createTechRequestDto: CreateTechRequestDto,
  ): Promise<TechDomain> {
    const techEntity = await this.techRepository.save(
      createTechRequestDto,
    );

    return TechMapper.toDomain(techEntity);
  }

  async update(
    techId: number,
    updateTechRequestDto: UpdateTechRequestDto,
  ) {
    await this.techRepository.update(
      techId,
      updateTechRequestDto,
    );
  }

  async remove(id: number) {
    await this.techRepository.delete(id);
  }
}