Phát hành ứng dụng do AI tạo an toàn hơn bằng cách đặt ràng buộc PostgreSQL (NOT NULL, CHECK, UNIQUE, FOREIGN KEY) làm hàng rào trước khi code và test.

Mã do AI viết thường trông đúng vì nó xử lý đường đi đẹp (happy path). Ứng dụng thực tế thất bại ở phần giữa lộn xộn: một form gửi chuỗi rỗng thay vì null, một job nền thử lại và tạo cùng một bản ghi hai lần, hoặc một thao tác xóa gỡ hàng cha và để lại con mồ côi. Đây không phải là lỗi kỳ lạ. Chúng xuất hiện dưới dạng trường bắt buộc trống, giá trị “unique” trùng lặp, và dòng mồ côi trỏ đến chỗ không tồn tại.
Chúng cũng lọt qua code review và test cơ bản vì lý do đơn giản: người review đọc ý định, không phải mọi trường hợp biên. Test thường bao phủ vài ví dụ điển hình, không phải hàng tuần hành vi người dùng thực, import từ CSV, retry do mạng, hay các request đồng thời. Nếu một assistant sinh mã, nó có thể bỏ sót những kiểm tra nhỏ nhưng quan trọng như cắt khoảng trắng, xác thực phạm vi, hoặc phòng tránh điều kiện đua.
“Ràng buộc trước, mã sau” nghĩa là bạn đặt các quy tắc không thể thương lượng trong cơ sở dữ liệu để dữ liệu xấu không thể lưu lại, dù đường viết dữ liệu đến từ đâu. Ứng dụng nên vẫn xác thực đầu vào để trả lỗi rõ ràng, nhưng cơ sở dữ liệu thực thi sự thật. Đó là nơi PostgreSQL constraints tỏa sáng: chúng bảo vệ bạn khỏi cả nhóm sai sót.
Một ví dụ nhanh: tưởng tượng một CRM nhỏ. Một script import do AI sinh tạo liên hệ. Một hàng có email là "" (rỗng), hai hàng lặp cùng email với chữ viết hoa khác nhau, và một liên hệ tham chiếu account_id không tồn tại vì tài khoản đã bị xóa ở quá trình khác. Nếu không có ràng buộc, tất cả những điều đó có thể vào production và phá báo cáo sau này.
Với quy tắc đúng, các ghi đó bị từ chối ngay lập tức, gần nguồn gây lỗi. Trường bắt buộc không thể thiếu, bản sao không thể lẻn vào khi retry, quan hệ không thể trỏ tới bản ghi đã bị xóa hoặc không tồn tại, và giá trị không thể nằm ngoài phạm vi cho phép.
Ràng buộc không ngăn mọi lỗi. Chúng không sửa giao diện gây nhầm lẫn, tính toán chiết khấu sai, hay câu truy vấn chậm. Nhưng chúng ngăn dữ liệu xấu tích tụ âm thầm, thường là lúc các “lỗi biên do AI” trở nên tốn kém.
Ứng dụng của bạn hiếm khi chỉ là một codebase giao tiếp với một người dùng. Một sản phẩm điển hình có web UI, app di động, màn hình admin, job nền, import CSV, và đôi khi tích hợp bên thứ ba. Mỗi đường đi đều có thể tạo hoặc thay đổi dữ liệu. Nếu mọi đường phải nhớ cùng một quy tắc, sẽ có đường quên.
Cơ sở dữ liệu là nơi chung mà tất cả chia sẻ. Khi bạn coi nó như người gác cổng cuối cùng, quy tắc áp dụng cho mọi thứ tự động. Các constraint của PostgreSQL biến “chúng tôi giả định điều này luôn đúng” thành “điều này phải đúng, nếu không ghi bị từ chối.”
Mã do AI sinh làm chuyện này càng quan trọng hơn. Một model có thể thêm xác thực form trong React nhưng bỏ sót góc nhỏ trong job nền. Hoặc nó xử lý dữ liệu happy-path tốt, rồi vỡ khi khách hàng thực nhập thứ bất ngờ. Ràng buộc bắt lỗi đúng lúc dữ liệu xấu cố vào, không phải vài tuần sau khi bạn gỡ lỗi các báo cáo kỳ lạ.
Khi bạn bỏ qua ràng buộc, dữ liệu xấu thường im lặng. Lưu thành công, app tiếp tục, và vấn đề xuất hiện sau này như ticket hỗ trợ, sai lệch tính phí, hoặc dashboard không ai tin. Dọn dẹp tốn kém vì bạn sửa lịch sử, không phải một request.
Dữ liệu xấu thường lẻn vào qua các tình huống hàng ngày: phiên bản client mới gửi trường là rỗng thay vì không tồn tại, retry tạo trùng, sửa admin bỏ qua kiểm tra UI, file import có định dạng không nhất quán, hoặc hai người cùng cập nhật bản ghi liên quan cùng lúc.
Một mô hình tư duy hữu dụng: chỉ chấp nhận dữ liệu nếu nó hợp lệ tại ranh giới. Trong thực tế, ranh giới đó nên bao gồm cơ sở dữ liệu, vì cơ sở dữ liệu thấy mọi ghi.
NOT NULL là ràng buộc đơn giản nhất của PostgreSQL, và nó ngăn một lớp lỗi đáng ngạc nhiên. Nếu một giá trị phải tồn tại để hàng có ý nghĩa, hãy để cơ sở dữ liệu thực thi điều đó.
NOT NULL thường đúng cho định danh, tên bắt buộc, và timestamps. Nếu bạn không thể tạo bản ghi hợp lệ mà thiếu nó, đừng cho phép nó rỗng. Trong CRM nhỏ, một lead không có owner hoặc created time không phải là “lead một phần”. Đó là dữ liệu hỏng sẽ gây hành vi kỳ lạ về sau.
NULL len lỏi thường hơn với mã do AI sinh vì dễ tạo các đường “tùy chọn” mà không để ý. Một trường form có thể tùy chọn trong UI, API chấp nhận key thiếu, và một nhánh của hàm tạo có thể bỏ qua gán giá trị. Mọi thứ vẫn biên dịch và test happy-path pass. Rồi người dùng import CSV với ô trống, hoặc client di động gửi payload khác, và NULL vào DB.
Một pattern hữu ích là kết hợp NOT NULL với default hợp lý cho các trường hệ thống quản lý:
created_at TIMESTAMP NOT NULL DEFAULT now()status TEXT NOT NULL DEFAULT 'new'is_active BOOLEAN NOT NULL DEFAULT trueDefault không luôn là chiến thắng. Đừng đặt mặc định cho các trường do người dùng cung cấp như email hoặc company_name chỉ để thỏa NOT NULL. Chuỗi rỗng không “hợp lệ” hơn NULL; nó chỉ che giấu vấn đề.
Khi băn khoăn, quyết định giá trị đó thật sự là “không biết” hay nó biểu thị một trạng thái khác. Nếu “chưa cung cấp” có ý nghĩa, hãy cân nhắc một cột trạng thái riêng thay vì cho phép NULL khắp nơi. Ví dụ: để phone nullable, nhưng thêm phone_status như missing, requested, hoặc verified. Điều này giữ ý nghĩa nhất quán trong code.
Một CHECK constraint là lời hứa bảng của bạn đưa ra: mỗi hàng phải thỏa một quy tắc, mọi lúc. Đây là một trong những cách dễ nhất để ngăn các trường hợp biên tạo ra các bản ghi trông hợp lý ở mã nhưng vô nghĩa trong thực tế.
CHECK phù hợp nhất cho các quy tắc chỉ phụ thuộc giá trị trong cùng một hàng: phạm vi số, giá trị cho phép, và quan hệ đơn giản giữa các cột.
-- 1) Totals should never be negative
ALTER TABLE invoices
ADD CONSTRAINT invoices_total_nonnegative
CHECK (total_cents >= 0);
-- 2) Enum-like allowed values without adding a custom type
ALTER TABLE tickets
ADD CONSTRAINT tickets_status_allowed
CHECK (status IN ('new', 'open', 'waiting', 'closed'));
-- 3) Date order rules
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_date_order
CHECK (end_date IS NULL OR end_date >= start_date);
Một CHECK tốt dễ đọc thoáng qua. Hãy coi nó như tài liệu cho dữ liệu của bạn. Ưu tiên biểu thức ngắn, tên constraint rõ ràng, và mẫu dự đoán được.
CHECK không phải công cụ cho mọi thứ. Nếu quy tắc cần tra cứu hàng khác, tổng hợp, hoặc so sánh xuyên bảng (ví dụ “một account không được vượt giới hạn gói”), giữ logic đó ở mã ứng dụng, trigger, hoặc job nền có kiểm soát.
UNIQUE đơn giản: cơ sở dữ liệu từ chối lưu hai hàng có cùng giá trị trong cột bị ràng buộc (hoặc cùng tổ hợp cột). Điều này xóa cả một lớp lỗi khi đường tạo chạy hai lần, retry xảy ra, hoặc hai người dùng gửi cùng lúc.
UNIQUE đảm bảo không trùng cho chính xác giá trị bạn định nghĩa. Nó không đảm bảo giá trị có tồn tại (NOT NULL), tuân theo định dạng (CHECK), hay khái niệm bằng nhau của bạn (chữ hoa, khoảng trắng, dấu câu) trừ khi bạn định nghĩa nó.
Những chỗ thường muốn UNIQUE gồm email trên bảng user, external_id từ hệ thống khác, hoặc tên phải duy nhất trong một account như (account_id, name).
Một lưu ý: NULL và UNIQUE. Trong PostgreSQL, NULL được coi là “không biết”, nên nhiều NULL được phép dưới UNIQUE. Nếu bạn muốn “giá trị phải tồn tại và phải duy nhất”, kết hợp UNIQUE với NOT NULL.
Một pattern thực tế cho định danh người dùng là duy nhất không phân biệt hoa thường. Mọi người sẽ gõ “[email protected]” rồi sau đó “[email protected]” và mong là giống nhau.
-- Case-insensitive unique email
CREATE UNIQUE INDEX users_email_unique_ci
ON users (lower(email));
-- Unique contact name per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_name_unique UNIQUE (account_id, name);
Xác định “trùng” nghĩa là gì với người dùng của bạn (hoa thường, khoảng trắng, theo tài khoản hay toàn cục), rồi mã hóa nó một lần để mọi đường viết theo cùng một quy tắc.
FOREIGN KEY nói rằng “hàng này phải trỏ tới một hàng thực ở chỗ kia.” Nếu không có nó, mã có thể âm thầm tạo các bản ghi mồ côi trông hợp lệ một mình nhưng phá ứng dụng sau này. Ví dụ: một note tham chiếu customer đã bị xóa, hoặc một invoice trỏ tới user_id chưa từng tồn tại.
Foreign key quan trọng nhất khi hai hành động xảy ra gần nhau: xóa và tạo, retry sau timeout, hoặc job nền chạy với dữ liệu cũ. Cơ sở dữ liệu giỏi hơn ở việc đảm bảo nhất quán hơn là mọi đường viết phải nhớ kiểm tra.
Tùy chọn ON DELETE nên khớp với ý nghĩa thực tế của quan hệ. Hỏi: “Nếu hàng cha biến mất, con có nên tồn tại không?”
RESTRICT (hoặc NO ACTION): chặn xóa cha nếu còn con.CASCADE: xóa cha sẽ xóa luôn con.SET NULL: giữ con nhưng xóa liên kết.Cẩn thận với CASCADE. Nó có thể đúng, nhưng cũng có thể xóa nhiều hơn bạn mong khi một bug hoặc hành động admin xóa cha.
Trong app đa tenant, foreign key không chỉ về tính đúng. Chúng còn ngăn rò rỉ giữa các account. Một pattern phổ biến là thêm account_id trên mọi bảng thuộc sở hữu tenant và nối quan hệ qua nó.
CREATE TABLE contacts (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
PRIMARY KEY (account_id, id)
);
CREATE TABLE notes (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
contact_id bigint NOT NULL,
body text NOT NULL,
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id, contact_id)
REFERENCES contacts (account_id, id)
ON DELETE RESTRICT
);
Điều này thực thi “ai sở hữu cái gì” ngay trong schema: một note không thể trỏ tới contact của account khác, ngay cả khi mã app (hoặc một truy vấn do LLM sinh) cố làm vậy.
Bắt đầu bằng cách viết một danh sách ngắn các bất biến: các sự thật phải luôn đúng. Giữ chúng đơn giản. “Mỗi contact cần một email.” “Một status phải là một trong vài giá trị cho phép.” “Một invoice phải thuộc về một customer thực.” Đây là các quy tắc bạn muốn DB thực thi mỗi lần.
Triển khai thay đổi thành các migration nhỏ để production không bị bất ngờ:
NOT NULL, UNIQUE, CHECK, FOREIGN KEY).Phần lộn xộn là dữ liệu xấu hiện có. Lên kế hoạch cho nó. Với trùng lặp, chọn một hàng giữ, gộp phần còn lại, và giữ một ghi chú audit nhỏ. Với trường bắt buộc thiếu, chọn default an toàn chỉ khi thật sự an toàn; nếu không, cách ly. Với quan hệ hỏng, gán lại các hàng con về cha đúng hoặc xóa các hàng xấu.
Sau mỗi migration, kiểm tra với vài ghi mà lẽ ra phải fail: insert hàng thiếu giá trị bắt buộc, insert khóa trùng, insert giá trị ngoài phạm vi, và tham chiếu cha không tồn tại. Ghi bị từ chối là tín hiệu hữu ích. Chúng cho bạn thấy chỗ mà app âm thầm dựa vào hành vi “nỗ lực tốt nhất”.
Hình dung một CRM nhỏ: accounts (mỗi khách hàng của SaaS), companies họ làm việc, contacts tại các công ty đó, và deals liên kết với company.
Đây chính xác là loại app người ta thường sinh nhanh bằng công cụ chat. Nó trông ổn trong demo, nhưng dữ liệu thực trở nên lộn xộn nhanh. Hai lỗi xuất hiện sớm: contact trùng lặp (cùng email nhập hai lần theo cách hơi khác), và deals được tạo mà không có company vì một đường viết quên set company_id. Một lỗi kinh điển khác là giá trị deal âm sau khi refactor hoặc lỗi parse.
Cách sửa không phải thêm hàng loạt if. Là vài ràng buộc chọn lọc khiến dữ liệu xấu không thể lưu.
-- Contacts: prevent duplicates per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_email_uniq UNIQUE (account_id, email);
-- Deals: require a company and keep the relationship valid
ALTER TABLE deals
ALTER COLUMN company_id SET NOT NULL,
ADD CONSTRAINT deals_company_fk
FOREIGN KEY (company_id) REFERENCES companies(id);
-- Deals: deal value cannot be negative
ALTER TABLE deals
ADD CONSTRAINT deals_value_nonneg CHECK (deal_value >= 0);
-- A few obvious required fields
ALTER TABLE companies
ALTER COLUMN name SET NOT NULL;
ALTER TABLE contacts
ALTER COLUMN email SET NOT NULL;
Đây không phải chuyện khắt khe cho vui. Bạn biến kỳ vọng mơ hồ thành quy tắc mà DB có thể thi hành mọi lúc, dù phần nào của app ghi dữ liệu.
Khi các ràng buộc này có hiệu lực, app trở nên đơn giản hơn. Bạn có thể loại bỏ nhiều kiểm tra phòng thủ từng phần cố gắng phát hiện trùng sau khi đã xảy ra. Lỗi trở nên rõ ràng và có thể hành động (ví dụ “email đã tồn tại cho account này” thay vì hành vi kỳ lạ ở downstream). Khi một route API sinh ra quên trường hoặc xử lý sai giá trị, ghi bị từ chối ngay thay vì vô âm thầm làm hỏng DB.
Ràng buộc phát huy tốt nhất khi chúng khớp với cách doanh nghiệp thực sự hoạt động. Hầu hết khó chịu đến từ việc thêm quy tắc có vẻ “an toàn” lúc đó nhưng thành bất ngờ sau này.
Bẫy phổ biến là dùng ON DELETE CASCADE khắp nơi. Trông gọn gàng cho tới khi ai đó xóa một hàng cha và DB xóa mất nửa hệ thống. Cascade có thể đúng cho dữ liệu thật sự thuộc sở hữu (như dòng nháp không bao giờ tồn tại một mình), nhưng rủi ro cho các bản ghi quan trọng (khách hàng, hóa đơn, ticket). Nếu chưa chắc, ưu tiên RESTRICT và xử lý xóa một cách có chủ ý.
Vấn đề khác là viết CHECK quá hẹp. “Status phải là ‘new’, ‘won’, hoặc ‘lost’” nghe ổn cho tới khi bạn cần “paused” hoặc “archived”. Một CHECK tốt mô tả sự thật ổn định, không phải lựa chọn UI tạm thời. “amount >= 0” tồn tại tốt theo thời gian. “country in (...)” thì hiếm khi vậy.
Một vài vấn đề lặp lại khi team thêm ràng buộc sau khi mã sinh đã chạy:
CASCADE như công cụ dọn dẹp, rồi xóa nhiều hơn dự định.Về hiệu năng: PostgreSQL tự động tạo index cho UNIQUE, nhưng foreign key không tự động index cột tham chiếu. Thiếu index, cập nhật và xóa trên cha có thể chậm vì Postgres phải quét bảng con để kiểm tra tham chiếu.
Trước khi thắt một quy tắc, tìm các hàng hiện có sẽ fail nó, quyết định sửa hay cách ly, và triển khai thay đổi từng bước.
Trước khi ship, dành năm phút cho mỗi bảng và ghi ra những gì phải luôn đúng. Nếu bạn nói được bằng tiếng Anh đơn giản, thường có thể thi hành bằng một ràng buộc.
Hỏi những câu này cho mỗi bảng:
Nếu bạn dùng công cụ xây dựng theo chat, coi những bất biến đó là tiêu chí chấp nhận cho dữ liệu, không phải ghi chú tùy chọn. Ví dụ: “Giá trị deal phải >= 0”, “Email contact là duy nhất theo workspace”, “Một task phải tham chiếu contact thực”. Càng rõ ràng thì càng ít chỗ cho các trường hợp biên vô tình.
Koder.ai (koder.ai) bao gồm các tính năng như chế độ lập kế hoạch, snapshot và rollback, và xuất mã nguồn, giúp bạn dễ lặp schema an toàn khi siết ràng buộc theo thời gian.
Một mẫu triển khai đơn giản hiệu quả với các team thực: chọn một bảng giá trị cao (users, orders, invoices, contacts), thêm 1-2 ràng buộc ngăn các lỗi tệ nhất (thường NOT NULL và UNIQUE), sửa các ghi bị fail, rồi lặp lại. Thắt quy tắc theo thời gian tốt hơn một migration lớn rủi ro.