Khi nào code bắt đầu rối giữa DTO, Model và Entity
Rất nhiều codebase backend bị rối ở chỗ này:
DTObị truyền thẳng xuốngserviceEntitybị dùng luôn trong business logicservicetrảEntityngược ra controller- frontend, controller, service và database cùng kéo nhau bằng một kiểu object
Ban đầu cách này có vẻ nhanh. Nhưng càng về sau càng lộ vấn đề:
- code khó reuse
- khó test
- phụ thuộc chéo giữa framework và business logic
- đổi database hoặc đổi ORM là vỡ dây chuyền
Nếu bạn đang dùng NestJS hoặc backend chia tầng rõ hơn, thì việc tách DTO, Model và Entity ra đúng vai trò là rất quan trọng.
Kết luận nhanh trước khi đi sâu
Nếu cần chốt ngắn gọn:
DTOdùng ởcontrollerđể nhận input, validate và define contract requestModeldùng ởserviceđể xử lý business logicEntitychỉ nên gắn vớiTypeORMhoặc tầng database
Nói dễ hiểu hơn:
DTOlà dữ liệu đi vào hệ thốngModellà dữ liệu dùng để hệ thống suy nghĩEntitylà dữ liệu dùng để lưu xuống database
DTO là gì và nên dùng ở đâu
DTO là viết tắt của Data Transfer Object.
Trong backend hiện đại, đặc biệt với NestJS, DTO thường được dùng để:
- nhận request body
- nhận query param
- nhận route param
- validate đầu vào
- mô tả rõ contract mà controller chấp nhận
Ví dụ:
export class CreateAccountDto {
@IsString()
name: string;
@IsEmail()
email: string;
@IsDateString()
birthday: string;
}
Ở đây ý nghĩa của DTO là:
- client gửi gì vào
- backend chấp nhận format nào
- field nào bắt buộc
- field nào sai thì reject ngay từ controller
Vì sao DTO chỉ nên dừng ở controller
Đây là chỗ nhiều team dùng sai nhất.
DTO gắn rất chặt với:
- request format
- validation decorator
- framework layer như
controller,pipe,class-validator
Nếu nhét DTO xuống service, bạn sẽ gặp các vấn đề:
servicebị phụ thuộc vào request shape của controller- khó reuse cùng logic từ job, cron, queue, event consumer
- logic business bị lệ thuộc vào class validation hoặc HTTP input
- object đi vào service không còn thuần business nữa
Một vấn đề thực tế khác là:
DTOthường không được thiết kế như một object business đểnewlên và truyền khắp nơi- nó sinh ra để nhận dữ liệu đầu vào và validate, không phải để sống lâu trong các tầng bên dưới
Vì vậy:
- controller nhận
DTO - validate xong
- map sang
Model - rồi mới đưa vào
service
Model là gì và nên dùng ở đâu
Trong bài này, Model nên hiểu là object đại diện cho dữ liệu business mà tầng service muốn làm việc cùng.
Model không nên phụ thuộc vào:
class-validatorTypeORM- decorator của database
- HTTP request
Ví dụ:
export class AccountModel {
constructor(
public readonly id: string | null,
public readonly name: string,
public readonly email: string,
public readonly birthday: Date | null,
) {}
}
Model hợp với service vì:
- nó thuần business hơn
- dễ
new - dễ test
- dễ reuse từ nhiều entry point khác nhau
Vì sao service nên làm việc với Model thay vì DTO
Giả sử controller nhận:
export class GetAccountDto {
@IsUUID()
accountId: string;
}
Nếu bạn truyền thẳng GetAccountDto xuống service, service đang bị kéo theo request contract.
Cách tốt hơn là:
export class AccountLookupModel {
constructor(public readonly accountId: string) {}
}
Controller:
@Get(":accountId")
getAccount(@Param() dto: GetAccountDto) {
const model = new AccountLookupModel(dto.accountId);
return this.accountService.getAccount(model);
}
Service:
getAccount(model: AccountLookupModel) {
// business logic
}
Lợi ích là:
- sau này queue consumer cũng gọi lại được
getAccount - cron job cũng gọi lại được
- test unit cũng tạo
new AccountLookupModel(...)dễ dàng - service không cần biết request ban đầu là HTTP, queue hay CLI
Đây chính là ý nghĩa lớn nhất của Model ở tầng service:
- giúp business logic dễ reuse
Entity là gì và nên dùng ở đâu
Entity thường là object gắn với ORM hoặc database.
Nếu dùng TypeORM, Entity thường có:
@Entity()@Column()@PrimaryGeneratedColumn()- relation
- mapping database
Ví dụ:
@Entity("accounts")
export class AccountEntity {
@PrimaryColumn("uuid")
id: string;
@Column()
name: string;
@Column()
email: string;
@Column({ type: "date", nullable: true })
birthday: string | null;
}
Vai trò đúng của Entity là:
- map với bảng database
- để ORM load/save/query
- đại diện cho persistence layer
Nó không nên trở thành object business đi khắp codebase.
Vì sao không nên dùng Entity thẳng ở service
Nếu service xử lý trực tiếp bằng Entity, bạn sẽ dính:
- business logic phụ thuộc ORM
- relation lazy/eager ảnh hưởng logic không mong muốn
- test unit khó hơn
- đổi ORM hoặc đổi cách lưu dữ liệu sẽ kéo theo service
Tệ hơn nữa là Entity rất dễ bị dùng sai như một object “đa năng”:
- vừa là request contract
- vừa là business object
- vừa là database object
Khi một class ôm cả 3 vai trò này, code sẽ ngày càng khó kiểm soát.
Cách đúng: Entity chỉ ở repository hoặc database layer
Một cách tổ chức dễ giữ code sạch là:
ControllernhậnDTOServicelàm việc vớiModelRepositoryhoặc database layer làm việc vớiEntity
Ví dụ flow:
- Controller nhận
CreateAccountDto - Controller map sang
AccountModel - Service xử lý business trên
AccountModel - Repository map
AccountModelsangAccountEntity - TypeORM lưu
AccountEntity - Repository map lại
AccountEntityvềAccountModel
Nên map Entity sang Model như thế nào
Đây là chỗ pattern kiểu adapter hoặc mapper rất hợp.
Ví dụ:
@Entity("accounts")
export class AccountEntity {
@PrimaryColumn("uuid")
id: string;
@Column()
name: string;
@Column()
email: string;
@Column({ type: "date", nullable: true })
birthday: string | null;
toModel(): AccountModel {
return new AccountModel(
this.id,
this.name,
this.email,
this.birthday ? new Date(this.birthday) : null,
);
}
}
Hoặc dùng mapper riêng:
export class AccountMapper {
static toModel(entity: AccountEntity): AccountModel {
return new AccountModel(
entity.id,
entity.name,
entity.email,
entity.birthday ? new Date(entity.birthday) : null,
);
}
}
Cả 2 cách đều ổn. Điểm quan trọng là:
Entitykhông bị lộ thẳng lên service nếu bạn không muốnserviceluôn làm việc với một kiểu dữ liệu business ổn định hơn
DTO, Model và Entity khác nhau như thế nào
| Loại | Mục đích | Nên ở tầng nào | Không nên dùng cho gì |
|---|---|---|---|
DTO |
nhận input, validate, định nghĩa request contract | Controller |
business logic lâu dài, repository |
Model |
biểu diễn dữ liệu business để service xử lý | Service |
validate HTTP, mapping database trực tiếp |
Entity |
map ORM và database | Repository / database layer |
request contract, business object chính |
Một flow ví dụ hoàn chỉnh
Ví dụ tạo account:
DTO ở controller
export class CreateAccountDto {
@IsString()
name: string;
@IsEmail()
email: string;
@IsDateString()
birthday: string;
}
Model ở service
export class AccountModel {
constructor(
public readonly id: string | null,
public readonly name: string,
public readonly email: string,
public readonly birthday: Date | null,
) {}
}
Controller map DTO sang Model
@Post()
createAccount(@Body() dto: CreateAccountDto) {
const model = new AccountModel(
null,
dto.name,
dto.email,
dto.birthday ? new Date(dto.birthday) : null,
);
return this.accountService.createAccount(model);
}
Service xử lý business bằng Model
createAccount(model: AccountModel) {
// check duplicate email
// apply business rules
return this.accountRepository.save(model);
}
Repository map Model sang Entity
async save(model: AccountModel): Promise<AccountModel> {
const entity = new AccountEntity();
entity.id = model.id ?? randomUUID();
entity.name = model.name;
entity.email = model.email;
entity.birthday = model.birthday
? model.birthday.toISOString().slice(0, 10)
: null;
const saved = await this.repo.save(entity);
return saved.toModel();
}
Flow này nhìn có vẻ dài hơn một chút, nhưng đổi lại:
- code tách tầng rõ
- service dễ reuse
- test dễ hơn
- đổi ORM hoặc đổi transport layer ít ảnh hưởng hơn
Khi nào có thể đơn giản hơn
Không phải project nào cũng cần tổ chức quá nặng.
Nếu app rất nhỏ, đôi khi bạn có thể gom nhẹ hơn. Nhưng vẫn nên giữ nguyên tinh thần:
DTOkhông đi sâu xuống serviceEntitykhông nổi lên business layer
Ngay cả khi chưa tách đủ class riêng, việc giữ đúng ranh giới tư duy vẫn rất đáng làm.
Những lỗi phổ biến khi dùng lẫn DTO, Model và Entity
- dùng
Entitylàm luôn response cho API - truyền
DTOthẳng xuốngservice - để
servicenhận object phụ thuộcclass-validator - thêm method business vào
Entityrồi để cả hệ thống phụ thuộc vào ORM class đó - để controller gọi repository trực tiếp vì “service chỉ pass through”
Các lỗi này thường làm codebase nhanh phình nợ kỹ thuật hơn mọi người tưởng.
Kết luận
Nếu muốn code backend sạch và dễ reuse hơn, bạn nên nhớ:
DTOdùng để nhận và validate input ởcontrollerModeldùng để xử lý business ởserviceEntitydùng cho ORM và database
Đừng để một object ôm cả 3 vai trò cùng lúc.
Khi tách đúng:
- controller rõ contract
- service rõ business
- repository rõ persistence
Đó mới là ý nghĩa đúng của việc phân biệt DTO, Model và Entity trong source code backend.