Gần đây mình có xây dựng một module để tạo số hóa đơn tự động cho sản phẩm của công ty. Nghe thì có vẻ đơn giản, chỉ cần một cột auto-increment là đủ :v Tuy nhiên, đối diện với vấn đề multi-tenancy, mỗi domain yêu cầu định dạng khác nhau và có thể linh hoạt thay đổi theo nhu cầu của từng tenant, mình nhận ra nó phức tạp hơn nhiều.
Trong bài viết này, mình sẽ chia sẻ cách mình thiết kế module này, những thứ mình đã cân nhắc, và tại sao cách tách counting và formatting lại giúp module dễ tái sử dụng.
Bài toán
Platform của mình là một platform multi-tenant - với nhiều workspace, mỗi workspace có dữ liệu riêng. Trên toàn hệ thống, các tính năng khác nhau cần những số tham chiếu rõ ràng và dễ đọc, ví dụ:
INV-2026-00001 (hóa đơn)
DOC/DEPT-A/2026/00001 (tài liệu)
TKT/2026/06/042 (phiếu hỗ trợ)
Nhìn đơn giản nhưng đây là những điểm làm cho nó trở nên phức tạp:
- Định dạng khác nhau cho mỗi domain. Hóa đơn yêu cầu định dạng
INV-{year}-{seq}. Tài liệu yêu cầuDOC/{department}/{year}/{seq}. Mỗi tính năng có một định dạng riêng. - Bộ đếm có phạm vi. Bộ đếm có thể reset theo phòng ban theo năm hoặc tháng. Tài liệu của Phòng A và Phòng B mỗi cái có bộ đếm riêng.
- Đồng thời. Hai người dùng có thể tạo bản ghi cùng lúc và không được phép trùng lặp. Cái này thì là nhu cầu cơ bản.
- Multi-tenancy. Sequence
00001của Workspace A không liên quan gì đến00001của Workspace B.
Mình không muốn xây dựng một bộ tạo sequence khác nhau cho mỗi module. Mình muốn một service có thể tái sử dụng mà bất kỳ phần nào của hệ thống cũng có thể gọi với một vài tham số đơn giản, kiểu như “đây là pattern của tôi, đây là scope của tôi, hãy cho tôi số tiếp theo.”
Thiết kế
Mình chia vấn đề thành ba phần cần xử lý:
- Counter: Tự động tăng và trả về số tiếp theo
- Formatting: Biến một pattern + context thành chuỗi cuối cùng
- Domain logic: Quyết định pattern và scope nào sẽ được sử dụng
Ở đây, module sequence không biết gì về hóa đơn hay tài liệu hay phòng ban. Nó chỉ biết về pattern và counter. Domain logic hoàn toàn nằm ở caller - phần gọi service này quyết định họ muốn format gì, scope thế nào, và context ra sao.
Cấu trúc của một pattern
Các pattern sử dụng placeholder {key}. Một trong số đó phải là {seq} — đó là chỗ số tự tăng sẽ được chèn vào. Còn lại sẽ được thay thế từ một đối tượng context mà caller cung cấp.
DOC/{department}/{year}/{seq}
Với { department: 'SALES', year: 2026 } và counter đang ở 1, nó sẽ tạo ra:
DOC/SALES/2026/00001
Đơn giản là vậy. Không có DSL, không có cú pháp đặc biệt.
Database Model
CREATE TABLE sequences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL,
type VARCHAR NOT NULL, -- e.g., 'invoice', 'document'
scope TEXT NOT NULL, -- e.g., 'dept-uuid:2026'
next_number INT NOT NULL,
padding INT DEFAULT 5,
last_generated_at TIMESTAMPTZ,
UNIQUE (workspace_id, type, scope)
);
Ba cột xác định tính duy nhất: workspace_id, type, và scope. Ban đầu mình định để cả workspace_id và type trong scope, nhưng sau đó nhận ra việc tách riêng type giúp truy vấn dễ dàng hơn mà không phải parse chuỗi scope.
typexác định domain kinh doanh. Hóa đơn có một type, tài liệu có một type khác.scopelà một chuỗi tự do mà caller xây dựng. Với tài liệu, nó có thể là{departmentId}:{year}, vậy mỗi phòng ban sẽ reset bộ đếm của mình mỗi năm. Với hóa đơn, nó có thể là{year}:{month}để reset hàng tháng trên toàn workspace.next_numberlà số tiếp theo sẽ được trả về. Mỗi lần gọi service, nó sẽ tăng lên 1.
Mình dùng
varcharchotypethay vì enum của PostgreSQL vì muốn linh hoạt thêm mới hoặc thậm chí cho người dùng định nghĩa type của riêng họ mà không phải chạy migration.
Atomic Upsert
Để đảm bảo tính đồng thời, mình sử dụng một câu lệnh SQL duy nhất với INSERT ... ON CONFLICT ... DO UPDATE. Câu lệnh này sẽ chèn một hàng mới nếu chưa tồn tại, hoặc cập nhật hàng hiện có bằng cách tăng next_number lên 1. Dù có bao nhiêu request cùng lúc, mỗi request sẽ nhận được một số duy nhất mà không cần phải lo về race conditions hoặc xử lý lock ở cấp ứng dụng.
INSERT INTO sequences (id, workspace_id, type, scope, next_number, padding, last_generated_at)
VALUES (uuid_generate_v4(), $1, $2, $3, 1, $4, now())
ON CONFLICT (workspace_id, type, scope)
DO UPDATE SET
next_number = sequences.next_number + 1,
padding = EXCLUDED.padding,
last_generated_at = now()
RETURNING next_number, padding
Lệnh SQL này sẽ chạy trong transaction của caller, vì vậy nếu transaction đó bị rollback vì lý do nào khác, increment cũng sẽ rollback theo. Điều này đảm bảo không có khoảng trống trong số sequence do các operation thất bại.
Implementation
Pattern Interpolation
Đây là các pure function để xử lý pattern. Không class, không state, không dependency.
const PLACEHOLDER_REGEX: RegExp = /\{([a-zA-Z0-9_]+)\}/g;
const INTERNAL_SEQUENCE_TOKEN: string = "__sequence_token__";
function interpolatePattern(
pattern: string,
context: Readonly<Record<string, string | number>>
): string {
return pattern.replace(PLACEHOLDER_REGEX, (_match, key) => {
if (key === "seq") {
return INTERNAL_SEQUENCE_TOKEN;
}
const value = context[key];
if (value === undefined || value === null) {
throw new Error(
`Sequence pattern interpolation failed: missing context key "${key}"`
);
}
return String(value);
});
}
Placeholder {seq} được xử lý đặc biệt — nó được thay thế bằng một token nội bộ để sau này chúng ta có thể replace bằng số thực tế sau khi lấy được từ database. Các placeholder khác được thay thế bằng giá trị tương ứng trong context.
Ở bước này, nếu một key nào đó trong pattern không có trong context, nó sẽ throw lỗi ngay lập tức. Fail fast giúp nhanh chóng phát hiện lỗi cấu hình hoặc typo trong pattern.
function extractPatternParts(interpolatedPattern: string): PatternParts {
const matches =
interpolatedPattern.match(new RegExp(INTERNAL_SEQUENCE_TOKEN, "g")) ?? [];
if (matches.length !== 1) {
throw new Error(
"Sequence pattern must contain exactly one {seq} placeholder"
);
}
const [prefix, suffix] = interpolatedPattern.split(INTERNAL_SEQUENCE_TOKEN);
return { prefix, suffix };
}
extractPatternParts sẽ tách phần prefix và suffix của pattern dựa trên INTERNAL_SEQUENCE_TOKEN. Nó cũng đảm bảo rằng chỉ có đúng một {seq} trong pattern — không được phép thiếu hoặc thừa. Điều này giữ cho logic đơn giản và tránh những trường hợp vô nghĩa như {seq}-{seq}.
function buildSequenceName(params: BuildSequenceNameParams): string {
const paddedSequence = params.nextNumber
.toString()
.padStart(params.padding, "0");
return `${params.prefix}${paddedSequence}${params.suffix}`;
}
buildSequenceName sẽ tạo ra chuỗi cuối cùng bằng cách chèn số đã được zero-pad vào giữa prefix và suffix. Nếu số vượt quá padding (ví dụ 99999 với padding 3), nó sẽ không bị cắt bớt — 99999 vẫn tốt hơn 999 hoặc một lỗi. Padding chỉ ảnh hưởng đến cách hiển thị, người dùng có thể thay đổi nó trong tương lai mà không ảnh hưởng đến số thực tế.
Public Service
Bây giờ chỉ cần ghép các phần lại với nhau trong một service:
@Injectable()
export class SequenceService {
async generateNext(
params: GenerateSequenceParams,
manager: EntityManager
): Promise<string> {
const resolvedPadding = params.padding ?? DEFAULT_PADDING;
const interpolated = interpolatePattern(params.pattern, params.context);
const { prefix, suffix } = extractPatternParts(interpolated);
const sequenceResult = await this.upsertAndGetNextNumber({
workspaceId: params.workspaceId,
type: params.type,
scope: params.scope,
padding: resolvedPadding,
manager,
});
return buildSequenceName({
prefix,
suffix,
nextNumber: sequenceResult.next_number,
padding: sequenceResult.padding,
});
}
}
EntityManager được truyền vào từ caller thay vì service tự tạo transaction riêng. Điều này là có chủ đích - việc tạo sequence phải nằm trong transaction của caller. Nếu service tự tạo transaction, bạn sẽ có những số sequence bị “mồ côi” khi operation bên ngoài thất bại.
Cách sử dụng
Dưới đây là một ví dụ từ một document service:
async function generateDocumentNumber(
payload: {
workspaceId: string;
department: {
id: string;
code: string;
};
date: string;
},
manager: EntityManager
): Promise<string> {
const department = payload.department;
const year = payload.date.slice(0, 4);
return this.sequenceService.generateNext(
{
workspaceId: payload.workspaceId,
type: SequenceType.DOCUMENT,
scope: `${department.id}:${year}`,
pattern: "DOC/{department}/{year}/{seq}",
context: { department: department.code, year },
},
manager
);
}
Cho phép người dùng tùy chỉnh pattern
Mặc dù phần implementation không đề cập đến yêu cầu này, thiết kế của module vẫn cho phép chúng ta dễ dàng mở rộng để hỗ trợ pattern do người dùng định nghĩa.
Hãy tưởng tượng một trang cài đặt nơi người dùng có thể cấu hình lại pattern, padding, và scope reset của hóa đơn:
- Pattern:
INV-{year}-{seq}hoặcDOC/{department}/{year}/{seq}hoặc bất kỳ thứ gì họ muốn - Padding:
3nếu số lượng bản ghi ít,5hoặc nhiều hơn nếu có nhiều bản ghi - Scope reset: theo năm, theo tháng, hoặc bất kỳ scope nào bạn cho phép cấu hình
Khi đó caller chỉ cần lấy settings của user và truyền chúng vào service:
const settings = await this.getSequenceSettings(workspaceId, entityId);
const scopeParts = [entityId];
if (settings.resetPeriod === "yearly") scopeParts.push(year);
if (settings.resetPeriod === "monthly") scopeParts.push(year, month);
// 'never' → scope chỉ chứa entityId
return this.sequenceService.generateNext(
{
workspaceId,
type: settings.sequenceType,
scope: scopeParts.join(":"),
pattern: settings.pattern,
context: { department, year, month },
padding: settings.padding,
},
manager
);
Chuỗi scope là mấu chốt ở đây. Nó kiểm soát khi nào bộ đếm reset mà không cần bất kỳ logic reset đặc biệt nào trong module sequence:
| Scope | Behavior | Output |
|---|---|---|
dept-uuid:2026 | Resets mỗi năm cho mỗi department | DOC/SALES/2026/00001 → next year starts at 00001 again |
dept-uuid:2026:06 | Resets mỗi tháng cho mỗi department | DOC/SALES/2026-06/00001 → next month starts fresh |
dept-uuid | Không bao giờ reset, tăng liên tục cho mỗi department | DOC/SALES/00001, 00002, 00003, … indefinitely |
workspace-uuid | Không bao giờ reset, tăng liên tục cho mỗi workspace | INV-00001, INV-00002, … |
Sequence module không biết “yearly” hay “monthly” nghĩa là gì. Nó chỉ thấy một chuỗi scope khác nhau và tạo một hàng counter riêng biệt. Một scope abc:2026 và abc:2027 là hai hàng độc lập. Hành vi reset được kiểm soát ở phần caller, không phải module sequence.
Key Takeaways
-
Tách biệt counting và formatting. Database xử lý atomic increments. Pure functions xử lý string interpolation.
-
Để caller sở hữu domain logic. Module sequence không biết “invoice” hay “document” là gì. Caller quyết định pattern, scope, và context. Điều này làm cho module có thể tái sử dụng mà không cần sửa đổi.
-
Sử dụng PostgreSQL upsert cho atomic counters.
INSERT ... ON CONFLICT ... DO UPDATE ... RETURNINGlà một câu lệnh duy nhất xử lý cả khởi tạo và tăng. Không cần lock ở tầng ứng dụng hay lo lắng về race conditions. -
Sử dụng DB transaction của caller. Bằng cách nhận
EntityManagertừ caller thay vì tự tạo transaction, increment của sequence được gắn chặt với operation kinh doanh. Nếu operation thất bại, không có số sequence bị “mồ côi”. -
Bắt đầu đơn giản. Một regex-based interpolation engine với các placeholder
{key}đã đủ cho các use case thực tế mà mình gặp. Tránh xây dựng một cái gì đó qua phức tạp cho đến khi thực sự cần.
Happy coding! :p