Level 3–4 · Kiến trúc đẹp mà không vận hành được là bài tập trên giấy. Chương này trả lời: các concern vận hành nằm ở vòng nào, và viết thế nào để chúng không ăn mòn ranh giới.
Nguyên tắc chung của cả chương: concern vận hành là cross-cutting → sống ở adapter/middleware/decorator/composition root; vòng trong cùng lắm biết khái niệm (context, error), không bao giờ biết công cụ (Prometheus, OTel, Viper).
1. Configuration
Config là input của composition root, không phải của component:
// internal/platform/config/config.go
type Config struct {
HTTPAddr string `env:"HTTP_ADDR" default:":8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
KafkaBrokers []string `env:"KAFKA_BROKERS,required"`
RedeemLimit int `env:"REDEEM_DAILY_LIMIT" default:"3"`
ShutdownWait time.Duration `env:"SHUTDOWN_WAIT" default:"10s"`
}
func MustLoad() Config { /* env → struct, fail-fast nếu thiếu */ }
Kỷ luật: (a) đọc một lần lúc khởi động, fail-fast — thiếu config chết ngay với thông báo rõ, không chết lúc 3h sáng khi request đầu chạm code path đó; (b) component nhận giá trị đã bóc (NewService(limit int)), không nhận cả Config — tránh mọi component coupling vào struct config toàn cục; (c) config nghiệp vụ (RedeemLimit) đi vào domain qua constructor như mọi dependency — domain không import package config; (d) secret từ secret manager, cũng tại composition root.
2. Logging
Go 1.21+ có log/slog — structured logging chuẩn, đủ cho production:
// Middleware HTTP: log MỌI request một kiểu — không handler nào tự log request
func LogRequests(log *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(rw, r)
log.InfoContext(r.Context(), "http_request",
"method", r.Method, "path", r.URL.Path,
"status", rw.status, "duration_ms", time.Since(start).Milliseconds())
})
}
}
Câu hỏi kiến trúc: use case có được log không? Quan điểm thực dụng: có, qua *slog.Logger inject (hoặc interface mỏng nếu muốn tuyệt đối) — vì log quyết định nghiệp vụ (“từ chối redeem vì limit”) là log có ngữ nghĩa mà adapter không tái tạo được. Kỷ luật kèm theo: use case log sự kiện nghiệp vụ, adapter log kỹ thuật (SQL chậm, retry); không log trùng một sự kiện ở hai tầng; correlation/trace ID chảy qua context và handler của slog tự đính kèm — code nghiệp vụ không bao giờ tự nhét trace ID.
Đừng log rồi vẫn return error nguyên vẹn lên trên (“log and throw”) — tầng trên lại log → một lỗi 4 dòng log. Quy tắc: lỗi được log đúng một lần, ở nơi xử lý cuối cùng (thường middleware/consumer loop); các tầng giữa chỉ wrap thêm ngữ cảnh (fmt.Errorf("save wallet: %w", err)).
3. Error Handling — hệ thần kinh của kiến trúc
Tổng hợp các mảnh đã rải qua các chương thành một chính sách:
// 1. Domain định nghĩa lỗi NGHIỆP VỤ — sentinel hoặc typed
var ErrInsufficientPoints = errors.New("loyalty: insufficient points")
type ValidationError struct{ Field, Reason string }
func (e ValidationError) Error() string { return e.Field + ": " + e.Reason }
// 2. Adapter DỊCH lỗi hạ tầng → lỗi domain (sql.ErrNoRows → ErrWalletNotFound)
// và phân loại transient để tầng resilience quyết định retry (chương 7)
// 3. Use case WRAP với ngữ cảnh, không nuốt, không dịch
// return fmt.Errorf("redeem for %s: %w", id, err)
// 4. Delivery dịch lỗi domain → giao thức (HTTP status, gRPC code) — bảng tập trung
// 5. Log một lần ở biên; lỗi 5xx kèm stack/trace ID, lỗi 4xx không cần alert
Điều phải tránh tuyệt đối: use case return errors.New("internal error") (nuốt nguyên nhân), hoặc handler errors.Is(err, sql.ErrNoRows) (hạ tầng rò qua hai tầng lên delivery).
4. Observability — Metrics & Tracing
Cùng một pattern decorator/middleware:
- Metrics (Prometheus): RED (Rate, Errors, Duration) ở middleware HTTP/gRPC + decorator quanh cổng (repo, gateway — chương 1.4 đã minh họa). Metric nghiệp vụ (
points_redeemed_total) phát từ event consumer thay vì từ use case — tận dụng event đã có, giữ use case sạch. - Tracing (OpenTelemetry): span mở ở middleware, truyền qua
context.Context— vốn đã chảy xuyên mọi tầng. Adapter mở span con (otelsqlbọc driver DB, interceptor gRPC, instrumented http.Client). Use case có thể mở span nghiệp vụ nếu team muốn — qua interface mỏngTracer— nhưng 80% giá trị nằm ở auto-instrument tầng adapter, miễn phí với vòng trong.
context.Context chính là “đường ống cross-cutting” hợp pháp duy nhất xuyên các vòng: cancellation, deadline, trace ID. Kỷ luật: không nhét business data vào context (userID cho authorization là tham số tường minh, không phải context value).
5. Health Check & Graceful Shutdown
// /healthz — liveness: process sống. /readyz — readiness: sẵn sàng nhận traffic.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil { // + kafka, redis...
http.Error(w, "db: "+err.Error(), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
Shutdown đúng thứ tự — ngược với khởi động, và ngừng nhận trước, xử nốt sau, đóng kết nối cuối:
func run(ctx context.Context, cfg Config) error {
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
// ... khởi tạo db, kafka, server, consumer (errgroup quản lý vòng đời)
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return consumer.Run(gctx) })
g.Go(func() error {
<-gctx.Done() // nhận tín hiệu dừng
shCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownWait)
defer cancel()
return srv.Shutdown(shCtx) // 1. ngừng nhận request mới, đợi in-flight
})
g.Go(func() error {
if err := srv.ListenAndServe(); err != http.ErrServerClosed { return err }
return nil
})
err := g.Wait() // 2. consumer thoát khi ctx hủy (commit offset nốt)
db.Close() // 3. đóng kết nối sau cùng
return err
}
Toàn bộ nằm ở vòng 4 (cmd/) — use case không biết mình đang “được shutdown”; nó chỉ tôn trọng ctx.Done() như mọi khi. Kubernetes lưu ý: readiness fail trước khi SIGTERM xử xong giúp LB rút traffic sớm — thêm cờ shuttingDown cho /readyz.
6. Retry & Idempotency — cặp bài trùng
Retry đã dựng ở chương 7 (decorator, chỉ transient, backoff + jitter, tôn trọng ctx). Nửa còn lại: mọi retry đều tạo nguy cơ thực hiện hai lần — at-least-once là mặc định của thế giới phân tán. Idempotency là câu trả lời, và nó thuộc thiết kế nghiệp vụ, không chỉ kỹ thuật:
// Consumer idempotent: bảng processed_events trong CÙNG transaction với side-effect
func (p *Projector) OnOrderPlaced(ctx context.Context, e OrderPlaced) error {
return p.tx.WithinTx(ctx, func(ctx context.Context) error {
inserted, err := p.markProcessed(ctx, e.EventID) // INSERT ... ON CONFLICT DO NOTHING
if err != nil { return err }
if !inserted { return nil } // đã xử lý — bỏ qua an toàn
return p.applyProjection(ctx, e)
})
}
// API idempotent: client gửi Idempotency-Key; server lưu (key → kết quả)
// Request lặp với cùng key → trả kết quả cũ, KHÔNG chạy lại nghiệp vụ.
// Key sinh ở nơi hiểu ngữ nghĩa "một lần" — thường là client/use case (chương 7).
Checklist idempotency cho từng loại thao tác: ghi trạng thái tuyệt đối (SET balance = x) — tự idempotent; ghi tương đối (balance += x) — cần event ID dedupe; gọi API ngoài có tiền — bắt buộc idempotency key; gửi email — dedupe theo business key (đơn X chỉ 1 mail xác nhận).
7. Anti-patterns
log.Fataltrong thư viện/component — giết process từ nơi không có quyền quyết định; chỉmainđược fatal.- Component tự đọc env (
os.Getenvtrong repo constructor) — config vương vãi, test phải set env, 12-factor giả. - Metric/trace code rải trong use case — 5 dòng đo cho 3 dòng nghiệp vụ; dùng decorator.
- Retry ở 3 tầng chồng nhau (http.Client retry × gateway retry × consumer redelivery = 27 lần gọi) — chọn MỘT tầng sở hữu retry cho mỗi đường đi, thường tầng ngoài nhất hiểu ngữ nghĩa.
- Health check “sâu” gọi cả downstream của downstream — một service cảm cúm, cả cụm readiness fail dây chuyền. Readiness chỉ kiểm dependency trực tiếp và thiết yếu.
- Nuốt ctx.Done(): vòng lặp worker không check ctx — shutdown treo đến khi bị SIGKILL, mất message đang xử lý.
Tóm tắt
- Mọi concern vận hành có một chỗ đúng: config → composition root; log kỹ thuật/metrics/tracing → middleware + decorator; log nghiệp vụ → use case với kỷ luật; shutdown → cmd/; retry → decorator quanh cổng; idempotency → thiết kế nghiệp vụ + dedupe tại consumer.
context.Contextlà đường ống hợp pháp duy nhất xuyên tầng — cancellation và trace, không phải business data.- Lỗi: domain định nghĩa, adapter dịch hai chiều, wrap ở giữa, log một lần ở biên.
Chương tiếp theo: Ví dụ tổng hợp — Xây hệ thống E-commerce từng bước