DTO
DB
Validate in Code
dto • service • not db

Tại sao không nên dùng database để validate input trong backend

Nhiều backend dựa vào NOT NULL, VARCHAR(n), UNIQUE constraint của database để bắt lỗi input. Quan điểm này sai về kiến trúc — database không phải lớp validate. Validate đúng chỗ: DTO hoặc service layer, trước khi data chạm vào DB.

10 phút đọc18/06/2026

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 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:

  1. Catch exception từ ORM
  2. Parse error code/message
  3. Map về từng trường hợp
  4. 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 statusVARCHAR(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