OmniStore — monorepo microservices
Monorepo microservices với gateway NestJS, ba service Go và hai worker Node, nơi toàn bộ topology được sinh ra từ một file registry duy nhất.
- NestJS
- Go
- Turborepo
- Docker
- PostgreSQL
OmniStore là monorepo lớn nhất tôi tự dựng: một gateway NestJS, ba service Go, hai worker Node và hai frontend React/Next, nằm chung trong một workspace pnpm do Turborepo điều phối.
Phần khó không nằm ở việc viết service#
Viết thêm một service không khó. Cái khó là giữ cho topology khỏi trôi dạt. Port
của một service xuất hiện ở ít nhất năm nơi: compose.yml, cấu hình Nginx, route
APISIX, biến môi trường của gateway, và bảng port trong tài liệu. Sửa một chỗ rồi
quên bốn chỗ còn lại là chuyện chắc chắn xảy ra — và nó chỉ lộ ra khi chạy full
stack, tức là lúc tốn thời gian debug nhất.
Nên quyết định kiến trúc đầu tiên của tôi không phải về service, mà về chỗ đặt source of truth.
Toàn bộ topology nằm trong một file#
config/services.yaml mô tả mọi thành phần: port runtime và port local, hostname
trong Docker, đường health check, thư mục làm việc, lệnh dev, và cách một prefix API
công khai ánh xạ xuống service nào.
components:
main-server:
kind: gateway
host: main-server
runtimePort: 3001
localPort: 3001
healthPath: /health
cwd: apps/server/gateways/main-server
devCommand: [pnpm, run, dev]
includeSideServices: true
apiMappings:
- prefix: /notifications
targets:
- service: notification-service
upstreamPrefix: /Sinh ra mọi thứ còn lại#
Từ registry đó, một script duy nhất render ra toàn bộ artifact hạ tầng. Không file
nào trong số này được sửa tay — chúng đều mang header Do not edit.
export function renderArtifacts(registry) {
validateRegistry(registry);
return new Map([
['.generated/compose.env', renderComposeEnv(registry)],
['apps/server/gateways/main-server/.env.services.generated', renderMainServerEnv(registry)],
['infra/apisix/apisix.generated.yaml', renderApisixRoutes(registry)],
['infra/nginx/nginx.generated.conf', renderNginx(registry)],
['docs/SERVICES.generated.md', renderDocs(registry)],
]);
}Điểm tôi thích nhất là pnpm config:check: nó render lại toàn bộ artifact trong bộ
nhớ rồi so với file trên đĩa, lệch một ký tự là fail. CI chạy lệnh này trước
typecheck, lint, test và build. Nhờ vậy việc quên chạy config:generate làm hỏng
pull request, chứ không làm hỏng lúc deploy.
Chỉ một entry point#
Nginx là edge công khai duy nhất, APISIX nhận /api và đẩy về main-server. Các
side-backend không publish port ra host — chúng chỉ nói chuyện qua network nội bộ
của Docker.
main-server gọi side service bằng một client duy nhất, lấy base URL theo service
key trong registry thay vì URL viết cứng, và quan trọng hơn là nó dịch lỗi hạ tầng
thành lỗi HTTP có nghĩa: ECONNABORTED thành 504, mọi lỗi còn lại thành 502. Client
của tôi không cần biết service nào đang chết; nó chỉ cần phân biệt "upstream hỏng"
với "request của bạn sai".
Hai đường xử lý bất đồng bộ cũng nằm sau cùng một facade: bullmq-worker giữ
queue Redis cho email hẹn giờ rồi gọi mail-service viết bằng Go, còn
rabbitmq-worker consume topic exchange notification_exchange rồi đẩy việc sang
notification-service.
Go và Node trong cùng một task graph#
Mỗi service Go có một package.json khai báo build, test, lint trỏ vào
scripts/go-run.mjs. Nhờ lớp mỏng đó, service Go là workspace member thật: Turborepo
xếp lịch chúng cùng phần Node, không cần Makefile riêng hay pipeline song song.
Trong turbo.json, build phụ thuộc ^build và codegen, còn codegen đặt
cache: false vì nó ghi thẳng vào node_modules và .next — cache một output nằm
ngoài thư mục build là cách nhanh nhất để nhận một bản build sai. config/services.yaml
nằm trong globalDependencies, nên đổi topology là invalidate cache toàn workspace,
đúng như kỳ vọng.
Kết quả#
- Thêm một side-backend là khai báo trong registry rồi chạy
config:generate; Nginx, APISIX, env và tài liệu tự khớp theo - CI chặn artifact lệch registry ngay trước khi chạy typecheck, lint, test và build
- Không service phụ nào lộ ra host; mọi request công khai đi qua đúng một facade
- Env thật không bao giờ vào git: một script guard chạy local và một job riêng trong CI đảm bảo điều đó