Chào mừng trở lại với bản tin Tech Radar. Tuần trước chúng ta đã mổ xẻ cách Kratos và Dapr v1.15 giải quyết Xung đột Trạng thái qua ETags. Tuần này chúng ta sẽ đi sâu hơn một tầng: làm thế nào để cấu trúc toàn bộ codebase sao cho Kratos, Wire, và Dapr Pub/Sub kết hợp một cách gọn gàng — và làm thế nào để giữ cho kiến trúc đó dễ test, bền bỉ và an toàn trên môi trường production?
1. Bốn Phân Lớp của Kratos Clean Architecture
Answer-first: Kratos áp đặt một Clean Architecture gồm bốn lớp — api, service, biz, và data — nơi logic nghiệp vụ trong biz được cách ly hoàn toàn khỏi transport và cơ sở hạ tầng. Mỗi lớp chỉ giao tiếp với lớp liền kề nó, và chỉ thông qua các interface.
Đây không phải là một lựa chọn về phong cách code. Nó là một ràng buộc cứng được tích hợp sẵn trong template kratos-layout:
| Lớp | Trách nhiệm | Những thứ KHÔNG ĐƯỢC chạm vào |
|---|---|---|
api | Định nghĩa Protobuf — sinh code HTTP & gRPC | Logic nghiệp vụ, DB |
service | Adapter — map DTO ↔ Domain Model, gọi biz | *gorm.DB, Redis |
biz | Domain Models, Usecases, các interface Repository | Bất kỳ driver DB cụ thể nào |
data | Triển khai (implement) các interface của biz — GORM, Redis, Dapr SDK | Quy tắc nghiệp vụ |
Ranh Giới Sống Còn: biz Không Bao Giờ Thấy *gorm.DB
Anti-pattern phổ biến nhất trong các dự án Kratos là rò rỉ *gorm.DB trực tiếp vào lớp biz. Điều này vi phạm Nguyên lý Đảo ngược Phụ thuộc (Dependency Inversion Principle) và làm cho việc viết unit test trở nên bất khả thi nếu không có database thật.
Pattern đúng: biz khai báo một interface, data triển khai nó.
// internal/biz/order.go — biz định nghĩa giao kèo (contract)
type OrderRepo interface {
CreateOrder(ctx context.Context, o *Order) error
}
type OrderUsecase struct {
repo OrderRepo
}
// internal/data/order.go — data triển khai giao kèo
type orderRepo struct {
data *Data // chứa *gorm.DB bên trong
}
func (r *orderRepo) CreateOrder(ctx context.Context, o *biz.Order) error {
return r.data.db.WithContext(ctx).Create(o).Error
}
Lúc này biz hoàn toàn độc lập với database. Đổi PostgreSQL sang MySQL — chỉ lớp data phải thay đổi.
2. Google Wire: Dependency Injection Lúc Biên Dịch
Answer-first: Wire là một công cụ sinh code lúc biên dịch (compile-time) để giải quyết toàn bộ đồ thị phụ thuộc (dependency graph) của service Kratos. Nó loại bỏ việc đấu nối thủ công, bắt các phụ thuộc bị thiếu ngay lúc build (không phải lúc runtime), và sinh ra mã khởi tạo với zero-overhead.
Cách Wire Cấu Trúc Các Provider Của Kratos
Mỗi lớp bộc lộ một ProviderSet khai báo các hàm khởi tạo (constructors):
// internal/data/data.go
var ProviderSet = wire.NewSet(NewData, NewOrderRepo)
// internal/biz/biz.go
var ProviderSet = wire.NewSet(NewOrderUsecase)
// internal/service/service.go
var ProviderSet = wire.NewSet(NewOrderService)
Điểm entry point sẽ nối tất cả chúng lại với nhau:
// cmd/server/wire.go
//go:build wireinject
func initApp(cfg *conf.Bootstrap, logger log.Logger) (*kratos.App, func(), error) {
panic(wire.Build(
server.ProviderSet,
data.ProviderSet,
biz.ProviderSet,
service.ProviderSet,
))
}
Chạy wire gen ./cmd/server/ và Wire sinh ra file wire_gen.go — một file Go bình thường với tất cả các constructors được gọi theo đúng thứ tự. Không dùng reflection. Không tốn chi phí runtime.
Cạm Bẫy Wire Mà Các Công Cụ AI Thường Bỏ Qua
Các công cụ sinh code AI (ChatGPT, Copilot) thường xuyên sinh ra cấu hình Wire có thể biên dịch được nhưng lại âm thầm tạo ra các singleton trùng lặp — ví dụ, khởi tạo hai kết nối *gorm.DB riêng biệt vì NewDB bị liệt kê trong hai ProviderSet khác nhau. Hãy luôn kiểm tra wire_gen.go sau khi sinh code và xác nhận mỗi dependency chỉ xuất hiện chính xác một lần trong kết quả cuối.
3. Dapr Pub/Sub: Tách Rời Event Bus Khỏi Code Của Bạn
Answer-first: Khối (building block) Pub/Sub của Dapr trừu tượng hóa message broker (Redis Streams, Kafka, RabbitMQ) đằng sau một API sidecar. Service Kratos của bạn publish và subscribe thông qua Dapr Go SDK — broker lúc này chỉ là một file cấu hình YAML, không phải là một dependency trong code.
Publishing từ lớp biz
Inject Dapr client dưới dạng interface EventPublisher (định nghĩa trong biz, triển khai trong data):
// internal/biz/order.go — interface ở lại trong biz
type EventPublisher interface {
PublishOrderCreated(ctx context.Context, order *Order) error
}
func (uc *OrderUsecase) CreateOrder(ctx context.Context, req *CreateOrderReq) error {
order := &Order{ /* ... */ }
if err := uc.repo.CreateOrder(ctx, order); err != nil {
return err
}
// ctx mang theo traceparent header — Dapr sẽ tự truyền nó vào CloudEvents
return uc.publisher.PublishOrderCreated(ctx, order)
}
// internal/data/publisher.go — data bọc Dapr SDK lại
type daprPublisher struct {
client dapr.Client
}
func (p *daprPublisher) PublishOrderCreated(ctx context.Context, o *biz.Order) error {
return p.client.PublishEvent(ctx, "order-pubsub", "order.created", o)
}
Subscribing qua Programmatic Endpoint
Dapr khám phá các đăng ký (subscriptions) lúc khởi động bằng cách gọi GET /dapr/subscribe trên service của bạn. Đăng ký route này trong HTTP server của Kratos:
// Phải trả về đúng cấu trúc JSON này
[
{
"pubsubname": "order-pubsub",
"topic": "order.created",
"route": "/api/v1/orders/webhook"
}
]
Dapr sau đó chuyển tiếp các sự kiện tới POST /api/v1/orders/webhook. Trích xuất phần lõi từ CloudEvents envelope — không đọc các byte body thô:
func (s *OrderService) HandleOrderCreatedWebhook(w http.ResponseWriter, r *http.Request) {
var ce cloudevents.Event
if err := json.NewDecoder(r.Body).Decode(&ce); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
var payload biz.Order
_ = ce.DataAs(&payload)
// xử lý logic...
w.WriteHeader(http.StatusOK)
}
Quan trọng: Để bỏ qua vĩnh viễn một message bị lỗi (bảo Dapr ngừng retry), hãy trả về HTTP 200 với body {"status":"DROP"}. Trả về HTTP 500 sẽ kích hoạt chính sách retry của Dapr từ file resiliency.yaml.
4. Vấn Đề Dual-Write: Dapr Transactional Outbox
Answer-first: Lưu vào database và publish một sự kiện là hai thao tác I/O riêng biệt. Nếu broker bị sập sau khi ghi DB thành công, sự kiện sẽ bị mất. Dapr v1.12+ bao gồm Transactional Outbox tích hợp sẵn giúp cả hai thao tác trở thành nguyên tử (atomic) — không cần tạo bảng outbox thủ công hay worker chạy nền.
Lỗi Cổ Điển
Hầu hết các service Kratos gọi db.Create() rồi tới client.PublishEvent() tuần tự. Nếu broker không khả dụng giữa hai lệnh gọi đó, bản ghi DB đã tồn tại nhưng không có service nhận nào được thông báo. Hệ thống hiện đang bất nhất một cách im lặng.
Giải Pháp Tích Hợp Sẵn Của Dapr
Bật outbox trên YAML cấu hình State Store:
# components/statestore.yaml
metadata:
- name: outboxPublishPubsub
value: "order-pubsub"
- name: outboxPublishTopic
value: "order.created"
Sau đó thay thế lệnh ghi 2 bước bằng một lệnh gọi giao dịch duy nhất:
ops := []*dapr.StateOperation{
{
Type: dapr.StateOperationTypeUpsert,
Item: &dapr.SetStateItem{Key: orderKey, Value: orderData},
},
}
// Dapr đảm bảo: DB write + event publish = một ACID transaction
err := client.ExecuteStateTransaction(ctx, "statestore", meta, ops)
Nếu broker tạm thời không thể truy cập, Dapr sẽ retry việc publish cho đến khi thành công. Code của bạn không phải bảo trì bất kỳ logic retry nào.
5. Q&A: Những Cạm Bẫy Môi Trường Thực Tế
biz handler của bạn phải triển khai tính lũy đẳng (idempotency). Trích xuất trường id từ CloudEvent đến và kiểm tra nó trên database (GORM FirstOrCreate hoặc Redis SET NX) trước khi chạy logic nghiệp vụ. Nếu ID đã tồn tại, trả về HTTP 200 — Dapr sẽ không gửi lại.EventPublisher là một interface định nghĩa trong biz, bạn có thể mock nó với gomock trong lúc test. Dapr SDK client thật nằm hoàn toàn bên trong data. Các unit test biz của bạn không bao giờ đụng tới sidecar — chúng chạy nhanh như bất kỳ test Go thuần nào khác.tracing.Server() của Kratos trích xuất traceparent header từ các request HTTP đến vào context.Context. Hãy truyền đúng ctx đó vào mọi lệnh gọi Dapr — client.PublishEvent(ctx, ...), client.ExecuteStateTransaction(ctx, ...). Dapr sẽ nhúng trace context vào CloudEvents envelope, do đó các subscriber bên dưới sẽ tự động nhận được span tương quan.terminationGracePeriodSeconds trên Pod bằng một giá trị lớn hơn dapr.io/graceful-shutdown-seconds. Điều này đảm bảo sidecar sống đủ lâu để Server.Stop() của Kratos hoàn thành việc xử lý các webhook event đang dang dở trước khi sidecar thoát.kratos/v2/transport/dapr chính thức nào cả. Các công cụ sinh code AI rất hay bị “ảo giác” (hallucinate) ra lớp tích hợp này. Cách làm đúng là sử dụng dapr/go-sdk client tiêu chuẩn, bọc nó sau một interface do biz sở hữu, và inject nó qua Wire. Không có module Dapr transport nào là native của Kratos cả.Tiếp tục chuỗi bài viết với các phân tích sâu về Microservices với Dapr và trọn bộ System Design Series. Radar tiếp theo sẽ nói về Dapr Workflow và mô hình Actor cho orchestration trạng thái.
📬 Nhận Tech Radar hàng tuần — không spam, chỉ signal: Đăng ký tại đây.