tRPC Trong NestJS Monorepo: Type-Safe Contract Từ Router Đến Hook
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Trong thế giới REST, đổi tên một field là môn thể thao hai người: backend đổi, frontend đuổi theo. Người thắng cuộc là bên chạy CI cuối cùng. Hòa thì cả hai cùng được undefined ở production, và một buổi chiều vui vẻ dành ra để truy tìm thủ phạm qua ba repo.
hr-tool — monorepo Nx của mình với backend NestJS, frontend React 19, mobile React Native — chọn đường khác: không có controller REST nào cả. Mọi endpoint đi qua tRPC, type của frontend sinh ra từ chính file router của backend. Nếu backend đổi field mà frontend chưa kịp theo, TypeScript đỏ trước cả khi ai đó kịp build. Bài này đi hết đường đi của một API contract trong repo: từ file router ở backend đến cái hook mà component gọi là useJobList().
tRPC là gì: gọi hàm ở xa, type không rơi giữa đường
Ngắn gọn: tRPC = TypeScript RPC. Bạn gọi một hàm chạy trên server như gọi hàm local, và type của input/output được infer xuyên suốt từ backend ra frontend. Không schema file riêng. Không bước codegen. Không nghề “sync DTO bằng tay”.
Sự khác biệt bắt đầu từ tư duy mô tả API. REST mô tả API bằng danh từ: resource + động từ HTTP — GET /jobs?page=2, PATCH /jobs/:id. tRPC mô tả API bằng hàm có tên: job.list({ page: 2 }), job.updateStage({ id, stage }). Cái đầu cần một lớp dịch (controller, DTO, client wrapper) để biến URL thành lệnh gọi có type; cái thứ hai là lệnh gọi có type ngay từ đầu.
Ba building block
- Procedure — một endpoint. Mỗi procedure có
input(zod schema) vàresolve(handler nhận{ ctx, input }). Output không cần khai báo — type được infer từ giá trị return của handler (muốn validate chặt hơn thì thêm.output()cho output schema). Procedure khai báo.query()hay.mutation()quyết định nó là thao tác đọc hay ghi — và cái đó quan trọng ở phần frontend, sắp tới. - Router — nhóm procedure theo domain bằng
router({ list, markAsRead }), rồi merge thành cây quarouter({ job: jobRouter, auth: authRouter }). Cây đó chính là bản đồ API: tên trên cây chính là đường URL. - Context — object tạo mới cho mỗi request: user, companyId, req/res. Nó đi vào mọi procedure, là nơi auth và multi-tenant sống.
Một procedure đi trên wire như thế nào
tRPC không phải magic — nó vẫn là HTTP. Client call useJobList({ page: 2 }) trở thành một request tới /trpc/job.list, với input đã serialize. Các query phát ra trong cùng một frame được batch vào một GET duy nhất với input nằm trong query param; mutations gộp vào một POST. Response là JSON với result.data — hoặc một array errors mang code của TRPC. HTTP vẫn ở đó, chỉ là bạn không bao giờ phải nghĩ về nó.
sequenceDiagram
participant H as useJobList({ page: 2 })
participant L as httpBatchLink
participant R as /trpc/job.list
participant Z as zod schema
participant Svc as jobService
H->>L: input + context headers
L->>R: GET batch (input serialized)
R->>Z: parse input
Z-->>R: input đã validate + đúng type
R->>Svc: resolve({ ctx, input })
Svc-->>R: dữ liệu
R-->>H: result.data — type inferred từ return
Error model: code có ngữ nghĩa, không phải string rải rác
Procedure ném TRPCError với một code trong bộ cố định — BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, INTERNAL_SERVER_ERROR… HTTP adapter map từng code về status tương ứng (401, 403, 404…). Không phải string lỗi tùy ý mà client phải đoán. Muốn can thiệp hình dạng lỗi trả về (ví dụ che stack ở production) thì có errorFormatter — phần procedure chain phía dưới sẽ gặp lại nó.
Type infer như thế nào, về bản chất
Đây là chìa khóa của cả hệ thống. Client không cần biết gì ngoài type của router:
export type AppRouter = ReturnType<typeof createAppRouter>;
Từ một type duy nhất đó, tRPC dựng proxy client có đầy đủ path type-safe: gõ trpc.job. là autocomplete gợi ra mọi procedure của job router, input của mỗi cái là zod schema đã parse thành TS type, output là return type của handler. Server đổi, type đổi — và mọi chỗ dùng cũ đỏ ngay. Type không phải tài liệu đi kèm code; nó chính là code.
Nếu cần hứng input/output dưới dạng type riêng (ví dụ để reuse), tRPC có sẵn helper inferRouterInputs<AppRouter> / inferRouterOutputs<AppRouter>. Ở hr-tool thì không cần dùng đến — mọi thứ chảy qua hooks và useUtils, type tự đi theo.
Và một câu hỏi ai cũng hỏi: query và mutation khác gì nhau ngoài tên gọi? Nó ánh xạ thẳng vào semantic của React Query: .query() cho thao tác idempotent, cacheable — sinh useQuery với cache, staleTime, refetch; .mutation() cho thao tác ghi — sinh useMutation, invalidate thủ công. Đó là lý do naming convention của hooks sinh tự động ở phần sau phân biệt hai loại này ngay từ lúc parse.
Điểm khác biệt cốt lõi so với REST nằm ở chỗ type sống ở đâu:
flowchart LR
subgraph REST["REST + OpenAPI"]
D["DTO + controller"] --> S["OpenAPI spec"]
S --> G["codegen hoặc viết tay type"]
G --> C1["FE fetch + parse runtime"]
end
subgraph TRPC["tRPC"]
Z["router + zod"] --> T["AppRouter type"]
T --> C2["FE gọi trực tiếp, type inferred"]
end
Ở phía REST, schema mô tả API tồn tại song song với code: spec, DTO, type bên FE — ba bản sao của cùng một sự thật, lệch nhau theo lịch release của ba nhóm người khác nhau. Ở tRPC chỉ có một bản: file router. Type không cần “được đồng bộ” — nó là chính cái code.
Đặt cạnh hai phương án quen khác:
| REST + OpenAPI | GraphQL | tRPC | |
|---|---|---|---|
| Nguồn type | spec + DTO viết tay | schema + codegen | inference từ router |
| Validation | thủ công / DTO | resolver tự lo | zod built-in |
| Client non-TS | OK | OK | Không |
| Over-fetch | Có | Ít | Không — procedure trả đúng thứ mình định nghĩa |
| Phù hợp | API public, đa client | Ecosystem GraphQL nặng | Full-TS, cùng team, monorepo |
Vì sao hr-tool chọn tRPC
Bốn lý do, xếp theo trọng lượng:
- Mọi client đều TS — web React 19, mobile React Native, không có consumer ngoài TS nào. Lợi thế lớn nhất của tRPC áp dụng được 100%, không nửa vời.
- Monorepo một nguồn sự thật — BE và FE nằm cùng repo, cùng release. Vấn đề kinh điển của tRPC là FE/BE buộc release cùng nhau; với hr-tool, đó không phải nhược điểm — đó là thiết kế.
- React Query có sẵn — tRPC không tự chế data-fetching, nó đứng trên TanStack Query. Caching, invalidation, retry: đều là chuẩn đã có, không phải học lại.
- Nhóm nhỏ, tốc độ cao — một người sửa backend và frontend trong cùng ngày. Chi phí “họp, chốt schema, phát hành spec” là overhead của tổ chức lớn; ở đây nó chỉ là friction thuần túy.
Và để công bằng: khi nào không nên dùng tRPC — cần expose API cho đối tác/third-party, có client Python/Java/Go, hoặc BE và FE thuộc hai team release độc lập. Những ca đó, OpenAPI hoặc GraphQL mới là lựa chọn đúng, còn tRPC sẽ thành cây cầu gãy giữa hai bờ. Nó là công cụ cho monorepo full-TS, không phải tôn giáo.
Toàn cảnh: contract đi qua đâu
Monorepo có ba tầng liên quan đến API:
flowchart LR
subgraph BE["apps/api — NestJS"]
CTX["7 bounded contexts"]
RF["router factory, co-located"]
APP["createAppRouter"]
EX["Express adapter /trpc"]
end
subgraph PKG["packages"]
T["api-types — AppRouter .d.ts"]
H["api-client — hooks sinh tự động"]
end
subgraph FE["apps/web + apps/mobile"]
F["feature folders"]
Q["TanStack Query"]
end
CTX --> RF --> APP --> EX
APP -. "type" .-> T
RF -. "ts-morph parse" .-> H
T --> F
H --> F
F --> Q
Điểm đáng chú ý: có hai channel chảy từ backend sang frontend. Type channel (mũi tên mờ) chạy compile-time — AppRouter được re-export qua @hr-tool/api-types. Hook channel cũng mờ, nhưng nó không phải type inference — là một script parse file router rồi in code ra. Hai channel đó là phần thú vị nhất của bài này, để phần sau.
tRPC sống chung với NestJS thế nào
tRPC không phải middleware bạn “cài vào” NestJS một cách thần kỳ. Nó là một express handler, mà NestJS vốn dĩ là express. Vậy nên TrpcModule chỉ việc implement NestModule.configure() rồi gắn adapter vào /trpc:
consumer.apply(
trpcExpress.createExpressMiddleware({
router: createAppRouter(/* services */),
createContext: ({ req, res }) => createContext(req, res),
onError: ({ error, path }) => {
const cause = error.cause?.message ?? error.message;
logger.error(`Error in ${path}: ${error.code} — ${cause}`);
},
})
).forRoutes('/trpc');
Không controller. Không DTO. Không interceptor của Nest. tRPC chiếm nguyên đường /trpc/*, Nest bị giáng cấp xuống DI container — đúng vai mà nó làm tốt nhất. Và vai đó không hề nhỏ: createAppRouter nhận 33 service làm tham số, inject qua constructor của TrpcModule từ 6 aggregator module của các bounded context.
Đây là chỗ đắt nhất của cách làm này, mình nói thẳng luôn: list 33 service đó là nợ có lãi. Thêm module mới là thêm một dòng ở hai chỗ — constructor và lời gọi createAppRouter. Đổi lại, nó là nơi duy nhất trong codebase biết các router được lắp với nhau. Một file duy nhất nắm toàn bộ wiring — nghe rùng rợn, nhưng vẫn tốt hơn mười file rải rác mỗi file nắm một phần.
Context: một lần xác thực cho web và mobile
Context của tRPC tạo mới cho mỗi request, và nó là nơi trả lời câu hỏi “ai đang gọi”. hr-tool có hai client: web đọc JWT từ httpOnly cookie, mobile gửi Authorization: Bearer. Một context phục vụ cả hai, đỡ phải duy trì hai bản auth logic nghe cùng một nội dung:
const token = tokenFromHeader ?? tokenFromCookie;
const payload = jwt.verify(token, deps.jwtSecret) as JwtPayload;
// logout thu hồi token qua denylist theo jti
if (payload.jti && (await getTokenDenylist().isRevoked(payload.jti))) {
throw new Error('Token revoked');
}
const userEntity = await deps.userRepository.findOne({
where: { id: payload.sub },
});
Ba chi tiết đáng kể hơn bản “verify JWT rồi xong”:
- Token denylist theo
jti: access token sống 7 ngày. Không có denylist, token bị đánh cắp sẽ sống sót qua logout thêm một tuần — logout chỉ là nghi thức. Mỗi request trả một lần check Redis; cái giá đổi lấy việc logout có nghĩa. - Không log token: catch block chỉ log
"JWT verification failed"— không log token, không log stack, vì log có tính năng rò token và PII rất ổn định. - Company mode đi theo user:
enterprisehayheadhuntquyết UI và quyền ở nhiều nơi, nên context load sẵn, thay vì mỗi procedure tự query rồi tự quên.
Procedure chain: ba tầng, mỗi tầng một việc
trpc.ts không export một procedure duy nhất — nó export một chuỗi tầng, mỗi tầng xử một class lỗi:
// Tầng 0: NestJS HttpException → TRPCError code
const baseProcedure = t.procedure.use(httpExceptionMiddleware);
// Tầng 1: bắt buộc đăng nhập
export const protectedProcedure = baseProcedure.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { ...ctx, user: ctx.user } }); // user không còn null
});
Tầng 0 vá một lỗi kinh điển khi ghép Nest với tRPC: service của Nest ném HttpException (401, 403, 404…), tRPC không biết đọc, gói tất cả thành 500 INTERNAL_SERVER_ERROR. Gõ sai password mà client nhận 500 “Internal server error” — sai cả semantics lẫn cả manh mối cho người đang debug lúc 2 giờ sáng.
Tầng trên nữa là procedure factory — cửa vào của cổng cộng tác viên (CTV):
export const createCollaboratorProcedure = (resolver: CollaboratorResolver) =>
protectedProcedure.use(async ({ ctx, next }) => {
if (!isCollaboratorRole(ctx.user.role)) throw ...FORBIDDEN;
const collaborator = await resolver.resolveActiveByUser(
ctx.user.id, ctx.user.companyId
);
if (!collaborator) throw ...FORBIDDEN;
return next({ ctx: { ...ctx, collaboratorId: collaborator.id } });
});
Cái hay: resolver được inject chứ không import — trpc.ts giữ một interface tối giản, không phụ thuộc ngược về referral context. Và collaboratorId gắn vào ctx là danh tính duy nhất được tin để scope dữ liệu CTV. Handler không bao giờ nhận collaboratorId từ client — vì client, như mọi khi, không đáng tin.
Ngoài cùng là errorFormatter: prod-like (production, staging) thì xoá stack khỏi payload và thay message của lỗi chưa xử lý bằng "Internal server error". Dev giữ nguyên để debug. Production không cần biết stack trace của mình trông thế nào.
Router factory: contract nằm cạnh business logic
Mỗi module có một file router nằm cùng thư mục với service — ADR-004 của repo gọi nó là co-located routers:
const createNotificationRouter = (
notificationService: NotificationService,
systemLogService: SystemLogService
) =>
router({
list: protectedProcedure
.input(z.object({
unreadOnly: z.boolean().optional().default(false),
page: z.number().min(1).default(PAGINATION.DEFAULT_PAGE),
limit: z.number().min(1).max(PAGINATION.MAX_PAGE_SIZE)
.default(PAGINATION.DEFAULT_LIMIT),
}).optional())
.query(async ({ ctx, input }) => {
return notificationService.list({
userId: ctx.user.id,
companyId: ctx.user.companyId, // multi-tenant scope
...input,
});
}),
markAsRead: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => { /* ... */ }),
});
Router là factory nhận service làm tham số — nó không tự import service. Nơi duy nhất lắp chúng lại là createAppRouter:
export const createAppRouter = (/* services */) =>
router({
health: healthRouter,
auth: createAuthRouter(authService, systemLogService),
job: createJobRouter(jobService, systemLogService),
// ...
});
export type AppRouter = ReturnType<typeof createAppRouter>;
URL ra đời có dạng /trpc/notification.list, /trpc/job.updateStage. Zod schema vừa validate input vừa là nguồn type — một declaration làm hai việc, một tỷ lệ hiệu suất hiếm thấy trong ngành.
Frontend: hai channel, vì sao phải hai
Đây là phần mà hầu hết giới thiệu tRPC lướt qua, vì demo của họ là fullstack-Next trong một package. Trong monorepo Nx mà backend Nest build riêng, type inference cross-package không chạy như quảng cáo.
Channel 1 — type: packages/api-types không infer trực tiếp từ source của apps/api. Nó re-export rồi pre-build thành .d.ts bằng tsc + tsc-alias, và apps/web typecheck trên file build đó:
// packages/api-types/src/index.ts — 9 dòng, nhưng quyết định cả pipeline
export type { AppRouter } from '../../../apps/api/src/trpc/routers';
Nếu web đọc type trực tiếp từ TS source của api, mỗi lần typecheck web phải kéo nguyên dependency graph của backend vào — chậm và giòn, đúng hai thứ không nên có trong pipeline. Pre-build cắt đuôi đó: web chỉ nhìn thấy .d.ts ổn định, việc build lại được nối vào build graph của Nx.
Channel 2 — hook: packages/api-client không chỉ re-export createTRPCReact. Script generate-hooks-auto.ts chạy yarn generate:hooks, dùng ts-morph parse từng file router trong apps/api/src, đọc từng procedure là query hay mutation, rồi sinh hook React Query theo bảng naming convention:
Vì sao không dùng inference của trpc.createTRPCReact<AppRouter> cho hooks? Vì inference cho biết cách gọi, không cho biết tên hook nghe như API được thiết kế. trpc.job.list.useQuery() chạy được — và đọc được như plumbing. useJobList() đọc như một hàm người viết care về đồng nghiệp của mình. Bảng naming còn có override theo từng router (cvMatching.createSession → useCreateMatchingSession), vì convention chung chưa bao giờ phủ hết những cái tên tự nhiên.
Một chi tiết config client đáng kể: web dùng httpBatchLink — các query trong cùng một frame gộp thành một HTTP request. Một màn hình dùng 5 endpoint không cần nói chuyện với server 5 lần, như thể nó là 2005.
FE xài hooks như thế nào: một trang thật
Chỉ kể chuyện generator mà không cho xem bên nhận thì chưa đủ. Đây là code thật từ JobsPage.tsx — trang danh sách job:
import {
useJobList, useDeleteJob, usePartnerList, useInvalidation,
} from '@hr-tool/api';
// query chỉ chạy khi đang ở headhunt mode
const { data: partnersData } = usePartnerList(
{ limit: PAGINATION.MAX_PAGE_SIZE },
{ enabled: isHeadhuntMode }
);
const deleteJob = useDeleteJob();
const handleDelete = async (id: string) => {
try {
await deleteJob.mutateAsync({ id });
toast.success(t('toasts.job.deleteSuccess', { title }));
await invalidate.job();
} catch {
toast.error(t('toasts.job.deleteFailed'));
}
};
Ba điểm đáng đọc kỹ:
- Tham số thứ hai là options chuẩn của React Query.
enabled: isHeadhuntModekhiến query nằm im cho tới khi điều kiện thỏa. Generator phải xử lý đúng cái này thì hook mới dùng được ngoài demo — và nó xử lý, vì hook chỉ là tham chiếu procedure. mutateAsyncthay vìmutate— await được, lỗi bắt bằng try/catch, success và error toast nằm cạnh nhau thay vì rải rác qua callbacks.invalidate.job()— component không biết employee, department, stats cũng đang cache số cũ liên quan đến thao tác này. Invalidation map lo phần đó, component chỉ việc gọi.
Còn “hook” sinh ra thực chất là gì? Mở file generated xem:
export const useJobList = trpc.job.list.useQuery;
Đúng vậy. Generator không đóng gói gì cả — nó chỉ đặt tên lại cho tham chiếu procedure, kèm nguyên vẹn input type từ zod schema của backend. Nghe hơi anticlimatic, nhưng đó là điểm tốt: không có thêm một lớp abstraction để học. Muốn enabled, onError, staleTime? Đều là options chuẩn React Query của hook gốc. Và khi gõ sai input — ví dụ đưa page: "1" thay vì số — TS đỏ ngay tại chỗ gõ, không cần đợi runtime tỏ lòng.
Invalidation: lớp mà không framework nào làm cho bạn
tRPC + React Query giải quyết fetch. Nó không giải quyết câu hỏi “mutation này làm bẩn những query nào?”. queryKeys.ts thêm một lớp invalidation map theo module, dựa trên trpc.useUtils():
employee: async () => {
await Promise.allSettled([
utils.employee.invalidate(),
utils.department.invalidate(),
utils.position.invalidate(),
utils.stats.invalidate(),
]);
},
Tạo employee → invalidate employee, department, position, stats cùng lúc — vì dashboard đếm nhân sự cũng đang cache số cũ. Component chỉ gọi invalidate.employee(), không cần biết list đó tồn tại. Map này là nơi tập trung “hiểu biết về dữ liệu nào dính dáng dữ liệu nào” — và nó chỉ viết được dễ dàng khi mọi query key sinh từ cùng một nguồn.
Cái giá phải trả
Không có kiến trúc free. Bảng này là hoá đơn sau gần một năm production:
| Vấn đề | Thực tế | Cách đang xử lý |
|---|---|---|
Quên chạy generate:hooks sau khi sửa router | Hook mới không tồn tại, TS báo lỗi ngay | Có trong checklist; CI typecheck chặn |
createAppRouter nhận 33 service | List tham số dài, thêm module là thêm 2 dòng | Chấp nhận — đó là chỗ wire duy nhất |
| Client ngoài TS không gọi được | Không có OpenAPI schema cho Swagger | Không cần — tất cả client (web, mobile) đều TS |
| FE/BE luôn release cùng nhau | Type đổi là web build phải theo | Đúng chủ đích của monorepo |
| Router test phải phủ ma trận role | Mỗi endpoint × mỗi role | Router spec 100%, template có sẵn |
Swagger xứng đáng có một lời riêng: nó là tài liệu API vĩ đại nhất thế giới — cho tới ngày nó lỗi một version. Trong monorepo toàn TS, cái phiên bản “luôn đúng” của Swagger chính là compiler. tRPC nhét contract vào compiler, đổi lấy hai bước yarn trong pipeline. Với mình, đó là món hời hiếm có trên thị trường.
Kết
Thêm endpoint mới trong hr-tool đi theo checklist trong ARCHITECTURE.md: service, router factory, test, wire vào aggregator, yarn generate:hooks. Bước dài nhất gần như luôn là viết test — phần code client thì không tồn tại để viết.
Còn quên chạy generate:hooks? TypeScript nhắc ngay khi bạn gõ tên hook, trước cả CI kịp lên tiếng. Đó là toàn bộ cơ chế bảo vệ của contract này: nó nằm trong compiler chứ không nằm trong một trang Confluence được cập nhật lần cuối vào quý trước. Compiler không biết lịch sự, nhưng nó không bao giờ nói dối.
Đọc tiếp
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.