Skip to content

🏗️ KIẾN TRÚC CORE BE — PHASE 0 (GO MONOLITH)

Scope: Chỉ phần Backend (Go) · Frontend & Crawler ở repo/branch khác Nguyên tắc: Layered monolith — tách rõ HTTP / Service / Repository để sau này tách binary không phải đập đi xây lại.


MỤC LỤC


0 · TỔNG QUAN & TECH STACK

Core BE chịu trách nhiệm toàn bộ nghiệp vụ: auth, campaign, order, payment, settlement. Frontend gọi qua REST.

Hạng mụcChọnLý do
RuntimeGo 1.22+Học Go · binary duy nhất · goroutine cho cron/SSE
HTTP RouterchiGần net/http chuẩn, dễ học, middleware chain rõ ràng
DB Driverpgx/v5Raw pool, không ORM cho hot path — kiểm soát SQL hoàn toàn
CodegensqlcViết SQL → sinh Go typed code. Không mất kiểm soát query
Authgolang-jwt/jwt/v5JWT access + refresh, không cần service ngoài
Configgodotenv + os.GetenvĐơn giản, đủ cho Phase 0
Migrationgolang-migrateSQL thuần, versioned, chạy qua CLI
Validationgo-playground/validatorStruct tag validation, phổ biến nhất
Logginglog/slog (stdlib)JSON structured, không cần lib ngoài
Testtesting + testify + httptestChuẩn Go

Không dùng ở Phase 0

Lý do chưa dùng
ORM (GORM/Ent)Mất kiểm soát SQL hot path, học sqlc đủ
RedisChưa cần cache/queue — in-process goroutine đủ
gRPC/ProtobufChưa tách service, REST là đủ
Framework full (Echo/Fiber/Gin)chi nhẹ hơn, gần stdlib hơn cho người học

1 · KIẾN TRÚC LAYER

┌─────────────────────────────────────────────────────────────┐
│  CLIENT (Frontend / Webhook provider)                       │
└──────────────────────┬──────────────────────────────────────┘
                       │ HTTP/S + JSON

┌─────────────────────────────────────────────────────────────┐
│  ① HTTP LAYER                                               │
│  ┌─ chi Router                                              │
│  ├─ Middleware: RequestID → Logging → CORS → Recover        │
│  ├─ Middleware: Auth (JWT) → RequireAdmin                   │
│  └─ Handlers: parse request → validate → gọi service        │
│     → trả JSON response                                     │
└──────────────────────┬──────────────────────────────────────┘

┌──────────────────────▼──────────────────────────────────────┐
│  ② SERVICE LAYER (business logic — KHÔNG biết HTTP/SQL)     │
│  ├─ AuthService        (login, register, refresh, JWT sign) │
│  ├─ CampaignService    (CRUD, open/close, tier management)  │
│  ├─ OrderService       (reserve slot, anti-oversell)        │
│  ├─ PaymentService     (webhook verify, idempotent)         │
│  ├─ SettlementService  (lock tier, calc amount_due)         │
│  └─ ExpiryService      (cron huỷ đơn quá hạn)               │
└──────────────────────┬──────────────────────────────────────┘
                       │ interface (để mock khi test)
┌──────────────────────▼──────────────────────────────────────┐
│  ③ REPOSITORY LAYER (sqlc generated + pgx pool)             │
│  ├─ UserRepo · CampaignRepo · OrderRepo · PaymentRepo       │
│  └─ Chỉ chứa SQL, KHÔNG có logic nghiệp vụ                  │
└──────────────────────┬──────────────────────────────────────┘
                       │ SQL (pgx)
┌──────────────────────▼──────────────────────────────────────┐
│  ④ PostgreSQL (Source of Truth duy nhất)                    │
└─────────────────────────────────────────────────────────────┘

       ═ In-process goroutine (Phase 0) ═
       ExpiryService chạy ticker 30s, gọi OrderRepo

Luồng 1 request

Request → Middleware chain → Handler
   Handler: bind JSON → validate struct
      → gọi Service (truyền ctx + domain struct)
         Service: business rules → mở transaction nếu cần
            → gọi Repository (1 hoặc nhiều query)
         Service: return domain result / error
      Handler: map error → HTTP status + JSON
   Response ← JSON

Quy tắc: Handler KHÔNG viết SQL. Service KHÔNG import net/http. Repository KHÔNG có if nghiệp vụ. Mỗi layer chỉ biết layer ngay dưới nó.


2 · PROJECT STRUCTURE

gghut-core/
├── cmd/
│   └── api/
│       └── main.go                 # entrypoint: load config → connect DB → run server

├── internal/
│   ├── config/                     # load .env + validate
│   │   └── config.go
│   │
│   ├── server/                     # HTTP wiring
│   │   ├── server.go               # chi router, mount routes
│   │   ├── middleware/
│   │   │   ├── auth.go             # parse JWT → inject user vào ctx
│   │   │   ├── require_admin.go
│   │   │   ├── logging.go
│   │   │   └── recover.go
│   │   └── handler/                # 1 file per domain
│   │       ├── auth.go
│   │       ├── campaign.go
│   │       ├── order.go
│   │       ├── payment_webhook.go
│   │       └── health.go
│   │
│   ├── service/                    # business logic
│   │   ├── auth.go
│   │   ├── campaign.go
│   │   ├── order.go
│   │   ├── payment.go
│   │   ├── settlement.go
│   │   └── expiry.go               # cron goroutine
│   │
│   ├── repository/                 # interfaces + sqlc impl
│   │   ├── user.go
│   │   ├── campaign.go
│   │   ├── order.go
│   │   └── payment.go
│   │
│   ├── model/                      # domain structs + errors
│   │   ├── user.go
│   │   ├── campaign.go
│   │   ├── order.go
│   │   ├── payment.go
│   │   └── errors.go               # domain errors (ErrSlotFull, ErrCampaignClosed...)
│   │
│   └── pkg/                        # shared utilities
│       ├── jwt/                    # sign/verify token
│       ├── response/               # helper viết JSON response
│       ├── ordercode/              # sinh mã đơn "GB-XXXX"
│       ├── r2/                     # Cloudflare R2 client (S3-compatible)
│       └── telemetry/              # tracing + logs qua OpenTelemetry (Grafana Cloud)

├── db/
│   ├── migrations/                 # 000001_init.up.sql / .down.sql
│   ├── queries/                    # *.sql cho sqlc
│   └── sqlc.yaml                   # sqlc config

├── docs/                           # (file này)
├── .env.example
├── go.mod
├── Makefile                        # make run / make migrate / make sqlc / make test
└── Dockerfile

Vì sao internal/: Go enforce internal/ không import được từ ngoài — đảm bảo không ai phụ thuộc vào cấu trúc bên trong. Sau này tách binary, move package ra pkg/ hoặc repo riêng dễ.


3 · API ENDPOINTS

Prefix: /api/v1 · Auth: Authorization: Bearer <JWT>

Auth

MethodPathAuthMô tả
POST/auth/registerĐăng ký (email + password)
POST/auth/loginLogin → trả access + refresh token
POST/auth/refreshrefreshĐổi access token mới
GET/auth/meuserThông tin user hiện tại

Campaign

MethodPathAuthMô tả
GET/campaignsList campaign (OPEN/CLOSED/CANCELLED), filter + paginate
GET/campaigns/:slugChi tiết campaign (kèm tiers, remaining slots, gallery)
POST/admin/campaignsadminTạo campaign (draft)
PUT/admin/campaigns/:idadminSửa (chỉ khi DRAFT)
POST/admin/campaigns/:id/openadminMở bán (DRAFT → OPEN)
POST/admin/campaigns/:id/closeadminĐóng nhóm (OPEN → CLOSED)
POST/admin/campaigns/:id/canceladminHuỷ campaign
POST/admin/campaigns/:id/banneradminUpload ảnh banner (multipart, field image, ≤10MB, jpg/png/webp/gif) — upload mới ghi đè cũ
POST/admin/campaigns/:id/imagesadminThêm nhiều ảnh vào gallery trong 1 request (multipart, field image lặp lại, tối đa 10 ảnh × 10MB)
DELETE/admin/campaigns/:id/images/:imageIDadminXoá 1 ảnh khỏi gallery

Ảnh campaign lưu trên Cloudflare R2 (bucket gghut, S3-compatible API, package internal/pkg/r2). DB chỉ giữ URL public (banner_url, campaign_images.url). Cấu hình qua env R2_ACCOUNT_ID/R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/R2_BUCKET/R2_PUBLIC_BASE_URL — không set R2_ACCOUNT_ID thì endpoint upload trả 503.

Tracing (distributed trace): OTel instrument trong internal/pkg/telemetry — middleware mở span mỗi request (extract W3C traceparent từ FE để nối cùng 1 trace), otelpgx tạo span cho từng query SQL. Xuất OTLP tới Google Cloud Trace (GOOGLE_CLOUD_PROJECT, sample ratio TRACE_SAMPLE_RATIO), xác thực bằng ADC của service account. Chưa set project → otel API chạy noop, không tốn chi phí. FE sau này instrument bằng @opentelemetry là tự nối user → API → DB.

Chi tiết lỗi ngay trên span: tầng response ghi lỗi nghiệp vụ thật (mã + message) vào writer qua interface ErrorRecorder.RecordResponseError — middleware flush thành event trên span (exception + app.error_code, app.http_status), nên waterfall trace hiện lý do lỗi thay vì chỉ con số 500. Mỗi response kèm header X-Trace-Id; mọi log emit bằng slog.XxxContext(ctx,...) tự gắn field trace_id (handler trong telemetry/log.go) — tra cứu log ↔ trace bằng cùng id. Panic + lỗi 5xx cũng được record vào span (status Error) — error tracking chạy trên cùng stack OTel/Cloud Trace, không cần vendor riêng.

Order

MethodPathAuthMô tả
POST/ordersuserĐặt slot (hot path — atomic SQL)
GET/ordersuserĐơn của tôi
GET/orders/:idownerChi tiết đơn (chủ đơn hoặc admin)
GET/admin/ordersadminTất cả đơn, filter theo campaign/status
GET/admin/campaigns/:id/ordersadminĐơn theo campaign
POST/admin/campaigns/:id/settleadminTrigger settlement thủ công (Phase 0)

Payment & Webhook

MethodPathAuthMô tả
POST/webhooks/sepayApikey headerWebhook biến động số dư từ SePay (verify auth → idempotent)

System

MethodPathAuthMô tả
GET/healthzLiveness (process sống)
GET/readyzReadiness (DB connect được)

Response format chuẩn

json
// Success
{ "data": { ... }, "meta": { "page": 1, "total": 42 } }

// Error
{ "error": { "code": "SLOT_FULL", "message": "Campaign đã hết slot" } }

Quy tắc: Mọi error trả về dùng error.code machine-readable để FE xử lý, message human-readable. HTTP status đi kèm (400/401/403/404/409/500).


4 · DATABASE SCHEMA

ERD tổng quan

┌──────────┐        ┌───────────────┐        ┌────────────────┐
│  users   │        │  campaigns    │        │ campaign_tiers │
├──────────┤        ├───────────────┤        ├────────────────┤
│ id       │        │ id            │◄───────┤ campaign_id    │
│ email    │        │ title, slug   │   1:N  │ qty_threshold  │
│ pwd_hash │        │ status        │        │ unit_price     │
│ is_admin │        │ capacity      │        └────────────────┘
└────┬─────┘        │ taken         │
     │ 1:N          │ deposit/unit  │        ┌────────────────┐
     │              │ shipping_fee  │        │ webhook_events │
     ▼              └───────┬───────┘        ├────────────────┤
┌──────────┐                │ 1:N            │ idempotency_key│ PK
│  orders  │◄───────────────┘                │ payload (json) │
├──────────┤                                 └────────────────┘
│ id, code │
│ user_id  │        ┌───────────────┐
│ campaign │        │   payments    │
│ status   │◄───────┤ order_id      │  (N:1)
│ qty      │   1:N  │ kind          │
│ deposit  │        │ amount        │
│ expires  │        │ idempotency_  │
└──────────┘        │   key         │
                    └───────────────┘

Bảng chi tiết

sql
-- ═══ ENUMS ═══
CREATE TYPE campaign_status AS ENUM ('DRAFT','OPEN','CLOSED','CANCELLED');
CREATE TYPE order_status AS ENUM
  ('RESERVED','EXPIRED','DEPOSIT_HELD','SETTLEMENT_DUE','CONFIRMED','CANCELLED');
CREATE TYPE payment_kind AS ENUM ('DEPOSIT','SETTLEMENT');
CREATE TYPE payment_status AS ENUM ('PENDING','SUCCESS','FAILED');

-- ═══ USERS ═══
CREATE TABLE users (
  id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email         TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  full_name     TEXT,
  is_admin      BOOLEAN NOT NULL DEFAULT false,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- ═══ CAMPAIGNS ═══
CREATE TABLE campaigns (
  id               BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title            TEXT NOT NULL,
  slug             TEXT NOT NULL UNIQUE,
  description      TEXT,
  status           campaign_status NOT NULL DEFAULT 'DRAFT',
  deposit_per_unit NUMERIC(12,0) NOT NULL,          -- tiền cọc / 1 sản phẩm
  settlement_window_h INT NOT NULL DEFAULT 72,       -- giờ để trả phần còn lại
  shipping_fee     NUMERIC(12,0) NOT NULL DEFAULT 0,
  capacity         INT NOT NULL,                     -- tổng slot
  taken            INT NOT NULL DEFAULT 0,           -- slot đã đặt
  closes_at        TIMESTAMPTZ,                      -- deadline mở bán
  opened_at        TIMESTAMPTZ,
  closed_at        TIMESTAMPTZ,
  locked_tier_id   BIGINT,                           -- set khi CLOSED (Phase 0 manual)
  version          INT NOT NULL DEFAULT 0,           -- optimistic lock
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  CHECK (taken >= 0 AND taken <= capacity),
  CHECK (deposit_per_unit >= 0)
);

-- ═══ TIERS (giá theo MOQ) ═══
CREATE TABLE campaign_tiers (
  id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  campaign_id   BIGINT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
  qty_threshold INT NOT NULL,                        -- đạt N đơn → giá này
  unit_price    NUMERIC(12,0) NOT NULL,
  UNIQUE (campaign_id, qty_threshold)
);

-- ═══ CAMPAIGN IMAGES (gallery chi tiết — object thật nằm trên R2) ═══
ALTER TABLE campaigns ADD COLUMN banner_url TEXT;    -- banner/cover duy nhất

CREATE TABLE campaign_images (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  campaign_id BIGINT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
  url         TEXT NOT NULL,                         -- URL public trên R2
  sort_order  INT NOT NULL DEFAULT 0,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_campaign_images_campaign ON campaign_images(campaign_id, sort_order);

-- ═══ ORDERS ═══
CREATE TABLE orders (
  id               BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  code             TEXT NOT NULL UNIQUE,             -- mã "GB-XXXX"
  user_id          BIGINT NOT NULL REFERENCES users(id),
  campaign_id      BIGINT NOT NULL REFERENCES campaigns(id),
  status           order_status NOT NULL DEFAULT 'RESERVED',
  qty              INT NOT NULL,
  deposit_due      NUMERIC(12,0) NOT NULL,           -- phải cọc
  deposit_paid     NUMERIC(12,0) NOT NULL DEFAULT 0, -- đã cọc
  expires_at       TIMESTAMPTZ NOT NULL,             -- hết hạn giữ slot
  final_unit_price NUMERIC(12,0),                    -- set khi chốt giá
  amount_due       NUMERIC(12,0),                    -- còn phải trả
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  CHECK (qty > 0),
  CHECK (deposit_due >= 0)
);
CREATE INDEX idx_orders_campaign_status ON orders(campaign_id, status);
CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_orders_expires ON orders(status, expires_at);  -- cho expiry scan

-- ═══ PAYMENTS ═══
CREATE TABLE payments (
  id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id        BIGINT NOT NULL REFERENCES orders(id),
  kind            payment_kind NOT NULL,
  amount          NUMERIC(12,0) NOT NULL,
  status          payment_status NOT NULL DEFAULT 'PENDING',
  provider        TEXT NOT NULL DEFAULT 'SEPAY',
  provider_ref    TEXT,                              -- mã giao dịch từ provider
  idempotency_key TEXT NOT NULL UNIQUE,              -- chống double-process
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_payments_order ON payments(order_id);

-- ═══ WEBHOOK IDEMPOTENCY ═══
CREATE TABLE webhook_events (
  idempotency_key TEXT PRIMARY KEY,
  provider        TEXT NOT NULL,
  payload         JSONB NOT NULL,
  processed_at    TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

Invariant bất biến (DB-enforced hoặc code)

InvariantEnforce ở đâu
taken luôn ∈ [0, capacity]DB CHECK + atomic SQL
deposit_paid ≤ deposit_dueCode (PaymentService)
final_price ≥ depositCode (SettlementService validate trước khi lưu)
Mỗi webhook chỉ xử lý 1 lầnwebhook_events.idempotency_key UNIQUE
Mỗi payment có 1 idempotency keypayments.idempotency_key UNIQUE

5 · BUSINESS FLOWS

5.1 Đặt slot (hot path — chống oversell)

POST /orders { campaign_id, qty }


Handler validate → OrderService.ReserveSlot(ctx, userID, campaignID, qty)

   ▼  MỞ 1 TRANSACTION
┌──────────────────────────────────────────────────────────────┐
│ UPDATE campaigns                                             │
│    SET taken = taken + :qty                                  │
│  WHERE id = :campaign_id                                     │
│    AND status = 'OPEN'                                       │
│    AND taken + :qty <= capacity                              │
│ RETURNING capacity - taken AS remaining;                     │
│                                                              │
│  ↳ 0 rows ⟹ hết slot / không OPEN → rollback, trả SLOT_FULL │
├──────────────────────────────────────────────────────────────┤
│ INSERT INTO orders (code, user_id, campaign_id, qty,         │
│   deposit_due, status, expires_at)                           │
│ VALUES (:code, :uid, :cid, :qty, :qty * deposit_per_unit,    │
│   'RESERVED', now() + interval '15 minutes');                │
├──────────────────────────────────────────────────────────────┤
│ COMMIT                                                       │
└──────────────────────────────────────────────────────────────┘


Trả về order { id, code, deposit_due, expires_at }

Dạng UPDATE ... WHERE taken + qty <= capacity RETURNINGatomic single-statement — không cần SELECT FOR UPDATE, không giữ lock chờ nhau. Đây là vũ khí chống oversell khi nhiều người giành slot cuối.

5.2 Payment webhook (idempotent)

SePay → POST /webhooks/sepay (header: Authorization: Apikey <key>)


① Verify Apikey header         → sai: 401
② INSERT INTO webhook_events (idempotency_key = "sepay:<tx_id>", payload)
   ON CONFLICT (idempotency_key) DO NOTHING

   ├─ rowcount = 0 → trùng, đã xử lý → trả {"success": true} ✅
   └─ rowcount = 1 → mới →
③ Tách mã đơn GB-XXXXXX từ content/description
   └─ không có mã ⟹ log warn + ack 200 (retry vô ích, payload đã lưu để đối soát)
④ applyTransaction(ctx, event)
      MỞ TRANSACTION:
        SELECT * FROM orders WHERE code = :code;           -- tra theo mã đơn
        Kiểm tra status còn 'RESERVED'
        INSERT INTO payments (kind='DEPOSIT', status='SUCCESS', ...)
        UPDATE orders SET status='DEPOSIT_HELD', deposit_paid=:amt
      COMMIT
      mark webhook_events.processed_at


Trả HTTP 200 + {"success": true} (contract SePay; lỗi hạ tầng → 500 để SePay tự retry)

5.3 Deposit expiry (in-process cron — Phase 0)

ExpiryService.Run(ctx):
   ticker := 30s
   mỗi tick:
      MỞ TRANSACTION:
        SELECT id FROM orders
         WHERE status = 'RESERVED' AND expires_at < now()
         FOR UPDATE SKIP LOCKED LIMIT 100;
        --
        UPDATE orders SET status = 'EXPIRED' WHERE id IN (...)
        UPDATE campaigns SET taken = taken - qty  -- trả slot
         WHERE id IN (SELECT campaign_id FROM expired_orders)
      COMMIT

Phase 0 dùng goroutine in-process. Restart server = mất job đang chạy, nhưng vì query idempotent (WHERE status='RESERVED') nên tick sau sẽ bắt lại. Phase 1 chuyển sang Asynq để reliable hơn.

5.4 Settlement (Phase 0 — manual trigger)

Admin POST /admin/campaigns/:id/settle


SettlementService.Settle(ctx, campaignID):
   MỞ TRANSACTION:
     ① SELECT SUM(qty) FROM orders
        WHERE campaign_id=:cid AND status='DEPOSIT_HELD'
        FOR UPDATE;                          -- đóng băng tổng qty
     ② tra bảng tiers → chọn tier phù hợp tổng qty
     ③ UPDATE campaigns SET status='CLOSED', locked_tier_id=:tier
     ④ UPDATE orders SET final_unit_price=:price,
              amount_due = qty * :price + shipping - deposit_paid,
              status='SETTLEMENT_DUE'
        WHERE campaign_id=:cid AND status='DEPOSIT_HELD'
   COMMIT


Admin xem bảng amount_due trên dashboard → thu tiền thủ công
(Sau khi user chuyển khoản, admin xác nhận → CONFIRMED)

Phase 3 tự động hoá bước này bằng SettlementBatch worker. Phase 0 admin làm tay vì shop nhỏ, vài đơn.

5.5 Order State Machine

                    đặt slot (15')
              ┌──────────────────┐
              │     RESERVED     │──── hết hạn (expiry cron) ───▶ EXPIRED
              └────────┬─────────┘
              webhook cọc (idempotent)

              ┌──────────────────┐
              │   DEPOSIT_HELD   │◀── đơn này mới được đếm vào MOQ/tier
              └────────┬─────────┘
              campaign settle (khoá tier)

              ┌──────────────────┐
              │  SETTLEMENT_DUE  │◀── amount_due đã tính
              └───┬──────────┬───┘
        trả đủ    │          │ huỷ / quá hạn
                  ▼          ▼
           ┌───────────┐  ┌────────────┐
           │ CONFIRMED │  │ CANCELLED  │──▶ (refund cọc — Phase 3)
           └───────────┘  └────────────┘

6 · QUYẾT ĐỊNH THEN CHỐT

#Quyết địnhVì sao
1PostgreSQL là source of truth duy nhấtKhông cache quyết định ghi. Redis (sau này) chỉ đọc/queue
2Không ORM, dùng sqlc + pgxHot path cần kiểm soát SQL từng dòng. sqlc vẫn type-safe
3Layered: Handler → Service → RepoTách rõ để test + sau này tách binary không đập đi
4Repository qua interfaceService test bằng mock repo, không cần DB
5Atomic SQL chống oversellUPDATE ... WHERE taken+qty<=capacity — không lock chờ
6Webhook idempotent-firstwebhook_events UNIQUE key — provider retry vô hại
7In-process cron cho expiryPhase 0 đủ đơn giản, không cần Redis. Idempotent nên restart-safe
8internal/ cho toàn bộ codeKhông ai import được từ ngoài — sạch boundary
9JWT access ngắn + refresh dàiAccess 15', refresh 7d lưu DB (để revoke được)
10Error domain ở model/errors.goHandler map error → HTTP status, không leak SQL error ra ngoài
11Observability gom về 1 packageToàn bộ tracing + logs nằm trong internal/pkg/telemetry — call-site không import vendor trực tiếp
12Tracing bằng OTel chuẩn W3CInstrument 1 lần, đổi backend chỉ đổi OTLP endpoint; FE/worker tương lai nối cùng trace qua traceparent

7 · KHI NÀO CẦN ĐỔI

Cấu trúc này cố tình đơn giản cho Phase 0. Dưới đây là những gì thay đổi ở phase sau — không phải việc của bây giờ:

ĐổiỞ PhaseLý do
Expiry cron → Asynq delayed jobPhase 1Reliable khi restart, có retry/backoff
Thêm Redis (queue + cache)Phase 1Chạy Asynq, sau dùng cache
Thêm Notifier (Telegram)Phase 1Tự động thông báo thay vì ngồi canh
SSE Hub (goroutine + channel)Phase 2Realtime đếm slot
Rate Limiter (Redis sliding window)Phase 2Chống spam
Outbox PatternPhase 3Nối state transition với side effects
Settlement → SettlementBatch workerPhase 3Tự động sinh bill hàng loạt
Tách Order Engine (gRPC binary riêng)Phase 4Hot path chịu tải cao
RBAC bool → CasbinPhase 4Permission chi tiết từng action
Thêm workspace_id + RLSPhase 5Multi-tenant

Quy tắc: Chỉ refactor khi phase hiện tại bắt đầu nghẽn. Layered structure ở trên đã chừa sẵn chỗ để tách — không cần viết lại từ đầu.