Vấn đề với cách "để database tự validate"
Thỉnh thoảng gặp codebase có pattern như thế này:
// Service không validate gì cả
async createUser(dto: CreateUserDto) {
return this.userRepository.save(dto);
}
Lý luận là: "Nếu email null thì database sẽ báo lỗi NOT NULL. Nếu username quá dài thì varchar(20) sẽ chặn. Nếu email trùng thì UNIQUE constraint sẽ reject."
Database có chặn được. Nhưng đây là cách tiếp cận sai — không phải vì kết quả cuối cùng khác nhau, mà vì database không phải tầng để validate input.
Tại sao phụ thuộc vào database để validate là sai
1. Lỗi từ database không phải lỗi của người dùng
Khi database throw lỗi, backend nhận được exception kỹ thuật:
ER_DUP_ENTRY: Duplicate entry '[email protected]' for key 'users.UQ_email'
ER_DATA_TOO_LONG: Data too long for column 'username' at row 1
ER_BAD_NULL_ERROR: Column 'email' cannot be null
Muốn trả về HTTP 400 với message thân thiện thì phải:
- Catch exception từ ORM
- Parse error code/message
- Map về từng trường hợp
- Format lại response
// Code xử lý lỗi DB — rối, brittle, không đủ
try {
return await this.userRepository.save(dto);
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
if (err.sqlMessage.includes('UQ_email')) {
throw new ConflictException('Email đã tồn tại');
}
}
if (err.code === 'ER_DATA_TOO_LONG') {
// parse sqlMessage để biết column nào?
throw new BadRequestException('Dữ liệu quá dài');
}
throw err; // cái gì nữa? không biết
}
So sánh với validate ở DTO:
// Validate ở DTO — rõ ràng, đủ thông tin, kiểm soát được
export class CreateUserDto {
@IsEmail()
@IsNotEmpty()
email: string;
@MaxLength(20)
@IsNotEmpty()
username: string;
}
NestJS tự throw BadRequestException với message chi tiết từng field, không cần catch gì cả.
2. Khi nào error đến tay app thì đã tốn tài nguyên DB
Để database chặn lỗi có nghĩa là mỗi request invalid phải:
- Mở connection
- Gửi query đến DB
- DB parse query
- DB check constraint
- DB reject và trả lỗi
Với validate ở application layer, request invalid bị từ chối trước khi chạm đến database — giảm tải thực sự.
Với hệ thống nhỏ thì không đáng kể. Nhưng đây là nguyên tắc kiến trúc đúng: garbage in phải bị chặn sớm nhất có thể.
3. Database constraint và business rule là hai việc khác nhau
Database constraint giỏi bảo vệ tính toàn vẹn của schema. Nhưng business rule thường phức tạp hơn nhiều:
// Validate này không thể làm ở DB
if (dto.endDate <= dto.startDate) {
throw new BadRequestException('Ngày kết thúc phải sau ngày bắt đầu');
}
if (dto.discountPercent > 0 && !dto.promoCode) {
throw new BadRequestException('Cần promo code khi có giảm giá');
}
if (user.role === 'free' && dto.fileSize > 10 * 1024 * 1024) {
throw new BadRequestException('Tài khoản free chỉ được upload tối đa 10MB');
}
Nếu validate ở DB được một số thứ nhưng không validate được những thứ này, code sẽ bị chia thành hai chỗ — một phần ở DB constraint, một phần ở service. Không nhất quán và khó debug.
4. Test khó hơn
Test business logic khi validation dựa vào DB constraint cần setup database thật, seed data, không thể chỉ unit test service layer:
// Phải test integration với DB thật mới biết validate đúng không
it('should reject email > 255 chars', async () => {
await expect(
service.createUser({ email: 'a'.repeat(256) + '@x.com', ... })
).rejects.toThrow(); // reject vì DB hay vì logic? không rõ
});
Validate ở DTO/service thì test unit hoàn toàn được, không cần DB:
it('should reject email > 255 chars', () => {
const dto = plainToClass(CreateUserDto, { email: 'a'.repeat(256) + '@x.com' });
const errors = validateSync(dto);
expect(errors[0].property).toBe('email');
});
5. Database constraint thay đổi khó hơn
Khi business rule thay đổi — ví dụ nâng giới hạn username từ 20 lên 50 ký tự — phải chạy migration:
ALTER TABLE users MODIFY COLUMN username VARCHAR(50) NOT NULL;
Migration trên bảng lớn có thể lock table, cần downtime hoặc online DDL phức tạp. Nếu rule này nằm ở code:
@MaxLength(50) // đổi số ở đây
username: string;
Deploy code mới là xong — không cần migration, không lock.
DB constraint vẫn có vai trò — nhưng khác
DB constraint không phải vô nghĩa. Vai trò đúng của nó là last line of defense cho tính toàn vẹn dữ liệu, không phải để validate input.
| Validate ở code | DB constraint | |
|---|---|---|
| Mục đích | Bắt invalid input từ user/client | Bảo vệ tính toàn vẹn dữ liệu schema |
| Khi nào chạy | Trước khi chạm DB | Khi DB nhận query |
| Error message | Kiểm soát được, thân thiện | Lỗi kỹ thuật, cần parse |
| Test | Unit test, nhanh | Cần DB thật |
| Thay đổi | Deploy code | Migration |
| Business logic | Làm được | Không làm được |
Ví dụ: UNIQUE constraint trên cột email trong DB vẫn nên tồn tại — không phải để validate input, mà để ngăn race condition khi hai request đến cùng lúc đều pass validate ở code nhưng cùng insert email giống nhau. DB constraint ở đây là safety net ở tầng cuối cùng.
Validate ở đâu trong NestJS
Tầng DTO — validate format và cấu trúc
import { IsEmail, IsNotEmpty, MaxLength, MinLength, IsOptional, IsInt, Min, Max } from 'class-validator';
export class CreateUserDto {
@IsEmail({}, { message: 'Email không hợp lệ' })
@IsNotEmpty()
email: string;
@MinLength(3, { message: 'Username tối thiểu 3 ký tự' })
@MaxLength(50, { message: 'Username tối đa 50 ký tự' })
@IsNotEmpty()
username: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(120)
age?: number;
}
Bật ValidationPipe global trong NestJS:
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip extra fields
forbidNonWhitelisted: true, // throw nếu có field không khai báo
transform: true, // auto transform type
}));
Request có field invalid sẽ tự động nhận 400 với body:
{
"statusCode": 400,
"message": ["email không hợp lệ", "username tối thiểu 3 ký tự"],
"error": "Bad Request"
}
Không cần catch gì, không cần xử lý ở service.
Tầng Service — validate business rule
DTO validate format và cấu trúc. Business rule như kiểm tra trùng, kiểm tra quan hệ giữa fields, kiểm tra dựa trên state của hệ thống — validate ở service:
@Injectable()
export class UserService {
constructor(private readonly userRepository: UserRepository) {}
async createUser(dto: CreateUserDto) {
// Business rule: kiểm tra email đã tồn tại chưa
const existing = await this.userRepository.findByEmail(dto.email);
if (existing) {
throw new ConflictException('Email đã được sử dụng');
}
// Business rule: username chỉ chứa chữ thường, số và dấu gạch dưới
if (!/^[a-z0-9_]+$/.test(dto.username)) {
throw new BadRequestException('Username chỉ được dùng chữ thường, số và _');
}
return this.userRepository.save(dto);
}
}
Tầng DB — chỉ để integrity
-- Đây là safety net, không phải primary validation
ALTER TABLE users ADD CONSTRAINT UQ_email UNIQUE (email);
ALTER TABLE orders ADD CONSTRAINT FK_orders_users
FOREIGN KEY (user_id) REFERENCES users(id);
Ví dụ cụ thể: varchar(6) và 7 ký tự
Giả sử column status có VARCHAR(6) với các giá trị: active, paused, banned.
// Sai: để DB chặn
async updateStatus(id: number, status: string) {
await this.userRepository.update(id, { status });
// Nếu status = 'deleted' (7 ký tự) → DB throw ER_DATA_TOO_LONG
// Nếu status = 'ACTIVE' (6 ký tự) → lưu được nhưng sai giá trị
}
// Đúng: validate ở DTO
export class UpdateStatusDto {
@IsIn(['active', 'paused', 'banned'], { message: 'Status không hợp lệ' })
status: string;
}
Bây giờ:
'deleted'→ 400 ngay ở DTO layer, DB không nhận được request'ACTIVE'→ 400, rõ message'active'→ pass, lưu đúng
Không cần catch ER_DATA_TOO_LONG, không cần parse sqlMessage, không cần DB phải làm việc.
Tổng kết
Validate ở codebase (DTO + service), không phải ở database. DB constraint vẫn cần nhưng vai trò là bảo vệ tính toàn vẹn, không phải bắt lỗi input.
Quy tắc đơn giản:
- DTO: validate format, kiểu dữ liệu, độ dài, pattern, enum value
- Service: validate business rule liên quan đến state và logic
- DB constraint: UNIQUE, FK, NOT NULL — safety net tầng cuối, không phải primary validation