Hệ sinh thái du lịch tách theo dịch vụ
Tách nền tảng du lịch thành bốn repo — infrastructure, storage, API NestJS và web Next.js — với Authentik giữ danh tính và Makefile dựng toàn bộ phần nền.
- NestJS
- Next.js
- TypeScript
- Docker
- Makefile
Bản làm lại của ứng dụng đặt tour tôi từng viết, lần này không còn là một monolith mà chia thành bốn repo chạy độc lập.
Vì sao không làm tiếp bản monolith#
Bản cũ chạy được: một API Node.js, một MongoDB, phục vụ cả web lẫn mobile. Vấn đề không nằm ở chỗ nó chậm hay hỏng, mà ở chỗ mọi thứ dùng chung một vòng đời. Muốn đổi cách lưu dữ liệu thì phải build lại API. Muốn thay cơ chế đăng nhập thì phải sửa đúng vào nơi đang giữ logic đặt chỗ.
Nên khi làm lại, điểm tôi chọn để cắt không phải là "domain" mà là nhịp thay đổi.
Database gần như đứng yên hàng tháng. Gateway và identity provider đổi vài lần một
quý. API nghiệp vụ thì đổi mỗi ngày. Ba nhịp đó không có lý do gì phải nằm chung một
lần git push.
Bốn repo, bốn vòng đời#
Storage dựng cụm dữ liệu: PostgreSQL cho tiền và đơn, MongoDB cho đánh giá và mô tả tour, Redis cho cache, Qdrant để dành cho tìm kiếm vector sau này. Cả cụm nằm trong một compose project duy nhất, nên nó lên hoặc xuống trọn gói chứ không lên lẻ tẻ từng con.
Infrastructure dựng phần xung quanh: APISIX làm gateway (kèm etcd và dashboard),
Authentik làm identity provider, RabbitMQ làm chỗ đi cho event. Mỗi thứ một thư mục,
mỗi thư mục một compose.yml riêng.
Business services là API NestJS, và Web là Next.js App Router có routing theo ngôn ngữ. Hai repo này là nơi tôi thực sự viết code hằng ngày.
Hạ tầng gọi bằng một lệnh#
Điều tôi muốn tránh là một Makefile phải sửa mỗi lần thêm thành phần mới. Nên nó tự dò thư mục thay vì liệt kê sẵn:
# Mỗi thư mục con có compose.yml đều là một thành phần hạ tầng.
COMPOSE_DIRS := $(sort $(dir $(wildcard */compose.yml)))
SERVICES := $(COMPOSE_DIRS:/=)
up:
@for svc in $(SERVICES); do \
docker compose -f $$svc/compose.yml --project-directory $$svc up -d; \
done
# up-apisix, up-authentik, up-rabbitmq... sinh tự động từ SERVICES.
$(addprefix up-,$(SERVICES)): up-%:
@docker compose -f $*/compose.yml --project-directory $* up -dThêm một thành phần mới chỉ là tạo thêm thư mục. Không có danh sách nào để quên cập nhật — đúng thứ tôi cần khi phần nền có nhiều mảnh hơn khả năng nhớ của mình.
Danh tính nằm ngoài API#
Đây là quyết định tôi thấy đáng giá nhất. API không lưu mật khẩu, không phát token,
không có bảng password_hash. Toàn bộ việc đó thuộc về Authentik.
Web chạy Authorization Code kèm PKCE, nhưng bước đổi code lấy token thì đi vòng qua
backend, để client_secret không bao giờ rơi vào trình duyệt. Còn API chỉ làm một
việc: xác minh chữ ký token bằng public key lấy từ JWKS.
@Injectable()
export class AuthentikJwtStrategy extends PassportStrategy(
Strategy,
'authentik-jwt',
) {
constructor() {
const env = getEnv();
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
/* Khóa công khai lấy từ JWKS của Authentik rồi cache tại chỗ. */
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 10,
jwksUri: env.authentikJwksUri,
}),
audience: env.authentikAudience,
issuer: env.authentikIssuer,
});
}
}Bảng users trong Postgres vì thế chỉ là cái bóng: nó giữ authentik_id lấy từ
claim sub, cộng thêm avatar và số điện thoại — những thứ thuộc về nghiệp vụ chứ
không thuộc về danh tính. Mỗi request đã xác thực sẽ upsert lại record này, nên
foreign key từ bookings và payments luôn có chỗ để trỏ tới.
Dữ liệu chia theo hình dạng#
Tour, đơn đặt và thanh toán nằm ở Postgres vì chúng cần foreign key và ràng buộc
CHECK cho trạng thái. Đánh giá, lịch trình chi tiết và nhật ký hoạt động nằm ở
MongoDB vì mỗi record một hình dạng. Redis cache danh sách tour, và khi Redis không
lên thì service tự tắt cache sau ba lần thử lại thay vì chết theo.
Kết quả#
- Bốn repo tách theo nhịp thay đổi, phần nền dựng lại từ đầu bằng
make up - Đăng nhập, phân quyền theo group và đồng bộ người dùng chạy thật qua Authentik
- API có tour, đơn, đặt chỗ, thanh toán (Stripe/VNPay), đánh giá và upload ảnh
- Web mới nối API ở flow đăng nhập, hồ sơ và trang quản trị; danh sách tour và thuê xe vẫn đang chạy trên mock data
- Event RabbitMQ mới ở mức phát đi, chưa có consumer; Qdrant dựng sẵn nhưng API chưa dùng tới