本文 AI 產出,尚未審核

TypeScript 實務開發與架構應用:DTO / Entity 型別設計


簡介

在大型 TypeScript 專案中,**資料傳輸物件(DTO)領域實體(Entity)**的型別設計是維持程式碼可讀性、可測試性與可維護性的關鍵。
DTO 用於 跨界面(API、Message Queue、檔案) 的資料交換,僅保留必要的欄位與驗證規則;Entity 則是 業務邏輯的核心,承載完整的狀態與行為,往往與資料庫模型緊密對應。

如果兩者的型別混用或設計不當,會導致:

  1. 資料洩漏:不必要的欄位被暴露給前端或第三方服務。
  2. 維護成本升高:修改資料結構時,需要在多處同步更新,容易遺漏。
  3. 測試困難:業務邏輯與輸入驗證耦合,單元測試的範圍變得模糊。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶領讀者一步步建立 乾淨、可擴充 的 DTO / Entity 型別設計。


核心概念

1. DTO 與 Entity 的定位差異

項目 DTO Entity
目的 資料傳輸(跨層、跨服務) 業務模型(內部邏輯與狀態)
包含欄位 必要欄位 + 客製化驗證 完整欄位 + 行為方法(method)
與資料庫的關係 通常 不直接 對應資料表 直接 對應資料表(ORM)
可變性 不可變(Immutable)或只讀 可變(狀態會隨業務流程改變)
序列化/反序列化 必須支援 JSON、XML、Protobuf 等格式 只在程式內部使用,序列化需求較少

重點:DTO 只負責「傳遞」資料,Entity 才是「處理」資料的主角。兩者分離可以讓 API contract 與內部模型各自演進,而不互相牽制。


2. 設計 DTO 的基本原則

  1. 只保留客戶端需要的欄位

    • 例如:UserEntity 可能有 passwordHashcreatedAt,但 UserDto 只回傳 idnameemail
  2. 使用 readonlyas const 讓 DTO 成為不可變

    • 可避免在程式流中意外修改傳入的資料。
  3. 配合驗證庫(class-validator、zod)

    • 在 DTO 上宣告驗證規則,讓 API 層自動完成輸入檢查。
  4. 保持扁平結構

    • 避免過深的巢狀物件,減少序列化/反序列化的成本。

3. 設計 Entity 的基本原則

  1. 與 ORM/資料庫同步(如 TypeORM、Prisma)

    • 欄位名稱、型別、關聯要與資料表保持一致。
  2. 封裝業務行為

    • 把「改變狀態」的邏輯寫在實例方法中,而不是外部 service。
  3. 避免直接暴露屬性

    • 使用 private / protected,提供 getter/setter 以控制存取。
  4. 支援領域事件

    • 變更後可觸發事件(Domain Event),讓其他模組訂閱。

4. DTO ↔ Entity 的映射策略

方法 說明
手寫映射函式 (toEntity, toDto) 控制細節最精細,適合複雜轉換或自訂規則。
class-transformerplainToClass / classToPlain 自動映射,適合簡單、欄位對應相同的情況。
automapper-ts 宣告映射規則後自動執行,支援深層對應與自訂轉換。
Prisma 的 select / include 只取需要的欄位,直接返回 DTO 形狀,減少手動映射工作。

建議:在大型專案中,手寫映射函式 搭配 單元測試 是最安全的選擇,因為可以明確掌控每個欄位的轉換邏輯。


程式碼範例

以下範例以 NestJS + TypeORM 為背景,展示 DTO、Entity、以及映射函式的完整寫法。

1️⃣ 建立 UserEntity

// src/users/entities/user.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  BeforeInsert,
} from 'typeorm';
import * as bcrypt from 'bcrypt';

@Entity('users')
export class UserEntity {
  @PrimaryGeneratedColumn('uuid')
  private id!: string;

  @Column({ length: 100 })
  private name!: string;

  @Column({ unique: true })
  private email!: string;

  @Column()
  private passwordHash!: string;

  @CreateDateColumn()
  private createdAt!: Date;

  @UpdateDateColumn()
  private updatedAt!: Date;

  // ---------- Getter ----------
  getId(): string {
    return this.id;
  }
  getName(): string {
    return this.name;
  }
  getEmail(): string {
    return this.email;
  }
  // ---------- Business Logic ----------
  async setPassword(plain: string) {
    this.passwordHash = await bcrypt.hash(plain, 10);
  }

  async validatePassword(plain: string): Promise<boolean> {
    return bcrypt.compare(plain, this.passwordHash);
  }

  @BeforeInsert()
  async hashPassword() {
    // 確保在插入前自動雜湊密碼
    if (this.passwordHash) {
      this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
    }
  }
}

說明:Entity 嚴格使用 private 欄位與 getter,確保外部只能透過方法存取或修改,避免直接改寫資料。


2️⃣ 定義 CreateUserDtoUserDto

// src/users/dto/create-user.dto.ts
import { IsEmail, IsNotEmpty, Length } from 'class-validator';

export class CreateUserDto {
  @IsNotEmpty()
  @Length(2, 100)
  readonly name!: string;

  @IsEmail()
  readonly email!: string;

  @IsNotEmpty()
  @Length(6, 30)
  readonly password!: string;
}

// src/users/dto/user.dto.ts
export class UserDto {
  readonly id!: string;
  readonly name!: string;
  readonly email!: string;
  readonly createdAt!: Date;
}

重點CreateUserDto 加入 class-validator 裝飾器,讓 Nest 在接收請求時自動驗證。UserDto只讀readonly)的輸出格式。


3️⃣ 手寫映射函式

// src/users/mappers/user.mapper.ts
import { UserEntity } from '../entities/user.entity';
import { CreateUserDto } from '../dto/create-user.dto';
import { UserDto } from '../dto/user.dto';

/**
 * 從 CreateUserDto 產生一個未儲存的 UserEntity
 */
export async function mapCreateDtoToEntity(dto: CreateUserDto): Promise<UserEntity> {
  const user = new UserEntity();
  // 只設定需要的欄位,密碼會在 Entity 內自行雜湊
  user['name'] = dto.name;
  user['email'] = dto.email;
  await user.setPassword(dto.password);
  return user;
}

/**
 * 把已持久化的 UserEntity 轉成對外的 UserDto
 */
export function mapEntityToDto(entity: UserEntity): UserDto {
  return {
    id: entity.getId(),
    name: entity.getName(),
    email: entity.getEmail(),
    createdAt: entity['createdAt'],
  };
}

說明:使用 entity['name'] 直接存取私有屬性只是一種簡化寫法,實務上可以透過 constructorfactory 方法注入。映射函式保持 純函式(無副作用),便於單元測試。


4️⃣ 在 Service 中使用映射

// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UserDto } from './dto/user.dto';
import { mapCreateDtoToEntity, mapEntityToDto } from './mappers/user.mapper';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepo: Repository<UserEntity>,
  ) {}

  async create(dto: CreateUserDto): Promise<UserDto> {
    const entity = await mapCreateDtoToEntity(dto);
    const saved = await this.userRepo.save(entity);
    return mapEntityToDto(saved);
  }

  async findOne(id: string): Promise<UserDto | null> {
    const entity = await this.userRepo.findOne({ where: { id } });
    return entity ? mapEntityToDto(entity) : null;
  }
}

重點:Service 只關心 DTO ↔ Entity 的轉換與資料庫操作,不直接處理驗證或序列化,保持單一職責。


5️⃣ 使用 class-transformer 的自動映射(可選)

// src/users/mappers/user.auto-mapper.ts
import { plainToInstance } from 'class-transformer';
import { CreateUserDto } from '../dto/create-user.dto';
import { UserEntity } from '../entities/user.entity';
import { UserDto } from '../dto/user.dto';

export function autoMapCreateDto(dto: CreateUserDto): UserEntity {
  // 會自動把相同屬性名稱的欄位映射過去
  const entity = plainToInstance(UserEntity, dto, { excludeExtraneousValues: true });
  // 仍需自行處理密碼雜湊
  // (此範例僅示意,實務上不建議直接映射密碼欄位)
  return entity;
}

提示class-transformer 只適合 欄位名稱相同且無特殊邏輯 的情況,若有密碼雜湊或關聯處理,仍需自行補足。


常見陷阱與最佳實踐

陷阱 說明 最佳實踐
DTO 與 Entity 混用 直接在 Controller 回傳 Entity,會把敏感欄位(如 passwordHash)暴露。 永遠 只回傳 DTO,使用映射函式或自動映射工具。
過度映射 把所有 Entity 欄位搬到 DTO,失去 DTO 的精簡目的。 只挑選必要欄位,若需要額外資訊,另建 DetailDto
可變 DTO 在服務層或測試中意外修改 DTO,導致不可預期的副作用。 使用 readonlyas const深層凍結Object.freeze)。
缺少驗證 只依賴 TypeScript 型別,未在執行時驗證輸入。 結合 class-validator / zod,在 API 門檻層完成驗證。
映射函式缺測試 手寫映射時忘記測試,導致欄位遺失或格式錯誤。 為每個映射函式撰寫 單元測試,確保雙向轉換正確。
Entity 內部直接操作資料庫 把 Repository 注入到 Entity,違反領域模型的純粹性。 Repository 只屬於 Service / Repository Layer,Entity 僅保有行為。

其他實務建議

  1. 分層命名CreateUserDtoUpdateUserDtoUserResponseDto,讓意圖一目了然。
  2. 使用 Partial<Type> 建立更新 DTO:export class UpdateUserDto implements Partial<CreateUserDto> { ... }
  3. 在大型專案使用 automapper-ts:可在 mapper-profile.ts 中集中管理所有映射規則,減少重複程式碼。
  4. 領域事件:Entity 內部方法完成狀態變更後,可透過 EventEmitter 發出 UserCreatedEvent,讓其他模組(如 Email Service)解耦處理。

實際應用場景

場景一:RESTful API 的使用者註冊

  1. 前端送出 CreateUserDto(含 nameemailpassword)。
  2. NestJS Controller 透過 ValidationPipe 先驗證。
  3. Service 呼叫 mapCreateDtoToEntity,產生 UserEntity,自動雜湊密碼。
  4. Repository 儲存至資料庫。
  5. 回傳 UserDto(不含密碼)。

好處:前端永遠不會看到密碼雜湊,且 API contract 嚴格受 DTO 控制。

場景二:微服務間的事件傳遞

假設有 User ServiceOrder Service,當使用者建立時需要通知訂單服務。

  1. UserEntity 變更後,Domain Event UserCreatedEvent 被發出,負載為 純粹的 DTOUserCreatedDto { id, email }
  2. 事件序列化為 JSON,透過 Kafka 發送。
  3. Order Service 收到後,使用 UserCreatedDto 建立客製化的 CustomerProfile

好處:只傳遞必要資訊,避免跨服務共用 Entity,降低耦合度。

場景三:GraphQL 的輸出型別

在 GraphQL 中,Resolver 需要回傳 User 型別:

@Resolver(() => User)
export class UserResolver {
  @Query(() => User)
  async me(@CurrentUser() user: UserEntity): Promise<UserDto> {
    // 直接把 Entity 轉成 DTO,GraphQL 只會曝光 DTO 定義的欄位
    return mapEntityToDto(user);
  }
}

好處:即使 GraphQL Schema 與資料庫結構不同,也能透過 DTO 做 Adapter


總結

  • DTO 與 Entity 的分離 是大型 TypeScript 專案的基礎建設,能有效防止資料洩漏、降低維護成本。
  • 設計原則:DTO 只保留必要欄位、使用只讀、配合驗證;Entity 負責完整狀態與業務行為、封裝欄位、支援領域事件。
  • 映射策略:手寫映射函式最安全、最具可測試性;若需求簡單,可考慮 class-transformerautomapper-ts
  • 常見陷阱 包括混用 DTO/Entity、缺少驗證、可變 DTO 等,透過 readonly、驗證管線、單元測試 可有效避免。
  • 實務應用 包含 REST API、微服務事件、GraphQL 等場景,皆能從 DTO / Entity 的清晰分層中受益。

把握好這套 型別設計思維,不僅能提升程式碼品質,還能讓團隊在面對功能擴充與重構時更加從容。祝開發順利,寫出乾淨、可維護的 TypeScript 應用!