TypeScript 實務開發與架構應用:DTO / Entity 型別設計
簡介
在大型 TypeScript 專案中,**資料傳輸物件(DTO)與領域實體(Entity)**的型別設計是維持程式碼可讀性、可測試性與可維護性的關鍵。
DTO 用於 跨界面(API、Message Queue、檔案) 的資料交換,僅保留必要的欄位與驗證規則;Entity 則是 業務邏輯的核心,承載完整的狀態與行為,往往與資料庫模型緊密對應。
如果兩者的型別混用或設計不當,會導致:
- 資料洩漏:不必要的欄位被暴露給前端或第三方服務。
- 維護成本升高:修改資料結構時,需要在多處同步更新,容易遺漏。
- 測試困難:業務邏輯與輸入驗證耦合,單元測試的範圍變得模糊。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶領讀者一步步建立 乾淨、可擴充 的 DTO / Entity 型別設計。
核心概念
1. DTO 與 Entity 的定位差異
| 項目 | DTO | Entity |
|---|---|---|
| 目的 | 資料傳輸(跨層、跨服務) | 業務模型(內部邏輯與狀態) |
| 包含欄位 | 必要欄位 + 客製化驗證 | 完整欄位 + 行為方法(method) |
| 與資料庫的關係 | 通常 不直接 對應資料表 | 常 直接 對應資料表(ORM) |
| 可變性 | 不可變(Immutable)或只讀 | 可變(狀態會隨業務流程改變) |
| 序列化/反序列化 | 必須支援 JSON、XML、Protobuf 等格式 | 只在程式內部使用,序列化需求較少 |
重點:DTO 只負責「傳遞」資料,Entity 才是「處理」資料的主角。兩者分離可以讓 API contract 與內部模型各自演進,而不互相牽制。
2. 設計 DTO 的基本原則
只保留客戶端需要的欄位
- 例如:
UserEntity可能有passwordHash、createdAt,但UserDto只回傳id、name、email。
- 例如:
使用
readonly或as const讓 DTO 成為不可變- 可避免在程式流中意外修改傳入的資料。
配合驗證庫(class-validator、zod)
- 在 DTO 上宣告驗證規則,讓 API 層自動完成輸入檢查。
保持扁平結構
- 避免過深的巢狀物件,減少序列化/反序列化的成本。
3. 設計 Entity 的基本原則
與 ORM/資料庫同步(如 TypeORM、Prisma)
- 欄位名稱、型別、關聯要與資料表保持一致。
封裝業務行為
- 把「改變狀態」的邏輯寫在實例方法中,而不是外部 service。
避免直接暴露屬性
- 使用
private/protected,提供 getter/setter 以控制存取。
- 使用
支援領域事件
- 變更後可觸發事件(Domain Event),讓其他模組訂閱。
4. DTO ↔ Entity 的映射策略
| 方法 | 說明 |
|---|---|
手寫映射函式 (toEntity, toDto) |
控制細節最精細,適合複雜轉換或自訂規則。 |
class-transformer 的 plainToClass / 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️⃣ 定義 CreateUserDto 與 UserDto
// 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']直接存取私有屬性只是一種簡化寫法,實務上可以透過constructor或factory方法注入。映射函式保持 純函式(無副作用),便於單元測試。
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,導致不可預期的副作用。 | 使用 readonly、as const 或 深層凍結(Object.freeze)。 |
| 缺少驗證 | 只依賴 TypeScript 型別,未在執行時驗證輸入。 | 結合 class-validator / zod,在 API 門檻層完成驗證。 |
| 映射函式缺測試 | 手寫映射時忘記測試,導致欄位遺失或格式錯誤。 | 為每個映射函式撰寫 單元測試,確保雙向轉換正確。 |
| Entity 內部直接操作資料庫 | 把 Repository 注入到 Entity,違反領域模型的純粹性。 | Repository 只屬於 Service / Repository Layer,Entity 僅保有行為。 |
其他實務建議
- 分層命名:
CreateUserDto、UpdateUserDto、UserResponseDto,讓意圖一目了然。 - 使用
Partial<Type>建立更新 DTO:export class UpdateUserDto implements Partial<CreateUserDto> { ... }。 - 在大型專案使用
automapper-ts:可在mapper-profile.ts中集中管理所有映射規則,減少重複程式碼。 - 領域事件:Entity 內部方法完成狀態變更後,可透過
EventEmitter發出UserCreatedEvent,讓其他模組(如 Email Service)解耦處理。
實際應用場景
場景一:RESTful API 的使用者註冊
- 前端送出
CreateUserDto(含name、email、password)。 - NestJS Controller 透過 ValidationPipe 先驗證。
- Service 呼叫
mapCreateDtoToEntity,產生UserEntity,自動雜湊密碼。 - Repository 儲存至資料庫。
- 回傳
UserDto(不含密碼)。
好處:前端永遠不會看到密碼雜湊,且 API contract 嚴格受 DTO 控制。
場景二:微服務間的事件傳遞
假設有 User Service 與 Order Service,當使用者建立時需要通知訂單服務。
UserEntity變更後,Domain EventUserCreatedEvent被發出,負載為 純粹的 DTO:UserCreatedDto { id, email }。- 事件序列化為 JSON,透過 Kafka 發送。
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-transformer或automapper-ts。 - 常見陷阱 包括混用 DTO/Entity、缺少驗證、可變 DTO 等,透過 readonly、驗證管線、單元測試 可有效避免。
- 實務應用 包含 REST API、微服務事件、GraphQL 等場景,皆能從 DTO / Entity 的清晰分層中受益。
把握好這套 型別設計思維,不僅能提升程式碼品質,還能讓團隊在面對功能擴充與重構時更加從容。祝開發順利,寫出乾淨、可維護的 TypeScript 應用!