Domain-Driven Design · Go · PostgreSQL · Redis

Restaurant Queue Management System

ทำไมถึงต้องเป็นระบบนี้?
ปัจจุบันมีบริษัทที่ขาย POS และระบบคิวมากมาย แต่ยังไม่มีใครเลยที่จำลองทุกสถานการณ์จริงในหน้าจอเดียว ระบบนี้คือ ผู้บุกเบิก — ออกแบบเพื่อให้นักพัฒนาเรียนรู้ logic ของระบบคิว ร้านอาหาร และ POS ทั้งหมดผ่าน API จริง แบบ end-to-end

1. ทำไมต้อง DDD?

ปัญหาของการออกแบบแบบเดิม (Anemic Domain Model)

❌ แบบเดิม (Transaction Script)
Controller → Service → Repository → Database
 ↓
 สิ่งที่เกิดขึ้น:
 - Logic ทุกอย่างกระจุกที่ Service layer
 - Model เป็นแค่ data container (struct ว่างๆ)
 - ไม่มีกฎธุรกิจป้องกันตัวเอง
 - State ผิดพลาดได้ทุกเมื่อ (เช่น ticket "called" ซ้ำ)
 - Test ยาก เพราะ Logic ผูกกับ Database

DDD แก้ปัญหาอย่างไร?

✅ DDD (Rich Domain Model)
Controller → Application Service → Domain Entity (มี Logic ในตัว) → Repository
 ↓
 ผลลัพธ์:
 - Entity ป้องกันตัวเองด้วย State Machine
 - Business Rule อยู่ใกล้ Data ที่สุด
 - ทดสอบ Logic ได้โดยไม่ต้องเชื่อม Database
 - Ubiquitous Language — โค้ดพูดภาษาเดียวกับ Domain Expert
 - ไม่มีทางสร้าง State ผิดพลาดได้จาก outside

สามประเภทของ Domain

ประเภท Domain ในระบบนี้ ความสำคัญ
Core Domain (ความได้เปรียบทางธุรกิจ) Queue Management, Table & Session สูงสุด — คือเหตุผลที่ระบบนี้มีอยู่
Supporting Domain (สนับสนุน Core) Order Management, Menu Management สูง — ใช้งานได้แต่ซื้อหรือสร้างเองก็ได้
Generic Domain (ทั่วไป) Identity & Auth, Audit Log ต่ำ — ใช้ Library หรือ Service สำเร็จรูปได้

2. Domain Map

แผนภาพนี้แสดง ทุก Subdomain ในระบบ และความสัมพันธ์ระหว่างกัน

┌─────────────────────────────────────────────────────────────────────────┐
│ RESTAURANT QUEUE SYSTEM — DOMAIN MAP │
│ │
│ ┌──────────────────── CORE DOMAIN ────────────────────────────────┐ │
│ │ │ │
│ │ ┌────────────────────┐ ┌────────────────────────────┐ │ │
│ │ │ Queue Management │◄────►│ Table & Session Mgmt │ │ │
│ │ │ │ │ │ │ │
│ │ │ • QueueTicket │ │ • Table │ │ │
│ │ │ • Queue Stats │ │ • TableSession │ │ │
│ │ │ • NoShow Logic │ │ • QRGuest │ │ │
│ │ │ • Position Mgmt │ │ • BuffetExpiry Worker │ │ │
│ │ │ • NoShow Worker │ │ │ │ │
│ │ └───────────┬────────┘ └──────────────┬─────────────┘ │ │
│ │ │ │ │ │
│ └───────────────┼───────────────────────────────┼───────────────────┘ │
│ │ │ │
│ ┌───────────────┼─── SUPPORTING DOMAIN ─────────┼───────────────────┐ │
│ │ ▼ ▼ │ │
│ │ ┌────────────────────┐ ┌────────────────────────────┐ │ │
│ │ │ Order Management │◄────►│ Menu Management │ │ │
│ │ │ │ │ │ │ │
│ │ │ • Order │ │ • MenuCategory │ │ │
│ │ │ • OrderItem │ │ • MenuItem │ │ │
│ │ │ • QR Ordering │ │ • Buffet vs AlaCarte │ │ │
│ │ └────────────────────┘ └────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────┐ ┌────────────────────────────┐ │ │
│ │ │ Restaurant Mgmt │ │ Feature Toggle │ │ │
│ │ │ • Restaurant │ │ • Dynamic feature flags │ │ │
│ │ │ • Multi-tenant │ │ • No restart required │ │ │
│ │ └────────────────────┘ └────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────── GENERIC DOMAIN ────────────────────────────────┐ │
│ │ │ │ │
│ │ ┌────────────────────┐ ┌────────────────────────────┐ │ │
│ │ │ Identity & Auth │ │ Audit Log │ │ │
│ │ │ • User │ │ • AuditLog (Immutable) │ │ │
│ │ │ • JWT + 2FA │ │ • every critical action │ │ │
│ │ │ • Session (Redis) │ │ │ │ │
│ │ └────────────────────┘ └────────────────────────────┘ │ │
│ │ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘

3. Bounded Context Map

Bounded Context คือ "ขอบเขต" ที่ภาษาและ Model มีความหมายชัดเจน ภายในขอบเขตนั้น

┌──────────────────────────────────────────────────────────────────────┐
│ BOUNDED CONTEXT MAP │
│ │
│ ┌─────────────────┐ Conform ┌──────────────────────────────┐ │
│ │ Auth Context │──────────────►│ Restaurant Context │ │
│ │ │ │ (Multi-tenant owner) │ │
│ │ User │ │ Restaurant, Table │ │
│ │ JWT Claims │ │ FeatureToggle │ │
│ │ 2FA OTP │ └────────────┬─────────────────┘ │
│ └─────────────────┘ │ │
│ │ Customer/Staff │
│ ┌───────────────────┼────────────────┐ │
│ ▼ ▼ │ │
│ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ Queue Context (Core) │ │ │
│ │ │ │ │
│ │ QueueTicket (Waiting→Called→Seated→Completed/Cancelled/ │ │ │
│ │ Expired/NoShow) │ │ │
│ │ QueueStats, Position, NoShowWorker │ │ │
│ └───────────────────────────┬──────────────────────────────────┘ │ │
│ │ "Seat(tableID)" │ │
│ ▼ │ │
│ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ Table & Session Context (Core) │ │ │
│ │ │ │ │
│ │ Table (Vacant→Occupied→Clearing→Vacant/Disabled) │ │ │
│ │ TableSession (QR Token, Buffet timer, OrderCutoff) │ │ │
│ │ QRGuest (individual identity per table) │ │ │
│ │ BuffetExpiryWorker │ │ │
│ └───────────────────────────┬──────────────────────────────────┘ │ │
│ │ "canOrder(sessionToken)" │ │
│ ▼ │ │
│ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ Order Context (Supporting) │ │ │
│ │ │ │ │
│ │ Order (Pending→Confirmed→Preparing→Served/Cancelled) │ │ │
│ │ OrderItem (Pending→Preparing→Ready→Served/Cancelled) │ │ │
│ └───────────────────────────┬──────────────────────────────────┘ │ │
│ │ "getMenu(restaurantID)" │ │
│ ▼ │ │
│ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ Menu Context (Supporting) │ │ │
│ │ MenuCategory, MenuItem │ │ │
│ │ Buffet mode / AlaCarte mode toggle │ │ │
│ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │
│ ┌─────────────Cross-Cutting Concerns (Generic)───────────────────┐ │
│ │ AuditLog (immutable, every context writes here) │ │
│ │ CacheFront (Redis read-through cache, all contexts read here) │ │
│ │ EventHub (SSE broadcast, all contexts publish here) │ │
│ └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘

Context Integration Patterns

From Context To Context Pattern รายละเอียด
Auth Restaurant Conformist Restaurant ใช้ UserID จาก Auth โดยตรง
Queue Table/Session Customer/Supplier Queue เรียก Seat() → สร้าง TableSession
Table/Session Order Published Language QR Token คือ "ภาษากลาง" ระหว่าง Session กับ Order
All AuditLog Open Host Service ทุก Context เขียน AuditLog ผ่าน AuditLogRepository
All EventHub Open Host Service ทุก Context publish event ผ่าน EventHub.Broadcast()
Service CacheFront Shared Kernel CacheFront ใช้ร่วมกันทุก Service

4. Core Domain: Queue Management

Aggregate Root: QueueTicket

QueueTicket คือ Aggregate Root หลักของ Queue Context มีหน้าที่รักษา invariant ทั้งหมด

State Machine ของ QueueTicket

 ┌─────────────┐
 ┌───►│ CANCELLED │
 │ └─────────────┘
 │
 ┌──────────┐ Call() │ ┌──────────┐ Seat(tableID) ┌──────────┐ Complete() ┌───────────┐
 │ WAITING │──────────┼─►│ CALLED │────────────────►│ SEATED │─────────────►│ COMPLETED │
 └──────────┘ │ └──────────┘ └───────────┘
 │ │ │
 Cancel() │ Cancel()
 Expire() │ Expire()
 │ │ MarkNoShow() (auto by NoShowWorker)
 ▼ │ │
 ┌──────────┐ │ ▼
 │ EXPIRED │ │ ┌──────────┐
 └──────────┘ │ │ NO_SHOW │
 └─►└──────────┘
 (Seat จาก Waiting ได้ด้วย — walk-in direct)

Terminal States (สถานะสุดท้าย ไม่สามารถเปลี่ยนได้อีก)

Status ความหมาย ถูกตั้งโดย
completed ลูกค้าทานเสร็จ จ่ายเงินแล้ว Staff กดปิด session
cancelled ลูกค้าหรือ Staff ยกเลิก User / Staff
expired หมดเวลารอคิว (เช่นระบบ purge คิวเก่า) System
no_show ถูกเรียกแล้วไม่มา จนเกิน timeout NoShowWorker (auto)

Invariants ที่ Entity บังคับ (ไม่ผ่าน Database)

// ตัวอย่าง: ไม่มีทางเรียก Call() บน ticket ที่ไม่ใช่ "waiting"
func (t *QueueTicket) Call() error {
 if t.Status != QueueTicketStatusWaiting {
 return ErrInvalidStateTransition // ← ป้องกัน state ผิดพลาด 100%
 }
 // ...
}

Queue Domain Rules (Business Invariants)

  1. Idempotency Key: ทุก ticket มี idempotency_key — ป้องกัน double-submit
  2. Optimistic Locking: version field ป้องกัน race condition เมื่อหลาย staff เรียกคิวพร้อมกัน
  3. Party Size: 1–50 คน (เกินกว่านี้จะ reject ที่ domain level)
  4. Position Management: เมื่อ ticket เปลี่ยนสถานะ ตำแหน่งคิวจะถูก recalculate
  5. No-Show Auto-Mark: ถ้า called เกิน QUEUE_NOSHOW_TIMEOUT_MIN นาที — NoShowWorker จะเปลี่ยนเป็น no_show อัตโนมัติ

QueueTicket Validation Rules

CustomerName : required (ไม่ว่างเปล่า)
PartySize : 1–50 (บังคับที่ domain layer ก่อนถึง DB)
EntryMode : "walk_in" | "remote" (enum ที่ validate ด้วย IsValid())
DiningType : "buffet" | "alacarte" (enum ที่ validate ด้วย IsValid())
IdempotencyKey: unique index ที่ DB — ป้องกัน duplicate

5. Core Domain: Table & Session Management

Aggregate Roots: Table และ TableSession

Table และ TableSession เป็น Aggregate แยกกัน มีความสัมพันธ์ผ่าน TableID

State Machine ของ Table

 ┌──────────────────────────────────────────┐
 │ │
 ┌─────────▼──┐ Occupy() ┌──────────┐ StartClearing() ┌──────────┐
 │ VACANT │───────────►│ OCCUPIED │─────────────────►│ CLEARING │
 └─────────┬──┘ └──────────┘ └────┬─────┘
 │ │ │
 Disable() Disable() FinishClearing()
 │ │ │
 ▼ │ ▼
 ┌──────────┐ ├──────────────────────► VACANT (loop)
 │ DISABLED │ │
 └────┬─────┘ (ห้าม Disable table ที่ OCCUPIED)
 │
 Enable()
 │
 ▼
 VACANT

State Machine ของ TableSession (Buffet vs AlaCarte)

 ┌────────────────────────────────────────────────┐
 │ SESSION LIFECYCLE │
 │ │
 Seat(tableID) │ ACTIVE SESSION │
 ──────────────────►│ │
 │ ┌────────────────────────────────────────────┐│
 │ │ IsQRTokenValid() = true ││
 │ │ CanOrder() = true / false (cutoff check) ││
 │ │ ││
 │ │ [BUFFET] [A LA CARTE] ││
 │ │ ExpiresAt = set ExpiresAt = nil ││
 │ │ OrderCutoffAt = set OrderCutoffAt = nil ││
 │ │ WarningAt = set WarningAt = nil ││
 │ └────────────────────────────────────────────┘│
 │ │
 │ ┌────────────┼────────────┐ │
 │ ▼ ▼ ▼ │
 │ RequestBill() Expire() Close() │
 │ (BuffetWorker) (Staff) │
 │ └────────────┼────────────┘ │
 │ ▼ │
 │ SESSION CLOSED │
 │ IsActive = false │
 └────────────────────────────────────────────────┘

QRGuest — Identity ภายใน Session

TableSession (1) ──── (many) QRGuest
 │
 └── Nickname (เช่น "พ่อ", "น้องแก้ว")
 └── GuestToken (unique per guest scan)
 └── Orders ของ guest คนนี้

ประโยชน์:
- ลูกค้าสแกน QR แล้วตั้งชื่อตัวเอง
- ครัวรู้ว่าออเดอร์นี้ของใคร
- คิดเงินแยกตามคนได้
- ป้องกัน race condition: guest_token ไม่ซ้ำ

6. Supporting Domain: Order Management

Aggregate Root: Order (contains OrderItem)

Order คือ Aggregate Root — OrderItem เป็น Value Object ภายใน Order เดียวกัน ต้องเปลี่ยนผ่าน Order เท่านั้น

State Machine ของ Order

 ┌───────────┐
 ┌─────────┐ Confirm() ┌───────────┐ │ CANCELLED │
 │ PENDING │────────────►│ CONFIRMED │──► └───────────┘
 └─────────┘ └─────┬─────┘ CanCancel()=true
 │
 StartPreparing()
 │
 ▼
 ┌────────────┐ CancelOrder() ┌───────────┐
 │ PREPARING │──────────────────►│ CANCELLED │
 └─────┬──────┘ └───────────┘
 │
 MarkServed()
 │
 ▼
 ┌────────────┐
 │ SERVED │ (Terminal — ห้าม Cancel)
 └────────────┘

State Machine ของ OrderItem

 ┌─────────┐ StartPreparing() ┌────────────┐ MarkReady() ┌───────┐ MarkServed() ┌────────┐
 │ PENDING │───────────────────►│ PREPARING │──────────────►│ READY │──────────────►│ SERVED │
 └────┬────┘ └─────┬──────┘ └───┬───┘ └────────┘
 │ │ │
 CancelItem() CancelItem() CancelItem()
 │ │ │
 └───────────────────────────────┴──────────────────────────┘
 │
 ▼
 ┌───────────┐
 │ CANCELLED │ (Terminal)
 └───────────┘

Business Rules สำหรับ Order

  1. Session Gate: ก่อน place order ต้อง session.CanOrder() == true (ป้องกัน order หลังหมดเวลาบุฟเฟ่ต์)
  2. Buffet Menu Only: รายการที่ is_buffet_only = true จะสั่งได้เฉพาะเมื่อ DiningType = buffet
  3. Price Snapshot: Price ของ OrderItem คือ snapshot ณ เวลาที่สั่ง ป้องกันการเปลี่ยนราคา menu หลังสั่งแล้ว
  4. Order Number: Auto-generate เช่น ORD-20250219-001 (เรียง sequential per restaurant)

7. Supporting Domain: Menu Management

Entities: MenuCategory, MenuItem

Restaurant (1) ──── (many) MenuCategory
 │
 MenuCategory (1) ──── (many) MenuItem
 │
 ┌─────┴──────────────────────────┐
 │ name, price, description │
 │ is_available (toggle by staff)│
 │ is_buffet_only │
 │ prep_time_minutes │
 └────────────────────────────────┘

CacheFront Integration (Uber-inspired)

Menu data อ่านบ่อยมาก และเปลี่ยนน้อย — เป็น Read-Heavy workload ที่เหมาะกับ CacheFront pattern:

Customer Request: GET /menu/:restaurantID
 │
 ▼
 ┌─────────────┐ Cache HIT ┌──────────────┐
 │ CacheFront │◄───────────────►│ Redis │ (TTL: 5 นาที)
 │(Read-Through)│ └──────────────┘
 └──────┬──────┘
 │ Cache MISS
 ▼
 ┌─────────────┐
 │ PostgreSQL │
 └─────────────┘
 │
 ▼ populate cache
 ┌─────────────┐
 │ Redis │
 └─────────────┘

Circuit Breaker: ถ้า Redis down → ข้ามไปอ่าน DB โดยตรง (ไม่ crash)


8. Generic Domain: Identity & Auth

Entity: User, DeletedUser

User
├── ID, Email, Password (bcrypt cost=14)
├── TwoFactorEnabled
└── Deleted → DeletedUser (soft delete, 14-day backup)

Authentication Flow (JWT + 2FA Email OTP)

Register/Login Flow:
─────────────────────
 Client ──POST /auth/register──► API
 │
 Hash bcrypt(cost=14)
 Validate email format
 Check duplicate email
 │
 Insert User to PostgreSQL
 │
 Client ◄──201 Created──────── API
Login Flow (with 2FA):
─────────────────────
 Client ──POST /auth/login──► API
 │
 ValidateToken JWT (HMAC-only, no algorithm confusion)
 bcrypt.CompareHash
 │
 ┌──────────▼──────────┐
 │ 2FA enabled? │
 └──────────┬──────────┘
 NO │ YES
 │ │ │
 │ │ Send OTP via Resend Email
 │ │ Store OTP in Redis (TTL: 5 min)
 │ │ 
 │ │ Client ──POST /auth/verify-otp──►
 │ │ │
 │ └────────────────┘
 ▼
 Generate TokenPair:
 ┌─────────────────────────────────────────┐
 │ AccessToken = JWT (15 min, HS256) │
 │ RefreshToken = random hex64 (in Redis) │
 └─────────────────────────────────────────┘
 │
 Client ◄──200 OK + TokenPair──
Token Refresh:
─────────────
 Client ──POST /auth/refresh──► API
 │
 Lookup RefreshToken in Redis
 Verify not expired
 Generate new AccessToken
 Rotate RefreshToken (old invalidated)
 │
 Client ◄──200 OK + new TokenPair──

Security Layers

Rate Limiting : Redis-backed (100 req/min per IP, 1000 req/hour global)
CORS : Whitelist mode in production (CORS_ALLOWED_ORIGINS env)
JWT Algorithm : HMAC-only (prevents algorithm confusion attack)
Password bcrypt : cost=14 + max 72-byte enforcement (prevents bcrypt truncation bug)
Ownership Guard : middleware ตรวจ restaurantID == user.ownerID ทุก request
Panic Recovery : ทุก panic ถูก recover + log stack trace + return 500
Request ID : X-Request-ID ทุก request (tracing)

9. Domain Events & EventHub (SSE)

แผนภาพ Event Flow

┌────────────────────────────────────────────────────────────────────┐
│ DOMAIN EVENTS FLOW │
│ │
│ Domain Action Domain Event EventHub Broadcast │
│ ───────────── → ───────────── → ────────────────── │
│ │
│ Ticket Created → queue_updated → Staff Dashboard refresh │
│ Ticket Called → queue_updated → Customer display update │
│ Ticket Seated → queue_updated → Queue board update │
│ Ticket NoShow → queue_updated → Staff notification │
│ │
│ Order Placed → order_received → Kitchen display (new) │
│ Order Confirmed → order_status_changed → Customer QR view │
│ Order Preparing → order_status_changed → Customer QR view │
│ Order Served → order_status_changed → Customer QR view │
│ │
│ Session Warning → session_warning → Customer QR (time alert) │
│ Session Expired → session_warning → Staff + Customer │
│ │
│ Table Occupied → table_status_changed → Floor plan update │
│ Table Freed → table_status_changed → Floor plan update │
│ │
└────────────────────────────────────────────────────────────────────┘

SSE EventHub Architecture

 ┌───────────────────────────────┐
 │ EventHub │
 │ (in-process, goroutine-safe) │
 │ │
 │ clients: map[clientID]→Client│
 │ byRestaurant: map[rID]→clients│
 │ │
 │ Broadcast(restaurantID, event)│
 │ → serialise to JSON │
 │ → send to all subscribers │
 │ of that restaurant │
 └─────────────┬─────────────────┘
 │ SSE push (text/event-stream)
 ┌─────────────┼───────────────────────┐
 ▼ ▼ ▼
 ┌──────────────────┐ ┌──────────────┐ ┌────────────────────┐
 │ Staff Dashboard │ │ QR Order │ │ Customer Queue │
 │ (browser SSE) │ │ Page (SSE) │ │ Display (SSE) │
 │ │ │ │ │ │
 │ GET /events/:rid│ │ GET /events/ │ │ GET /queue/events │
 └──────────────────┘ └──────────────┘ └────────────────────┘

Event Payload Structure

{
 "type": "order_received",
 "timestamp": "2025-02-20T10:30:00Z",
 "data": {
 "order_id": 42,
 "table_number": "A3",
 "guest_nickname": "น้องแก้ว",
 "items": ["ข้าวต้ม x2", "ไก่ทอด x1"],
 "total_amount": 280.0
 }
}

10. Background Workers

Worker 1: BuffetExpiryWorker

ทุก BUFFET_CHECK_INTERVAL_SEC (default: 30 วินาที):

 Step 1: FindSessionsNeedingWarning()
 → ดึง session ที่: IsActive=true AND WarningAt <= now AND WarningNotified=false
 → Broadcast EventSessionWarning ← แจ้งลูกค้าบน QR view
 → ตั้ง WarningNotified = true
 → บันทึก AuditLog "session.warning"

 Step 2: FindExpiredSessions()
 → ดึง session ที่: IsActive=true AND ExpiresAt <= now AND DiningType=buffet
 → session.Expire() ← ปิด Session
 → table.StartClearing() ← โต๊ะเข้าสถานะ "กำลังทำความสะอาด"
 → CancelAllPendingOrders() ← ยกเลิก order ที่ค้างอยู่
 → Broadcast EventTableStatusChanged ← แจ้ง Floor plan
 → Broadcast EventSessionWarning ← แจ้งลูกค้า (session expired)
 → บันทึก AuditLog "session.expired"

Worker 2: NoShowWorker

ทุก BUFFET_CHECK_INTERVAL_SEC (default: 30 วินาที):

 Step 1: FindTimedOutCalledTickets(QUEUE_NOSHOW_TIMEOUT_MIN)
 → ดึง ticket ที่: Status='called' AND CalledAt <= now - timeout
 → ticket.MarkNoShow() ← เปลี่ยน called → no_show
 → บันทึก AuditLog "queue.no_show"
 → Broadcast EventQueueUpdated ← แจ้ง Staff Dashboard

Worker Lifecycle & Graceful Shutdown

// ใน router.go
workerCtx, cancelWorkers := context.WithCancel(context.Background())

buffetWorker := service.NewBuffetExpiryWorker(...)
noShowWorker := service.NewNoShowWorker(...)

go buffetWorker.Start(workerCtx) // goroutine 1
go noShowWorker.Start(workerCtx) // goroutine 2

// เมื่อ SIGTERM/SIGINT:
cancelWorkers() // ทั้งสอง worker หยุดอย่าง graceful

11. Infrastructure Layer

Project Structure (DDD Layered)

my-api/
│
├── cmd/api/ ← Entry Point (Composition Root)
│ └── main.go → load config → connect DB/Redis → setup router → start server
│
├── internal/ ← ไม่ให้ package ภายนอก import ได้
│ ├── config/ ← Configuration (pure struct + env loader)
│ │ └── config.go
│ │
│ ├── model/ ← DOMAIN LAYER (Layer ที่สำคัญที่สุด)
│ │ ├── queue.go → Entities, State Machines, Domain Rules
│ │ ├── user.go → User Entity
│ │ └── errors.go → Domain Errors (ErrInvalidStateTransition, etc.)
│ │
│ ├── repository/ ← INFRASTRUCTURE LAYER (Database access)
│ │ ├── queue_repository.go
│ │ ├── order_repository.go
│ │ ├── session_repository.go
│ │ ├── table_repository.go
│ │ ├── menu_repository.go
│ │ ├── user_repository.go
│ │ ├── restaurant_repository.go
│ │ ├── audit_repository.go
│ │ ├── feature_toggle_repository.go
│ │ ├── qr_guest_repository.go
│ │ └── session_repository.go
│ │
│ ├── service/ ← APPLICATION LAYER (Use Cases / Orchestration)
│ │ ├── queue_service.go → CallNext, JoinQueue, SeatTicket
│ │ ├── order_service.go → PlaceOrder, UpdateStatus, CancelOrder
│ │ ├── qr_ordering_service.go → JoinSession, GetSessionDetail
│ │ ├── restaurant_service.go → CreateRestaurant, GetStats
│ │ ├── auth_service.go → Register, Login, Refresh, 2FA
│ │ ├── email_service.go → SendOTP via Resend API
│ │ ├── event_hub.go → SSE broadcast infrastructure
│ │ ├── buffet_worker.go → Background: buffet expiry
│ │ └── noshow_worker.go → Background: no-show detection
│ │
│ ├── handler/ ← INTERFACE LAYER (HTTP Handlers)
│ │ ├── auth.go
│ │ ├── health.go
│ │ ├── order.go
│ │ ├── queue.go
│ │ ├── qr_ordering.go
│ │ ├── restaurant.go
│ │ └── helpers.go
│ │
│ ├── middleware/ ← Cross-cutting HTTP concerns
│ │ ├── auth.go → JWT validation + claims injection
│ │ ├── cors.go → CORS (whitelist in prod)
│ │ ├── logger.go → Request logging
│ │ ├── ownership.go → Restaurant ownership guard
│ │ ├── ratelimit.go → Redis-backed rate limiting
│ │ ├── recovery.go → Panic recovery
│ │ ├── request_id.go → X-Request-ID header
│ │ └── swagger_auth.go
│ │
│ ├── router/ ← Route registration + DI wiring
│ │ └── router.go
│ │
│ ├── database/ ← DB connection + GORM auto-migrate
│ │ └── database.go
│ │
│ └── server/ ← HTTP server with graceful shutdown
│ └── server.go
│
├── pkg/ ← Reusable packages (ไม่ Business-specific)
│ ├── cache/
│ │ ├── cachefront.go → Read-through/Write-through cache
│ │ ├── circuit_breaker.go → Circuit Breaker (CLOSED/OPEN/HALF_OPEN)
│ │ ├── distributed_lock.go → Redis SETNX distributed lock
│ │ ├── redis.go → Redis client wrapper
│ │ └── metrics.go → Cache hit/miss/latency tracking
│ ├── logger/ → Structured logging
│ ├── response/ → Standardised JSON responses
│ └── utils/
│ ├── jwt.go → Token generation/validation
│ └── password.go → bcrypt hash/check + 72-byte guard
│
├── docs/ ← Swagger + DDD documentation
│ ├── swagger.json
│ ├── swagger.yaml
│ └── DDD.md ← (ไฟล์นี้)
│
├── public/ ← Static HTML simulation pages
│ ├── index.html → Portfolio landing page
│ ├── queue-simulation.html → จำลองลูกค้า join/call queue
│ ├── restaurant-game.html → จำลอง Staff dashboard
│ └── order.html → QR Ordering page
│
├── Dockerfile ← Multi-stage build (non-root user)
├── docker-compose.yml ← Postgres + Redis + API (healthchecks)
├── .env.example ← Template environment variables
├── .air.toml ← Hot-reload config
└── .golangci.yml ← Linter configuration

Database Schema — Entity Relationships

users
 ├── id, email, password, name, two_factor_enabled
 └── deleted_users (soft-delete backup, 14 days)

restaurants
 ├── id, owner_id → users.id
 ├── name, slug (unique), dining_type
 ├── buffet_time_minutes, buffet_warning_minutes, buffet_order_cutoff_minutes
 └── max_queue_size, is_active

tables
 ├── id, restaurant_id → restaurants.id
 ├── table_number, zone, capacity, min_guests
 ├── dining_type, status (vacant/occupied/clearing/disabled)
 ├── floor, is_smoking_area, sort_order, is_active
 └── version (optimistic lock)

queue_tickets
 ├── id, restaurant_id → restaurants.id
 ├── ticket_number, customer_name, customer_phone
 ├── party_size, dining_type, entry_mode
 ├── status (waiting/called/seated/completed/cancelled/expired/no_show)
 ├── position, priority, estimated_wait
 ├── called_at, seated_at, completed_at, cancelled_at
 ├── table_id → tables.id (nullable, set after seating)
 ├── user_id → users.id (nullable, for remote booking)
 ├── idempotency_key (unique index — prevents duplicates)
 └── version (optimistic lock)

table_sessions
 ├── id, restaurant_id, table_id → tables.id, ticket_id → queue_tickets.id
 ├── session_token (unique — QR Code payload)
 ├── dining_type, party_size
 ├── started_at, expires_at, order_cutoff_at, warning_at, ended_at
 ├── is_active, bill_requested, bill_requested_at
 ├── warning_notified, expire_notified
 └── total_orders, total_amount, bill_total, total_guests, version

qr_guests
 ├── id, session_id → table_sessions.id
 ├── restaurant_id, nickname
 ├── guest_token (unique — per guest identity)
 └── total_orders, total_amount

menu_categories
 └── id, restaurant_id, name, sort_order, is_active

menu_items
 ├── id, restaurant_id, category_id → menu_categories.id
 ├── name, description, price, image_url
 ├── is_available, is_buffet_only, prep_time_minutes
 └── sort_order

orders
 ├── id, restaurant_id, session_id, table_id, ticket_id
 ├── guest_id → qr_guests.id (nullable)
 ├── order_number, status, total_amount, item_count
 ├── notes, confirmed_at, preparing_at, served_at, cancelled_at
 └── version

order_items
 ├── id, order_id → orders.id
 ├── menu_item_id → menu_items.id
 ├── name (snapshot), price (snapshot)
 └── quantity, status, notes

feature_toggles
 └── id, restaurant_id, feature_key, is_enabled, description, updated_by

audit_logs
 └── id, restaurant_id, action, entity_type, entity_id
 actor_id, actor_type, details (JSON), ip_address, created_at

Redis Key Patterns

Session (Auth):
 session:{refreshToken} → userID (TTL: 7 วัน)
 otp:{userID}:{type} → OTP code (TTL: 5 นาที)

Rate Limiting:
 ratelimit:ip:{ip} → request count (TTL: 1 นาที)
 ratelimit:global:{ip} → request count (TTL: 1 ชั่วโมง)

Cache (CacheFront):
 menu:{restaurantID} → JSON menu (TTL: 5 นาที)
 queue:stats:{restaurantID} → QueueStats (TTL: 10 วินาที)
 restaurant:{id} → Restaurant (TTL: 10 นาที)

Distributed Lock:
 lock:queue:{restaurantID} → locked/unlocked (TTL: 5 วินาที)
 lock:table:{tableID} → locked/unlocked (TTL: 5 วินาที)

12. API Layer — Swagger & Route Map

Route Map ทั้งหมด

PUBLIC ROUTES (ไม่ต้อง Auth)
─────────────────────────────
POST /api/v1/auth/register → สมัครสมาชิก
POST /api/v1/auth/login → เข้าสู่ระบบ
POST /api/v1/auth/refresh → รีเฟรช access token
POST /api/v1/auth/verify-otp → ยืนยัน 2FA OTP
GET /health → Health check
GET /ready → Readiness probe (K8s/Docker)

CUSTOMER ROUTES (ไม่ต้อง Auth)
────────────────────────────────────
GET /api/v1/queue/:restaurantID/stats → ดูสถิติคิว (wait time, queue size)
GET /api/v1/queue/events/:restaurantID → SSE stream (real-time queue updates)
POST /api/v1/queue/:restaurantID/join → เข้าคิว (walk-in)
GET /api/v1/queue/ticket/:id/status → ดูสถานะตั๋วคิวของตัวเอง

QR ORDERING (token-based, no JWT)
───────────────────────────────────
POST /api/v1/qr/session/join → Join QR ordering session (scan QR)
GET /api/v1/qr/session/:token → ดู session detail (menu, time remaining)
POST /api/v1/qr/order → สั่งอาหาร (QR guest)
GET /api/v1/qr/orders/:guestToken → ดู orders ของตัวเอง
GET /api/v1/qr/events/:sessionToken → SSE stream (order status updates)

AUTHENTICATED ROUTES (ต้อง JWT Bearer Token)
──────────────────────────────────────────────
Restaurant Management:
 POST /api/v1/restaurants → สร้างร้านอาหาร
 GET /api/v1/restaurants → ดูร้านของตัวเอง
 GET /api/v1/restaurants/:id → ดูข้อมูลร้าน
 PUT /api/v1/restaurants/:id → แก้ไขข้อมูลร้าน
 DELETE /api/v1/restaurants/:id → ลบร้าน

Table Management:
 POST /api/v1/restaurants/:id/tables → สร้างโต๊ะ
 GET /api/v1/restaurants/:id/tables → ดูโต๊ะทั้งหมด
 PUT /api/v1/tables/:tableID → แก้ไขโต๊ะ
 DELETE /api/v1/tables/:tableID → ลบโต๊ะ
 PATCH /api/v1/tables/:tableID/status → เปลี่ยนสถานะโต๊ะ (clearing/vacant/disable)

Queue Management (Staff):
 GET /api/v1/queue/:restaurantID → ดูคิวทั้งหมด (waiting, called, etc.)
 POST /api/v1/queue/:restaurantID/call-next → เรียกคิวถัดไป
 PATCH /api/v1/queue/ticket/:id/seat → นั่งโต๊ะ (เชื่อม ticket กับ table)
 PATCH /api/v1/queue/ticket/:id/cancel → ยกเลิกตั๋ว
 PATCH /api/v1/queue/ticket/:id/no-show → Mark no-show (manual)

Session Management (Staff):
 GET /api/v1/sessions/:restaurantID → ดู active sessions
 GET /api/v1/sessions/:id → ดู session detail
 POST /api/v1/sessions/:id/request-bill → เรียกบิล
 POST /api/v1/sessions/:id/close → ปิด session (จ่ายเงินแล้ว)

Order Management (Staff — Kitchen View):
 GET /api/v1/orders/:restaurantID → ดู orders ทั้งหมด
 GET /api/v1/orders/:id → ดู order detail
 PATCH /api/v1/orders/:id/status → Update order status (confirm/prepare/serve)
 DELETE /api/v1/orders/:id → Cancel order

Menu Management (Staff):
 POST /api/v1/restaurants/:id/menu/categories → สร้าง category
 GET /api/v1/restaurants/:id/menu/categories → ดู categories
 POST /api/v1/menu/categories/:catID/items → สร้าง menu item
 GET /api/v1/restaurants/:id/menu/items → ดู items ทั้งหมด
 PUT /api/v1/menu/items/:itemID → แก้ไข item
 PATCH /api/v1/menu/items/:itemID/availability → toggle available/unavailable

Feature Toggles:
 GET /api/v1/restaurants/:id/features → ดู feature flags
 PATCH /api/v1/restaurants/:id/features/:key → enable/disable feature

Audit Log:
 GET /api/v1/restaurants/:id/audit → ดู audit log (owner only)

Cache Metrics (Admin):
 GET /cache/metrics → CacheFront + Circuit Breaker stats

Swagger UI

เข้าถึงได้ที่: http://localhost:8080/swagger/index.html

Authentication ใน Swagger: คลิก Authorize → ใส่ Bearer <your_token>


13. Data Flow Diagrams

Flow 1: Customer เข้าคิว (Walk-in)

Customer API Service DB/Cache
 │ │ │ │
 │─POST /queue/join───►│ │ │
 │ │─Validate Request────►│ │
 │ │ │─Check Feature Toggle►│
 │ │ │◄─── remote_booking ──│
 │ │ │ │
 │ │ │─Check Queue Stats ──►│ (Redis/DB)
 │ │ │◄──── QueueStats ─────│
 │ │ │ │
 │ │ │─Acquire DistLock ───►│ Redis SETNX
 │ │ │ │
 │ │ │─Create QueueTicket ─►│ PostgreSQL
 │ │ │◄─── Ticket(id=42) ───│
 │ │ │ │
 │ │ │─Release DistLock ───►│
 │ │ │ │
 │ │ │─Write AuditLog ─────►│
 │ │ │ │
 │ │ │─Broadcast SSE ──────►│ EventHub
 │ │ │ (Staff dashboard updates)
 │ │ │ │
 │◄─201 Ticket Q042────│◄────────────────────│ │

Flow 2: Staff เรียกคิว (CallNext)

Staff App API QueueService DB + SSE
 │ │ │ │
 │─POST /call-next───►│ │ │
 │ (restaurantID=5) │─Auth Middleware ──────►│ │
 │ │ JWT + Ownership │ │
 │ │───────────────────────►│ │
 │ │ │─FindNextWaiting ───►│ Postgres
 │ │ │◄─── ticket ─────────│
 │ │ │ │
 │ │ │─ticket.Call() ─────►│ (in-memory)
 │ │ │ version++ │
 │ │ │ status=called │
 │ │ │ │
 │ │ │─SaveTicket ────────►│ Postgres
 │ │ │ │
 │ │ │─UpdatePosition ────►│ (recalculate)
 │ │ │ │
 │ │ │─AuditLog ──────────►│
 │ │ │ │
 │ │ │─EventHub.Broadcast─►│ SSE → all clients
 │ │ │ EventQueueUpdated │
 │ │ │ │
 │◄─200 CalledTicket──│◄──────────────────────│ │
 │ │ │ │
 │ [Customer's phone buzzes / display updates in real-time via SSE] │

Flow 3: QR Ordering (Customer)

Customer Phone API QROrderingService DB
 │ │ │ │
 │─Scan QR Code ─────────►│─POST /qr/session/join ►│ │
 │ (session_token=abc) │ + nickname="น้องแก้ว" │ │
 │ │ │─ValidateSession ──►│
 │ │ │◄─ session(active) ─│
 │ │ │ │
 │ │ │─CreateQRGuest ────►│
 │ │ │◄─ guest_token ─────│
 │ │ │ │
 │◄─ guest_token ─────────│◄───────────────────────│ │
 │ │ │ │
 │─GET /qr/session/abc ──►│ │ │
 │ │─ValidateQRToken ───────►│ │
 │ │ │─GetMenuWithCache ─►│ Redis/DB
 │◄─ SessionDetail + Menu ─│ │ │
 │ │ │ │
 │─POST /qr/order ────────►│ │ │
 │ guest_token + items │─CanOrder() check ──────►│ │
 │ │ (is session active? │ │
 │ │ is before cutoff?) │ │
 │ │ │─CreateOrder ──────►│
 │ │ │─EventHub.Broadcast►│ → Kitchen SSE
 │◄─ 201 Order Created ───│ │ EventOrderReceived│

14. Security Architecture

Defence in Depth — ป้องกันหลายชั้น

Request
 │
 ▼
┌──────────────────────────────────────────────────────────┐
│ Layer 1: Rate Limiter (Redis) │
│ 100 req/min per IP, burst protection │
└─────────────────────────────┬────────────────────────────┘
 │
 ▼
┌──────────────────────────────────────────────────────────┐
│ Layer 2: CORS Guard │
│ Dev: allow all | Prod: whitelist only (env var) │
└─────────────────────────────┬────────────────────────────┘
 │
 ▼
┌──────────────────────────────────────────────────────────┐
│ Layer 3: JWT Auth Middleware │
│ HMAC-only signing (ป้องกัน alg:none attack) │
│ Claims: userID (ตรวจสิทธิ์ทุก endpoint) │
└─────────────────────────────┬────────────────────────────┘
 │
 ▼
┌──────────────────────────────────────────────────────────┐
│ Layer 4: Ownership Guard │
│ restaurantID param == auth.userID │
│ ป้องกัน IDOR (Insecure Direct Object Reference) │
└─────────────────────────────┬────────────────────────────┘
 │
 ▼
┌──────────────────────────────────────────────────────────┐
│ Layer 5: Domain Validation │
│ Validate() ก่อน persist ทุกครั้ง │
│ State Machine บังคับ transition rules │
└─────────────────────────────┬────────────────────────────┘
 │
 ▼
┌──────────────────────────────────────────────────────────┐
│ Layer 6: Panic Recovery │
│ ทุก panic ถูก recover → log stack trace → 500 │
└──────────────────────────────────────────────────────────┘

15. Deployment Architecture

Docker Compose (Local / Staging)

┌──────────────────────────────────────────────────────────────┐
│ docker-compose.yml │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ api (Go binary) │ │
│ │ - Multi-stage build (builder → runtime) │ │
│ │ - Non-root user (security best practice) │ │
│ │ - Port 8080 │ │
│ │ - healthcheck: GET /health (5s interval) │ │
│ │ - depends_on: postgres, redis (healthy) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ PostgreSQL 16 │ │ Redis 7 │ │
│ │ Port: 5432 │ │ Port: 6379 │ │
│ │ Volume: pg_data│ │ Volume: redis_data│ │
│ │ healthcheck: │ │ healthcheck: │ │
│ │ pg_isready │ │ redis-cli PING │ │
│ └─────────────────┘ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

Environment Variables

# App
APP_ENV=production
PORT=8080
APP_BASE_URL=https://api.yourdomain.com # ← สำคัญ! ใช้ generate QR URL

# Database
DB_HOST=postgres
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=your_secure_password
DB_NAME=myapi

# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password

# JWT
JWT_SECRET=your_256bit_secret_key_minimum_32_chars

# Resend (Email 2FA)
RESEND_API_KEY=re_xxxxx
[email protected]
RESEND_FROM_NAME=My Restaurant

# CORS (production whitelist)
CORS_ALLOWED_ORIGINS=https://yourdomain.com,https://app.yourdomain.com

# Queue
QUEUE_MAX_SIZE=100
QUEUE_MAX_PARTY_SIZE=20
QUEUE_NOSHOW_TIMEOUT_MIN=10 # ← NoShowWorker จะ mark no-show หลังจาก 10 นาที

# Buffet
BUFFET_DURATION_MIN=90
BUFFET_WARNING_MIN=15
BUFFET_ORDER_CUTOFF_MIN=30
BUFFET_CHECK_INTERVAL_SEC=30 # ← Worker รัน check ทุก 30 วินาที

Production Checklist ก่อน Deploy


16. Checklist ความสมบูรณ์ระบบ

Domain Layer

รายการ สถานะ รายละเอียด
QueueTicket State Machine 7 states, transitions ครบ, test ครอบคลุม
Table State Machine 5 states, optimistic lock
Order State Machine 6 states, item-level tracking
OrderItem State Machine 5 states
TableSession lifecycle Buffet timer, order cutoff, QR token validation
QRGuest identity Per-guest token, order attribution
Domain Validation (Validate()) ทุก entity ก่อน persist
Domain Errors (typed) ErrInvalidStateTransition, ErrInvalidPartySize, etc.
Idempotency (QueueTicket) unique_index idempotency_key
Optimistic Concurrency (Version) QueueTicket, Table, TableSession, Order

Application Layer

รายการ สถานะ รายละเอียด
QueueService (CallNext, Join, Seat) Distributed lock, Position recalc
OrderService (Place, Update, Cancel) Session gate check
QROrderingService Token validation, Buffet/Alacarte flow
RestaurantService Multi-tenant ownership
AuthService (JWT + 2FA) Refresh token rotation
EmailService (Resend) OTP delivery
BuffetExpiryWorker Warning + Expire auto loop
NoShowWorker Auto mark called→no_show
EventHub (SSE) All events broadcast

Infrastructure Layer

รายการ สถานะ รายละเอียด
PostgreSQL (GORM) Auto-migrate, all entities
Redis (go-redis/v9) Sessions, OTP, rate limit, cache
CacheFront (Read-through) Uber-inspired, menu/stats caching
Circuit Breaker CLOSED/OPEN/HALF_OPEN, FailureThreshold=5
Distributed Lock (SETNX) Prevent race on CallNext
Rate Limiting (Redis) Per-IP + global
Audit Log (immutable) Every critical action
Feature Toggles Dynamic, without restart

API & Docs Layer

รายการ สถานะ รายละเอียด
Swagger UI /swagger/index.html
Swagger JSON/YAML /docs/swagger.json, swagger.yaml
All routes documented @Summary, @Description, @Tags ทุก handler
Health Check (/health) Status, uptime, version
Readiness Probe (/ready) DB + Redis ping (K8s ready)
Cache Metrics (/cache/metrics) Hit rate, circuit breaker stats
Standard Response Format {success, message, data, error, pagination}

Security

รายการ สถานะ รายละเอียด
JWT (HMAC-only) ป้องกัน alg:none attack
bcrypt (cost=14) ป้องกัน brute force
72-byte password guard ป้องกัน bcrypt silent truncation
2FA Email OTP Resend API, TTL 5 นาที
Ownership Guard IDOR protection
CORS Whitelist Production env
Rate Limiting Redis-backed
Panic Recovery ไม่ crash server
Request ID tracing X-Request-ID header

DevOps

รายการ สถานะ รายละเอียด
Dockerfile (multi-stage) แยก builder/runtime, non-root user
docker-compose.yml healthcheck, volume, depends_on
.env.example ครบทุก variable รวม APP_BASE_URL
.air.toml Hot-reload สำหรับ dev
.golangci.yml Linter config (errcheck, gosec, staticcheck, etc.)
Graceful Shutdown SIGTERM → workers stop → server drain
Structured Logging Level-based, request context

Tests

รายการ สถานะ Package
Domain State Machine ✅ passed internal/model
Middleware (CORS, Recovery, Logger) ✅ passed internal/middleware
Health Handler ✅ passed internal/handler
Circuit Breaker ✅ passed pkg/cache
Response Helpers ✅ passed pkg/response
JWT + Password utils ✅ passed pkg/utils

ภาพรวมสุดท้าย — The Pioneer System

 ┌────────────────────────────────────────────────────────┐
 │ │
 │ Restaurant Queue + POS Simulation System │
 │ "ระบบจำลองทุกสถานการณ์จริง ในหน้าจอเดียว" │
 │ │
 │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
 │ │ Customer │ │ Staff │ │ Kitchen │ │
 │ │ Queue │ │Dashboard │ │ Display │ │
 │ │ Display │ │(Restaurant│ │ (Orders)│ │
 │ │ (SSE) │ │ Game) │ │ (SSE) │ │
 │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
 │ │ │ │ │
 │ └─────────────┴─────────────┘ │
 │ │ HTTP + SSE │
 │ ▼ │
 │ ┌──────────────────────────┐ │
 │ │ Go API (this server) │ │
 │ │ DDD + Clean Architecture│ │
 │ └──────────┬───────────────┘ │
 │ │ │
 │ ┌─────┴─────┐ │
 │ ▼ ▼ │
 │ ┌──────────┐ ┌──────────┐ │
 │ │PostgreSQL│ │ Redis │ │
 │ │ (domain │ │ (cache + │ │
 │ │ store) │ │ sessions│ │
 │ └──────────┘ │ + locks)│ │
 │ └──────────┘ │
 │ │
 └────────────────────────────────────────────────────────┘
สรุป: ระบบนี้ไม่ใช่แค่ CRUD API ธรรมดา — มัน encode ความรู้ของระบบร้านอาหารจริงๆ ลงไปใน domain model เองเลย ทุก business rule มีที่อยู่ที่ถูกต้อง ทุก state transition ถูกบังคับ ทุก race condition ถูกป้องกัน และทุกเหตุการณ์สำคัญถูกบันทึกไว้ในลักษณะ immutable นี่คือสิ่งที่ DDD ให้กับเรา