moicle 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,464 +1,867 @@
1
1
  # Go Backend Architecture
2
2
 
3
- > Production-grade Clean Architecture with Modular Design
3
+ > Production-grade DDD with Hexagonal Architecture for Go + Gin
4
4
 
5
- ## Project Structure
5
+ **Prerequisite:** Read `ddd-architecture.md` first. This doc shows how DDD maps to Go.
6
+
7
+ ## Tech Stack
8
+
9
+ | Component | Technology |
10
+ |-----------|------------|
11
+ | Framework | Gin |
12
+ | ORM | GORM |
13
+ | Database | MySQL/PostgreSQL |
14
+ | Cache | Redis |
15
+ | Queue | Asynq |
16
+ | Auth | Firebase Auth |
17
+ | CLI | Cobra |
18
+ | gRPC | google.golang.org/grpc |
19
+ | Storage | AWS S3 / Cloudflare R2 |
20
+ | Real-time | SSE + Redis pub/sub |
21
+
22
+ ---
23
+
24
+ ## DDD Directory Structure
6
25
 
7
26
  ```
8
- {project}/
9
- ├── cmd/
10
- │ ├── api/
11
- │ │ ├── main.go # Entry point (HTTP + gRPC + SSE)
12
- │ └── router.go # Route definitions & middleware
13
- │ └── background/
14
- ├── main.go # CLI entry (Cobra)
15
- ├── commands/ # CLI subcommands
16
- ├── consumer.go # Task consumer
17
- ├── scheduler.go # Task scheduler
18
- └── sync.go # Sync command
19
- └── consumers/ # Task handlers (Asynq)
20
- ├── notification.go
21
- └── cleanup.go
22
- ├── internal/
23
- │ ├── config/
24
- └── config.go # App configuration
25
- ├── middleware/
26
- │ │ ├── auth.go # Auth middleware + caching
27
- ├── admin_auth.go # Admin auth
28
- │ │ ├── cors.go # CORS
29
- │ │ ├── logger.go # Request logging
30
- ├── recovery.go # Panic recovery
31
- │ │ └── api_key.go # API key validation
32
- │ ├── grpc/ # gRPC services (optional)
33
- │ │ └── account_service.go
34
- │ └── modules/ # Feature modules
35
- │ ├── auth/
36
- │ ├── controllers/
37
- │ │ └── auth_controller.go
38
- │ │ ├── usecases/
39
- │ │ │ └── auth_usecase.go
40
- │ │ ├── dtos/
41
- │ │ │ └── auth_dto.go
42
- │ │ └── init.go # Module init & routes
43
- │ ├── user/
44
- │ │ ├── controllers/
45
- │ │ ├── usecases/
46
- │ │ ├── dtos/
47
- │ │ └── init.go
48
- │ └── {feature}/ # Other modules...
49
- │ ├── controllers/
50
- │ ├── usecases/
51
- │ ├── dtos/
52
- │ ├── validators/ # (optional) Chain validators
53
- │ └── init.go
54
- ├── pkg/
27
+ internal/
28
+ ├── domain/{domain}/
29
+ │ ├── entities/ # Aggregates + entities with behavior
30
+ │ │ └── {entity}.go # New(), state transitions, guard checks
31
+ ├── valueobjects/ # Immutable typed values with behavior
32
+ └── {vo}.go # Only stdlib imports
33
+ ├── ports/ # Hexagonal ports — 1 file per interface
34
+ │ └── {store_name}.go # Store interface + related DTOs
35
+ │ ├── events/ # 1 file per domain event
36
+ └── {event_name}.go # Embed shared.BaseEvent
37
+ ├── usecases/ # Business orchestration (pure, no infra)
38
+ └── {action}.go # One file per use case
39
+ └── validators/ # (optional) Pure validation rules
40
+
41
+ ├── domain/shared/
42
+ │ ├── base_event.go # BaseEvent struct
43
+ │ └── event_collector.go # EventCollector for entities
44
+
45
+ ├── application/
46
+ │ ├── ports/http/
47
+ │ │ ├── {module}_handler.go # Register{Module}Routes + handlers
48
+ │ │ └── {module}_dtos.go # Request/Response structs
49
+ │ ├── services/
50
+ │ │ └── {module}_service.go # Thin wrapper → domain usecases
51
+ │ ├── listeners/
52
+ │ │ └── on_{event_name}.go # Event side-effects
53
+ │ └── eventbus/
54
+ │ ├── dispatcher.go
55
+ └── registry.go
56
+
57
+ ├── infrastructure/
55
58
  │ ├── database/
56
- │ │ ├── database.go # GORM connection
57
- ├── user.go # User model
58
- │ └── {entity}.go # Other models
59
- │ ├── enums/ # Enumeration types
60
- │ ├── events/ # Event definitions
61
- │ ├── queue/ # Task queue (Asynq)
62
- │ ├── tasks.go # Task definitions
63
- │ └── client.go # Queue client
64
- ├── redis/ # Redis client
65
- ├── sse/ # Server-Sent Events
66
- │ │ └── hub.go # SSE Hub
67
- ├── response/ # HTTP response helpers
68
- ├── logger/ # Structured logging
69
- ├── storage/ # File storage (S3/R2)
70
- └── utils/ # Utility functions
71
- ├── migrations/ # SQL migrations
72
- ├── chart/ # Kubernetes Helm charts (optional)
73
- ├── .env.example
74
- ├── go.mod
75
- ├── Makefile
76
- ├── Dockerfile
77
- └── README.md
59
+ │ │ └── {domain}_{entity}_store.go
60
+ │ ├── cache/
61
+ ├── messaging/
62
+ │ ├── auth/
63
+ │ ├── storage/
64
+ │ ├── logger/
65
+ └── http/
66
+ ├── response.go
67
+ └── pagination.go
68
+
69
+ ├── models/ # GORM models (persistence only)
70
+ └── {entity}.go
71
+
72
+ ├── middleware/
73
+ ├── auth.go
74
+ ├── admin_auth.go
75
+ ├── cors.go
76
+ ├── logger.go
77
+ ├── recovery.go
78
+ │ └── api_key.go
79
+
80
+ ├── config/
81
+ │ └── config.go
82
+
83
+ ├── bootstrap/
84
+ │ └── app.go # App struct with all dependencies
85
+
86
+ └── cmd/v1/
87
+ ├── main.go
88
+ └── router.go
78
89
  ```
79
90
 
80
- ## Architecture Pattern
91
+ ### Layer Mapping (Generic DDD → Go)
92
+
93
+ | Generic DDD | Go Path |
94
+ |-------------|---------|
95
+ | `domain/` | `internal/domain/` |
96
+ | `application/ports/{transport}/` | `internal/application/ports/http/` |
97
+ | `application/services/` | `internal/application/services/` |
98
+ | `application/listeners/` | `internal/application/listeners/` |
99
+ | `infrastructure/{persistence}/` | `internal/infrastructure/database/` |
100
+ | `models/` | `internal/models/` |
101
+
102
+ ---
103
+
104
+ ## Layer Rules (Import Rules)
81
105
 
82
106
  ```
83
- Controller Usecase → Database (GORM)
84
- ↓ ↓
85
- Request Business
86
- Binding Logic
87
- ↓ ↓
88
- DTOs Models
107
+ domain/valueobjects/ only stdlib
108
+ domain/entities/ → only stdlib + domain/shared + domain/valueobjects
109
+ domain/ports/ → only stdlib + domain/entities + domain/valueobjects + domain/shared
110
+ domain/events/ → only stdlib + domain/shared
111
+ domain/usecases/ → domain/entities + ports + events + valueobjects (NO infra)
112
+ application/services/ → domain/usecases (thin wrapper)
113
+ application/ports/http/ → application/services + bootstrap + infrastructure/http
114
+ application/listeners/ → domain/events + bootstrap + infrastructure/messaging
115
+ infrastructure/database → domain/ports + models/
89
116
  ```
90
117
 
91
- **Layer Responsibilities:**
118
+ ---
119
+
120
+ ## Hard Rules
92
121
 
93
- | Layer | Responsibility |
94
- |-------|----------------|
95
- | Controller | HTTP handling, request/response, validation |
96
- | Usecase | Business logic, orchestration |
97
- | DTO | Data transfer objects, API contracts |
98
- | Model | Database entities (pkg/database) |
122
+ - `domain/` MUST NOT import gorm, gin, redis, firebase, asynq, or any external package
123
+ - Domain A MUST NOT import Domain B
124
+ - NO circular imports
125
+ - Domain entities returned via API MUST have `json:"snake_case"` tags
126
+ - Async goroutines MUST use `context.Background()`
127
+ - Event names in `NewBaseEvent("...")` MUST match `eventbus/registry.go`
128
+ - Entity SHOULD embed `shared.EventCollector` and raise events on state changes
129
+ - Ports use domain types (entities, value objects), NOT `string` for typed values
130
+ - Store constructors: `NewXxxStore(db *gorm.DB) *XxxStore`
99
131
 
100
- ## Module Structure
132
+ ---
101
133
 
102
- Each feature is an independent module:
134
+ ## Forbidden Imports in Domain
103
135
 
104
136
  ```
105
- internal/modules/{feature}/
106
- ├── controllers/
107
- │ └── {feature}_controller.go # HTTP handlers
108
- ├── usecases/
109
- │ └── {feature}_usecase.go # Business logic
110
- ├── dtos/
111
- │ └── {feature}_dto.go # Request/Response DTOs
112
- ├── validators/ # (optional)
113
- │ └── {feature}_validator.go # Chain validators
114
- └── init.go # Module init & route registration
137
+ "gorm.io/*"
138
+ "github.com/gin-gonic/*"
139
+ "github.com/redis/*"
140
+ "firebase.google.com/*"
141
+ "github.com/hibiken/asynq"
115
142
  ```
116
143
 
117
- ## Key Files
144
+ ---
145
+
146
+ ## Domain Layer Examples
147
+
148
+ ### Entity with Behavior
118
149
 
119
- ### cmd/api/main.go
120
150
  ```go
121
- package main
151
+ package entities
122
152
 
123
153
  import (
124
- "context"
125
- "log"
126
- "net/http"
127
- "os"
128
- "os/signal"
129
- "syscall"
130
154
  "time"
131
155
 
132
- "myapp/internal/config"
133
- "myapp/pkg/database"
134
- "myapp/pkg/redis"
135
- "myapp/pkg/queue"
136
- "myapp/pkg/sse"
156
+ "myapp/internal/domain/shared"
157
+ "myapp/internal/domain/wallet/valueobjects"
137
158
  )
138
159
 
139
- func main() {
140
- cfg := config.Load()
160
+ type Wallet struct {
161
+ shared.EventCollector
162
+ ID string
163
+ UserID string
164
+ Balance valueobjects.Money
165
+ Status valueobjects.WalletStatus
166
+ CreatedAt time.Time
167
+ UpdatedAt time.Time
168
+ }
141
169
 
142
- // Initialize dependencies
143
- db := database.Connect(cfg.Database)
144
- redisClient := redis.NewClient(cfg.Redis)
145
- queueClient := queue.NewClient(redisClient)
146
- sseHub := sse.NewHub(redisClient)
170
+ func NewWallet(userID string) *Wallet {
171
+ w := &Wallet{
172
+ ID: shared.NewID(),
173
+ UserID: userID,
174
+ Balance: valueobjects.ZeroMoney(),
175
+ Status: valueobjects.WalletStatusActive,
176
+ CreatedAt: time.Now(),
177
+ UpdatedAt: time.Now(),
178
+ }
179
+ w.Raise(events.NewWalletCreated(w.ID, userID))
180
+ return w
181
+ }
147
182
 
148
- // Start SSE Hub
149
- go sseHub.Run()
183
+ func (w *Wallet) IsActive() bool {
184
+ return w.Status == valueobjects.WalletStatusActive
185
+ }
150
186
 
151
- // Setup router
152
- router := SetupRouter(db, redisClient, queueClient, sseHub, cfg)
187
+ func (w *Wallet) CanWithdraw(amount valueobjects.Money) bool {
188
+ return w.IsActive() && w.Balance.GreaterOrEqual(amount)
189
+ }
153
190
 
154
- // HTTP Server
155
- server := &http.Server{
156
- Addr: ":" + cfg.Port,
157
- Handler: router,
158
- ReadTimeout: 15 * time.Second,
159
- WriteTimeout: 0, // Disabled for SSE
191
+ func (w *Wallet) Withdraw(amount valueobjects.Money) error {
192
+ if !w.CanWithdraw(amount) {
193
+ return ErrInsufficientBalance
160
194
  }
195
+ w.Balance = w.Balance.Subtract(amount)
196
+ w.UpdatedAt = time.Now()
197
+ w.Raise(events.NewWalletWithdrawalCreated(w.ID, w.UserID, amount))
198
+ return nil
199
+ }
161
200
 
162
- // Graceful shutdown
163
- go func() {
164
- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
165
- log.Fatalf("Server error: %v", err)
166
- }
167
- }()
201
+ func (w *Wallet) Deposit(amount valueobjects.Money) {
202
+ w.Balance = w.Balance.Add(amount)
203
+ w.UpdatedAt = time.Now()
204
+ }
205
+ ```
206
+
207
+ ### Value Object
208
+
209
+ ```go
210
+ package valueobjects
211
+
212
+ import "fmt"
213
+
214
+ type Money struct {
215
+ amount int64
216
+ currency string
217
+ }
218
+
219
+ func NewMoney(amount int64, currency string) Money {
220
+ return Money{amount: amount, currency: currency}
221
+ }
222
+
223
+ func ZeroMoney() Money {
224
+ return Money{amount: 0, currency: "VND"}
225
+ }
226
+
227
+ func (m Money) Amount() int64 { return m.amount }
228
+ func (m Money) Currency() string { return m.currency }
229
+
230
+ func (m Money) Add(other Money) Money {
231
+ return Money{amount: m.amount + other.amount, currency: m.currency}
232
+ }
233
+
234
+ func (m Money) Subtract(other Money) Money {
235
+ return Money{amount: m.amount - other.amount, currency: m.currency}
236
+ }
237
+
238
+ func (m Money) GreaterOrEqual(other Money) bool {
239
+ return m.amount >= other.amount
240
+ }
168
241
 
169
- quit := make(chan os.Signal, 1)
170
- signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
171
- <-quit
242
+ func (m Money) Format() string {
243
+ return fmt.Sprintf("%d %s", m.amount, m.currency)
244
+ }
245
+
246
+ type WalletStatus string
172
247
 
173
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
174
- defer cancel()
248
+ const (
249
+ WalletStatusActive WalletStatus = "active"
250
+ WalletStatusFrozen WalletStatus = "frozen"
251
+ WalletStatusClosed WalletStatus = "closed"
252
+ )
175
253
 
176
- if err := server.Shutdown(ctx); err != nil {
177
- log.Fatalf("Shutdown error: %v", err)
254
+ func (s WalletStatus) IsTerminal() bool {
255
+ return s == WalletStatusClosed
256
+ }
257
+
258
+ func (s WalletStatus) CanTransitionTo(target WalletStatus) bool {
259
+ switch s {
260
+ case WalletStatusActive:
261
+ return target == WalletStatusFrozen || target == WalletStatusClosed
262
+ case WalletStatusFrozen:
263
+ return target == WalletStatusActive || target == WalletStatusClosed
264
+ default:
265
+ return false
178
266
  }
179
267
  }
180
268
  ```
181
269
 
182
- ### cmd/api/router.go
270
+ ### Port Interface
271
+
183
272
  ```go
184
- package main
273
+ package ports
185
274
 
186
275
  import (
187
- "github.com/gin-gonic/gin"
188
- "gorm.io/gorm"
276
+ "context"
189
277
 
190
- "myapp/internal/config"
191
- "myapp/internal/middleware"
192
- "myapp/internal/modules/auth"
193
- "myapp/internal/modules/user"
194
- "myapp/pkg/queue"
195
- "myapp/pkg/redis"
196
- "myapp/pkg/sse"
278
+ "myapp/internal/domain/wallet/entities"
279
+ "myapp/internal/domain/wallet/valueobjects"
197
280
  )
198
281
 
199
- func SetupRouter(
200
- db *gorm.DB,
201
- redisClient *redis.Client,
202
- queueClient *queue.Client,
203
- sseHub *sse.Hub,
204
- cfg *config.Config,
205
- ) *gin.Engine {
206
- r := gin.New()
282
+ type WalletStore interface {
283
+ FindByID(ctx context.Context, id string) (*entities.Wallet, error)
284
+ FindByUserID(ctx context.Context, userID string) (*entities.Wallet, error)
285
+ Save(ctx context.Context, wallet *entities.Wallet) error
286
+ UpdateBalance(ctx context.Context, id string, balance valueobjects.Money) error
287
+ }
288
+ ```
207
289
 
208
- // Global middleware
209
- r.Use(middleware.Recovery())
210
- r.Use(middleware.Logger())
211
- r.Use(middleware.CORS(cfg.AllowedOrigins))
290
+ ### Domain Event
212
291
 
213
- // Health check
214
- r.GET("/health", func(c *gin.Context) {
215
- c.JSON(200, gin.H{"status": "ok"})
216
- })
292
+ ```go
293
+ package events
217
294
 
218
- // Auth middleware
219
- authMiddleware := middleware.NewAuthMiddleware(db, redisClient, cfg.Firebase)
295
+ import "myapp/internal/domain/shared"
220
296
 
221
- // Initialize modules
222
- auth.Init(r, db, redisClient, authMiddleware)
223
- user.Init(r, db, authMiddleware)
297
+ type WalletWithdrawalCreated struct {
298
+ shared.BaseEvent
299
+ WalletID string
300
+ UserID string
301
+ Amount int64
302
+ }
224
303
 
225
- return r
304
+ func NewWalletWithdrawalCreated(walletID, userID string, amount int64) WalletWithdrawalCreated {
305
+ return WalletWithdrawalCreated{
306
+ BaseEvent: shared.NewBaseEvent("wallet.withdrawal.created"),
307
+ WalletID: walletID,
308
+ UserID: userID,
309
+ Amount: amount,
310
+ }
226
311
  }
227
312
  ```
228
313
 
229
- ### internal/modules/auth/init.go
314
+ ### Use Case
315
+
230
316
  ```go
231
- package auth
317
+ package usecases
232
318
 
233
319
  import (
234
- "github.com/gin-gonic/gin"
235
- "gorm.io/gorm"
320
+ "context"
321
+ "errors"
236
322
 
237
- "myapp/internal/middleware"
238
- "myapp/internal/modules/auth/controllers"
239
- "myapp/internal/modules/auth/usecases"
240
- "myapp/pkg/redis"
323
+ "myapp/internal/domain/wallet/entities"
324
+ "myapp/internal/domain/wallet/ports"
325
+ "myapp/internal/domain/wallet/valueobjects"
241
326
  )
242
327
 
243
- func Init(
244
- r *gin.Engine,
245
- db *gorm.DB,
246
- redisClient *redis.Client,
247
- authMiddleware *middleware.AuthMiddleware,
248
- ) {
249
- // Initialize usecase
250
- usecase := usecases.NewAuthUsecase(db, redisClient)
328
+ type WithdrawUseCase struct {
329
+ walletStore ports.WalletStore
330
+ }
251
331
 
252
- // Initialize controller
253
- controller := controllers.NewAuthController(usecase)
332
+ func NewWithdrawUseCase(walletStore ports.WalletStore) *WithdrawUseCase {
333
+ return &WithdrawUseCase{walletStore: walletStore}
334
+ }
254
335
 
255
- // Register routes
256
- authGroup := r.Group("/auth")
257
- {
258
- authGroup.POST("/verify", controller.VerifyToken)
259
-
260
- // Protected routes
261
- protected := authGroup.Group("")
262
- protected.Use(authMiddleware.Authenticate())
263
- {
264
- protected.GET("/me", controller.GetMe)
265
- protected.PUT("/me", controller.UpdateMe)
266
- }
336
+ func (uc *WithdrawUseCase) Execute(ctx context.Context, walletID string, amount valueobjects.Money) (*entities.Wallet, error) {
337
+ wallet, err := uc.walletStore.FindByID(ctx, walletID)
338
+ if err != nil {
339
+ return nil, err
340
+ }
341
+
342
+ if err := wallet.Withdraw(amount); err != nil {
343
+ return nil, err
344
+ }
345
+
346
+ if err := uc.walletStore.Save(ctx, wallet); err != nil {
347
+ return nil, err
267
348
  }
349
+
350
+ return wallet, nil
268
351
  }
269
352
  ```
270
353
 
271
- ### internal/modules/auth/controllers/auth_controller.go
354
+ ---
355
+
356
+ ## Application Layer Examples
357
+
358
+ ### Handler (ports/http)
359
+
272
360
  ```go
273
- package controllers
361
+ package http
274
362
 
275
363
  import (
276
364
  "net/http"
277
365
 
278
366
  "github.com/gin-gonic/gin"
279
367
 
280
- "myapp/internal/modules/auth/dtos"
281
- "myapp/internal/modules/auth/usecases"
282
- "myapp/pkg/response"
368
+ "myapp/internal/application/services"
369
+ "myapp/internal/bootstrap"
370
+ infrahttp "myapp/internal/infrastructure/http"
283
371
  )
284
372
 
285
- type AuthController struct {
286
- usecase *usecases.AuthUsecase
373
+ type WalletHandler struct {
374
+ service *services.WalletService
287
375
  }
288
376
 
289
- func NewAuthController(usecase *usecases.AuthUsecase) *AuthController {
290
- return &AuthController{usecase: usecase}
377
+ func RegisterWalletRoutes(r *gin.RouterGroup, app *bootstrap.App) {
378
+ store := database.NewWalletStore(app.DB)
379
+ withdrawUC := usecases.NewWithdrawUseCase(store)
380
+ service := services.NewWalletService(withdrawUC)
381
+ handler := &WalletHandler{service: service}
382
+
383
+ wallets := r.Group("/wallets")
384
+ {
385
+ wallets.POST("/:id/withdraw", handler.Withdraw)
386
+ wallets.GET("/:id", handler.GetByID)
387
+ }
291
388
  }
292
389
 
293
- func (c *AuthController) VerifyToken(ctx *gin.Context) {
294
- var req dtos.VerifyTokenRequest
295
- if err := ctx.ShouldBindJSON(&req); err != nil {
296
- response.Error(ctx, http.StatusBadRequest, "Invalid request")
390
+ func (h *WalletHandler) Withdraw(c *gin.Context) {
391
+ var req WithdrawRequest
392
+ if err := c.ShouldBindJSON(&req); err != nil {
393
+ infrahttp.Error(c, http.StatusBadRequest, "Invalid request")
297
394
  return
298
395
  }
299
396
 
300
- user, err := c.usecase.VerifyToken(ctx, req.Token)
397
+ wallet, err := h.service.Withdraw(c, c.Param("id"), req.Amount, req.Currency)
301
398
  if err != nil {
302
- response.Error(ctx, http.StatusUnauthorized, "Invalid token")
399
+ infrahttp.Error(c, http.StatusUnprocessableEntity, err.Error())
303
400
  return
304
401
  }
305
402
 
306
- response.Success(ctx, dtos.ToUserResponse(user))
403
+ infrahttp.Success(c, ToWalletResponse(wallet))
307
404
  }
405
+ ```
308
406
 
309
- func (c *AuthController) GetMe(ctx *gin.Context) {
310
- userID := ctx.GetString("user_id")
407
+ ### DTOs (ports/http)
311
408
 
312
- user, err := c.usecase.GetByID(ctx, userID)
313
- if err != nil {
314
- response.Error(ctx, http.StatusNotFound, "User not found")
315
- return
316
- }
409
+ ```go
410
+ package http
317
411
 
318
- response.Success(ctx, dtos.ToUserResponse(user))
412
+ import "myapp/internal/domain/wallet/entities"
413
+
414
+ type WithdrawRequest struct {
415
+ Amount int64 `json:"amount" binding:"required,gt=0"`
416
+ Currency string `json:"currency" binding:"required"`
319
417
  }
320
418
 
321
- func (c *AuthController) UpdateMe(ctx *gin.Context) {
322
- userID := ctx.GetString("user_id")
419
+ type WalletResponse struct {
420
+ ID string `json:"id"`
421
+ UserID string `json:"user_id"`
422
+ Balance int64 `json:"balance"`
423
+ Status string `json:"status"`
424
+ }
323
425
 
324
- var req dtos.UpdateUserRequest
325
- if err := ctx.ShouldBindJSON(&req); err != nil {
326
- response.Error(ctx, http.StatusBadRequest, "Invalid request")
327
- return
426
+ func ToWalletResponse(w *entities.Wallet) *WalletResponse {
427
+ return &WalletResponse{
428
+ ID: w.ID,
429
+ UserID: w.UserID,
430
+ Balance: w.Balance.Amount(),
431
+ Status: string(w.Status),
328
432
  }
433
+ }
434
+ ```
329
435
 
330
- user, err := c.usecase.Update(ctx, userID, &req)
331
- if err != nil {
332
- response.Error(ctx, http.StatusInternalServerError, err.Error())
333
- return
334
- }
436
+ ### Service (thin wrapper)
437
+
438
+ ```go
439
+ package services
440
+
441
+ import (
442
+ "context"
335
443
 
336
- response.Success(ctx, dtos.ToUserResponse(user))
444
+ "myapp/internal/domain/wallet/entities"
445
+ "myapp/internal/domain/wallet/usecases"
446
+ "myapp/internal/domain/wallet/valueobjects"
447
+ )
448
+
449
+ type WalletService struct {
450
+ withdrawUC *usecases.WithdrawUseCase
451
+ }
452
+
453
+ func NewWalletService(withdrawUC *usecases.WithdrawUseCase) *WalletService {
454
+ return &WalletService{withdrawUC: withdrawUC}
455
+ }
456
+
457
+ func (s *WalletService) Withdraw(ctx context.Context, walletID string, amount int64, currency string) (*entities.Wallet, error) {
458
+ money := valueobjects.NewMoney(amount, currency)
459
+ return s.withdrawUC.Execute(ctx, walletID, money)
337
460
  }
338
461
  ```
339
462
 
340
- ### internal/modules/auth/usecases/auth_usecase.go
463
+ ### Listener
464
+
341
465
  ```go
342
- package usecases
466
+ package listeners
467
+
468
+ import (
469
+ "context"
470
+ "log"
471
+
472
+ "myapp/internal/bootstrap"
473
+ "myapp/internal/domain/wallet/events"
474
+ )
475
+
476
+ func OnWalletWithdrawalCreated(app *bootstrap.App) func(interface{}) {
477
+ return func(evt interface{}) {
478
+ event := evt.(events.WalletWithdrawalCreated)
479
+
480
+ go func() {
481
+ ctx := context.Background()
482
+ log.Printf("Withdrawal created: wallet=%s amount=%d", event.WalletID, event.Amount)
483
+ // Send notification, update analytics, etc.
484
+ }()
485
+ }
486
+ }
487
+ ```
488
+
489
+ ---
490
+
491
+ ## Infrastructure Layer Examples
492
+
493
+ ### Store Implementation
494
+
495
+ ```go
496
+ package database
343
497
 
344
498
  import (
345
499
  "context"
346
- "errors"
347
500
 
348
501
  "gorm.io/gorm"
349
502
 
350
- "myapp/internal/modules/auth/dtos"
351
- "myapp/pkg/database"
352
- "myapp/pkg/firebase"
353
- "myapp/pkg/redis"
503
+ "myapp/internal/domain/wallet/entities"
504
+ "myapp/internal/domain/wallet/ports"
505
+ "myapp/internal/domain/wallet/valueobjects"
506
+ "myapp/internal/models"
354
507
  )
355
508
 
356
- type AuthUsecase struct {
357
- db *gorm.DB
358
- redisClient *redis.Client
509
+ var _ ports.WalletStore = (*WalletStore)(nil)
510
+
511
+ type WalletStore struct {
512
+ db *gorm.DB
359
513
  }
360
514
 
361
- func NewAuthUsecase(db *gorm.DB, redisClient *redis.Client) *AuthUsecase {
362
- return &AuthUsecase{
363
- db: db,
364
- redisClient: redisClient,
365
- }
515
+ func NewWalletStore(db *gorm.DB) *WalletStore {
516
+ return &WalletStore{db: db}
366
517
  }
367
518
 
368
- func (u *AuthUsecase) VerifyToken(ctx context.Context, token string) (*database.User, error) {
369
- // Verify Firebase token
370
- firebaseToken, err := firebase.VerifyToken(ctx, token)
371
- if err != nil {
519
+ func (s *WalletStore) FindByID(ctx context.Context, id string) (*entities.Wallet, error) {
520
+ var model models.Wallet
521
+ if err := s.db.WithContext(ctx).First(&model, "id = ?", id).Error; err != nil {
372
522
  return nil, err
373
523
  }
524
+ return toEntity(&model), nil
525
+ }
374
526
 
375
- // Find or create user
376
- var user database.User
377
- result := u.db.Where("firebase_uid = ?", firebaseToken.UID).First(&user)
527
+ func (s *WalletStore) Save(ctx context.Context, wallet *entities.Wallet) error {
528
+ model := toModel(wallet)
529
+ return s.db.WithContext(ctx).Save(model).Error
530
+ }
378
531
 
379
- if errors.Is(result.Error, gorm.ErrRecordNotFound) {
380
- // Create new user
381
- user = database.User{
382
- FirebaseUID: firebaseToken.UID,
383
- Email: firebaseToken.Claims["email"].(string),
384
- }
385
- if err := u.db.Create(&user).Error; err != nil {
386
- return nil, err
387
- }
388
- } else if result.Error != nil {
389
- return nil, result.Error
532
+ func toEntity(m *models.Wallet) *entities.Wallet {
533
+ return &entities.Wallet{
534
+ ID: m.ID,
535
+ UserID: m.UserID,
536
+ Balance: valueobjects.NewMoney(m.Balance, m.Currency),
537
+ Status: valueobjects.WalletStatus(m.Status),
390
538
  }
539
+ }
391
540
 
392
- return &user, nil
541
+ func toModel(e *entities.Wallet) *models.Wallet {
542
+ return &models.Wallet{
543
+ ID: e.ID,
544
+ UserID: e.UserID,
545
+ Balance: e.Balance.Amount(),
546
+ Currency: e.Balance.Currency(),
547
+ Status: string(e.Status),
548
+ }
393
549
  }
550
+ ```
394
551
 
395
- func (u *AuthUsecase) GetByID(ctx context.Context, id string) (*database.User, error) {
396
- var user database.User
397
- if err := u.db.First(&user, "id = ?", id).Error; err != nil {
398
- return nil, err
552
+ ### GORM Model
553
+
554
+ ```go
555
+ package models
556
+
557
+ import (
558
+ "time"
559
+
560
+ "github.com/google/uuid"
561
+ "gorm.io/gorm"
562
+ )
563
+
564
+ type Wallet struct {
565
+ ID string `gorm:"type:char(36);primaryKey" json:"id"`
566
+ UserID string `gorm:"type:char(36);index" json:"user_id"`
567
+ Balance int64 `gorm:"type:bigint;default:0" json:"balance"`
568
+ Currency string `gorm:"type:varchar(10);default:VND" json:"currency"`
569
+ Status string `gorm:"type:varchar(20);default:active" json:"status"`
570
+ CreatedAt time.Time `json:"created_at"`
571
+ UpdatedAt time.Time `json:"updated_at"`
572
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
573
+ }
574
+
575
+ func (w *Wallet) BeforeCreate(tx *gorm.DB) error {
576
+ if w.ID == "" {
577
+ w.ID = uuid.New().String()
399
578
  }
400
- return &user, nil
579
+ return nil
401
580
  }
581
+ ```
402
582
 
403
- func (u *AuthUsecase) Update(ctx context.Context, id string, req *dtos.UpdateUserRequest) (*database.User, error) {
404
- var user database.User
405
- if err := u.db.First(&user, "id = ?", id).Error; err != nil {
406
- return nil, err
583
+ ---
584
+
585
+ ## Wiring Pattern
586
+
587
+ The `Register{Module}Routes` function wires all layers together:
588
+
589
+ ```
590
+ store → usecase → service → handler → routes
591
+ ```
592
+
593
+ ```go
594
+ func Register{Module}Routes(r *gin.RouterGroup, app *bootstrap.App) {
595
+ // 1. Infrastructure (implements ports)
596
+ store := database.New{Entity}Store(app.DB)
597
+
598
+ // 2. Domain use cases (depends on ports)
599
+ createUC := usecases.NewCreate{Entity}UseCase(store)
600
+ listUC := usecases.NewList{Entity}UseCase(store)
601
+
602
+ // 3. Application service (thin wrapper)
603
+ service := services.New{Module}Service(createUC, listUC)
604
+
605
+ // 4. HTTP handler
606
+ handler := &{Module}Handler{service: service}
607
+
608
+ // 5. Routes
609
+ group := r.Group("/{module}")
610
+ {
611
+ group.POST("", handler.Create)
612
+ group.GET("", handler.List)
613
+ group.GET("/:id", handler.GetByID)
614
+ }
615
+ }
616
+ ```
617
+
618
+ ### Bootstrap App Struct
619
+
620
+ ```go
621
+ package bootstrap
622
+
623
+ import (
624
+ "gorm.io/gorm"
625
+
626
+ "myapp/internal/application/eventbus"
627
+ "myapp/internal/config"
628
+ )
629
+
630
+ type App struct {
631
+ DB *gorm.DB
632
+ Config *config.Config
633
+ EventBus *eventbus.Dispatcher
634
+ // Add other shared dependencies
635
+ }
636
+ ```
637
+
638
+ ### Router Setup
639
+
640
+ ```go
641
+ func SetupRouter(app *bootstrap.App) *gin.Engine {
642
+ r := gin.New()
643
+
644
+ r.Use(middleware.Recovery())
645
+ r.Use(middleware.Logger())
646
+ r.Use(middleware.CORS(app.Config.AllowedOrigins))
647
+
648
+ r.GET("/health", func(c *gin.Context) {
649
+ c.JSON(200, gin.H{"status": "ok"})
650
+ })
651
+
652
+ v1 := r.Group("/v1")
653
+ {
654
+ http.RegisterWalletRoutes(v1, app)
655
+ http.RegisterAuthRoutes(v1, app)
656
+ // Register more domains here
407
657
  }
408
658
 
409
- updates := map[string]interface{}{}
410
- if req.Name != nil {
411
- updates["name"] = *req.Name
659
+ return r
660
+ }
661
+ ```
662
+
663
+ ---
664
+
665
+ ## Check Scripts
666
+
667
+ ```bash
668
+ DOMAIN={domain}
669
+
670
+ echo "=== Build ==="
671
+ go build ./internal/domain/$DOMAIN/... && echo "PASS" || echo "FAIL"
672
+
673
+ echo "=== Vet ==="
674
+ go vet ./internal/domain/$DOMAIN/... && echo "PASS" || echo "FAIL"
675
+
676
+ echo "=== Domain Purity ==="
677
+ grep -rn '"gorm.io\|"github.com/gin\|"github.com/redis\|"firebase.google.com\|"github.com/hibiken' internal/domain/$DOMAIN/ && echo "FAIL: infra in domain" || echo "PASS"
678
+
679
+ echo "=== No Cross-Domain Imports ==="
680
+ for d in $(ls internal/domain/ | grep -v shared | grep -v $DOMAIN); do
681
+ grep -rn "domain/$d" internal/domain/$DOMAIN/ && echo "FAIL: imports domain/$d"
682
+ done
683
+
684
+ echo "=== Tests ==="
685
+ go test ./internal/domain/$DOMAIN/... -v && echo "PASS" || echo "FAIL"
686
+ ```
687
+
688
+ ---
689
+
690
+ ## Test Patterns
691
+
692
+ ### Entity Tests (pure, no mocks)
693
+
694
+ ```go
695
+ package entities_test
696
+
697
+ import (
698
+ "testing"
699
+
700
+ "myapp/internal/domain/wallet/entities"
701
+ "myapp/internal/domain/wallet/valueobjects"
702
+ )
703
+
704
+ func TestWallet_Withdraw_Success(t *testing.T) {
705
+ wallet := entities.NewWallet("user-1")
706
+ wallet.Deposit(valueobjects.NewMoney(10000, "VND"))
707
+
708
+ err := wallet.Withdraw(valueobjects.NewMoney(5000, "VND"))
709
+
710
+ if err != nil {
711
+ t.Fatalf("expected no error, got %v", err)
412
712
  }
413
- if req.Phone != nil {
414
- updates["phone"] = *req.Phone
713
+ if wallet.Balance.Amount() != 5000 {
714
+ t.Fatalf("expected balance 5000, got %d", wallet.Balance.Amount())
415
715
  }
716
+ }
416
717
 
417
- if err := u.db.Model(&user).Updates(updates).Error; err != nil {
418
- return nil, err
718
+ func TestWallet_Withdraw_InsufficientBalance(t *testing.T) {
719
+ wallet := entities.NewWallet("user-1")
720
+ wallet.Deposit(valueobjects.NewMoney(1000, "VND"))
721
+
722
+ err := wallet.Withdraw(valueobjects.NewMoney(5000, "VND"))
723
+
724
+ if err == nil {
725
+ t.Fatal("expected error for insufficient balance")
419
726
  }
727
+ }
728
+
729
+ func TestWallet_RaisesEventOnWithdraw(t *testing.T) {
730
+ wallet := entities.NewWallet("user-1")
731
+ wallet.Deposit(valueobjects.NewMoney(10000, "VND"))
732
+ wallet.ClearEvents()
733
+
734
+ wallet.Withdraw(valueobjects.NewMoney(5000, "VND"))
420
735
 
421
- return &user, nil
736
+ events := wallet.Events()
737
+ if len(events) != 1 {
738
+ t.Fatalf("expected 1 event, got %d", len(events))
739
+ }
422
740
  }
423
741
  ```
424
742
 
425
- ### internal/modules/auth/dtos/auth_dto.go
743
+ ### UseCase Tests (mock ports)
744
+
426
745
  ```go
427
- package dtos
746
+ package usecases_test
747
+
748
+ import (
749
+ "context"
750
+ "testing"
751
+
752
+ "myapp/internal/domain/wallet/entities"
753
+ "myapp/internal/domain/wallet/usecases"
754
+ "myapp/internal/domain/wallet/valueobjects"
755
+ )
428
756
 
429
- import "myapp/pkg/database"
757
+ type mockWalletStore struct {
758
+ wallet *entities.Wallet
759
+ saved *entities.Wallet
760
+ }
430
761
 
431
- type VerifyTokenRequest struct {
432
- Token string `json:"token" binding:"required"`
762
+ func (m *mockWalletStore) FindByID(ctx context.Context, id string) (*entities.Wallet, error) {
763
+ if m.wallet != nil && m.wallet.ID == id {
764
+ return m.wallet, nil
765
+ }
766
+ return nil, errors.New("not found")
433
767
  }
434
768
 
435
- type UpdateUserRequest struct {
436
- Name *string `json:"name"`
437
- Phone *string `json:"phone"`
769
+ func (m *mockWalletStore) Save(ctx context.Context, wallet *entities.Wallet) error {
770
+ m.saved = wallet
771
+ return nil
438
772
  }
439
773
 
440
- type UserResponse struct {
441
- ID string `json:"id"`
442
- Email string `json:"email"`
443
- Name *string `json:"name"`
444
- Phone *string `json:"phone"`
445
- AvatarURL *string `json:"avatar_url"`
446
- CreatedAt string `json:"created_at"`
774
+ func TestWithdrawUseCase_Success(t *testing.T) {
775
+ wallet := entities.NewWallet("user-1")
776
+ wallet.Deposit(valueobjects.NewMoney(10000, "VND"))
777
+
778
+ store := &mockWalletStore{wallet: wallet}
779
+ uc := usecases.NewWithdrawUseCase(store)
780
+
781
+ result, err := uc.Execute(context.Background(), wallet.ID, valueobjects.NewMoney(5000, "VND"))
782
+
783
+ if err != nil {
784
+ t.Fatalf("expected no error, got %v", err)
785
+ }
786
+ if result.Balance.Amount() != 5000 {
787
+ t.Fatalf("expected 5000, got %d", result.Balance.Amount())
788
+ }
789
+ if store.saved == nil {
790
+ t.Fatal("expected wallet to be saved")
791
+ }
447
792
  }
793
+ ```
794
+
795
+ ### Integration Tests (real DB)
448
796
 
449
- func ToUserResponse(user *database.User) *UserResponse {
450
- return &UserResponse{
451
- ID: user.ID,
452
- Email: user.Email,
453
- Name: user.Name,
454
- Phone: user.Phone,
455
- AvatarURL: user.AvatarURL,
456
- CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
797
+ ```go
798
+ package database_test
799
+
800
+ import (
801
+ "context"
802
+ "testing"
803
+
804
+ "myapp/internal/infrastructure/database"
805
+ "myapp/internal/domain/wallet/entities"
806
+ "myapp/internal/domain/wallet/valueobjects"
807
+ "myapp/internal/models"
808
+ "myapp/internal/testutil"
809
+ )
810
+
811
+ func TestWalletStore_SaveAndFind(t *testing.T) {
812
+ db := testutil.SetupTestDB(t)
813
+ db.AutoMigrate(&models.Wallet{})
814
+
815
+ store := database.NewWalletStore(db)
816
+ ctx := context.Background()
817
+
818
+ wallet := entities.NewWallet("user-1")
819
+ wallet.Deposit(valueobjects.NewMoney(10000, "VND"))
820
+
821
+ err := store.Save(ctx, wallet)
822
+ if err != nil {
823
+ t.Fatalf("save failed: %v", err)
824
+ }
825
+
826
+ found, err := store.FindByID(ctx, wallet.ID)
827
+ if err != nil {
828
+ t.Fatalf("find failed: %v", err)
829
+ }
830
+
831
+ if found.Balance.Amount() != 10000 {
832
+ t.Fatalf("expected 10000, got %d", found.Balance.Amount())
457
833
  }
458
834
  }
459
835
  ```
460
836
 
461
- ### internal/middleware/auth.go
837
+ ---
838
+
839
+ ## Naming Conventions
840
+
841
+ | Item | Convention | Example |
842
+ |------|------------|---------|
843
+ | Package | lowercase, short | `entities`, `usecases`, `ports` |
844
+ | Domain folder | snake_case | `bank_account/` |
845
+ | File | snake_case | `wallet_store.go`, `withdraw.go` |
846
+ | Entity struct | PascalCase | `Wallet`, `Transaction` |
847
+ | Value object struct | PascalCase | `Money`, `WalletStatus` |
848
+ | Port interface | PascalCase + Store/Client suffix | `WalletStore`, `PaymentClient` |
849
+ | UseCase struct | PascalCase + UseCase suffix | `WithdrawUseCase` |
850
+ | Handler struct | PascalCase + Handler suffix | `WalletHandler` |
851
+ | Service struct | PascalCase + Service suffix | `WalletService` |
852
+ | Constructor | `New` prefix | `NewWallet()`, `NewWalletStore()` |
853
+ | Private func | camelCase | `validateToken`, `toEntity` |
854
+ | Constants | PascalCase | `WalletStatusActive` |
855
+ | Event struct | `{Domain}{Action}` PascalCase | `WalletWithdrawalCreated` |
856
+ | Listener file | `on_{event_name}.go` | `on_wallet_withdrawal_created.go` |
857
+ | Handler file | `{module}_handler.go` | `wallet_handler.go` |
858
+ | DTO file | `{module}_dtos.go` | `wallet_dtos.go` |
859
+ | Store file | `{domain}_{entity}_store.go` | `wallet_wallet_store.go` |
860
+
861
+ ---
862
+
863
+ ## Middleware
864
+
462
865
  ```go
463
866
  package middleware
464
867
 
@@ -470,21 +873,18 @@ import (
470
873
  "github.com/gin-gonic/gin"
471
874
  "gorm.io/gorm"
472
875
 
473
- "myapp/pkg/database"
474
- "myapp/pkg/firebase"
475
- "myapp/pkg/redis"
876
+ "myapp/internal/models"
877
+ "myapp/internal/infrastructure/auth"
878
+ "myapp/internal/infrastructure/cache"
476
879
  )
477
880
 
478
881
  type AuthMiddleware struct {
479
882
  db *gorm.DB
480
- redisClient *redis.Client
883
+ cache cache.Client
481
884
  }
482
885
 
483
- func NewAuthMiddleware(db *gorm.DB, redisClient *redis.Client) *AuthMiddleware {
484
- return &AuthMiddleware{
485
- db: db,
486
- redisClient: redisClient,
487
- }
886
+ func NewAuthMiddleware(db *gorm.DB, cache cache.Client) *AuthMiddleware {
887
+ return &AuthMiddleware{db: db, cache: cache}
488
888
  }
489
889
 
490
890
  func (m *AuthMiddleware) Authenticate() gin.HandlerFunc {
@@ -497,30 +897,26 @@ func (m *AuthMiddleware) Authenticate() gin.HandlerFunc {
497
897
 
498
898
  token := strings.TrimPrefix(authHeader, "Bearer ")
499
899
 
500
- // Check Redis cache first
501
900
  cacheKey := "auth:" + token[:32]
502
- if cached, err := m.redisClient.Get(c, cacheKey); err == nil {
901
+ if cached, err := m.cache.Get(c, cacheKey); err == nil {
503
902
  c.Set("user_id", cached)
504
903
  c.Next()
505
904
  return
506
905
  }
507
906
 
508
- // Verify Firebase token
509
- firebaseToken, err := firebase.VerifyToken(c, token)
907
+ firebaseToken, err := auth.VerifyToken(c, token)
510
908
  if err != nil {
511
909
  c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
512
910
  return
513
911
  }
514
912
 
515
- // Get user from database
516
- var user database.User
913
+ var user models.User
517
914
  if err := m.db.Where("firebase_uid = ?", firebaseToken.UID).First(&user).Error; err != nil {
518
915
  c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
519
916
  return
520
917
  }
521
918
 
522
- // Cache for 1 hour
523
- m.redisClient.Set(c, cacheKey, user.ID, time.Hour)
919
+ m.cache.Set(c, cacheKey, user.ID, time.Hour)
524
920
 
525
921
  c.Set("user_id", user.ID)
526
922
  c.Set("user", &user)
@@ -538,13 +934,13 @@ func (m *AuthMiddleware) Optional() gin.HandlerFunc {
538
934
 
539
935
  token := strings.TrimPrefix(authHeader, "Bearer ")
540
936
 
541
- firebaseToken, err := firebase.VerifyToken(c, token)
937
+ firebaseToken, err := auth.VerifyToken(c, token)
542
938
  if err != nil {
543
939
  c.Next()
544
940
  return
545
941
  }
546
942
 
547
- var user database.User
943
+ var user models.User
548
944
  if err := m.db.Where("firebase_uid = ?", firebaseToken.UID).First(&user).Error; err == nil {
549
945
  c.Set("user_id", user.ID)
550
946
  c.Set("user", &user)
@@ -555,87 +951,12 @@ func (m *AuthMiddleware) Optional() gin.HandlerFunc {
555
951
  }
556
952
  ```
557
953
 
558
- ### pkg/database/database.go
559
- ```go
560
- package database
954
+ ---
561
955
 
562
- import (
563
- "fmt"
564
- "log"
956
+ ## HTTP Response Helpers
565
957
 
566
- "gorm.io/driver/mysql"
567
- "gorm.io/gorm"
568
- "gorm.io/gorm/logger"
569
- )
570
-
571
- type Config struct {
572
- Host string
573
- Port string
574
- User string
575
- Password string
576
- Name string
577
- }
578
-
579
- func Connect(cfg Config) *gorm.DB {
580
- dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=UTC",
581
- cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Name)
582
-
583
- db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
584
- Logger: logger.Default.LogMode(logger.Info),
585
- })
586
- if err != nil {
587
- log.Fatalf("Failed to connect to database: %v", err)
588
- }
589
-
590
- sqlDB, _ := db.DB()
591
- sqlDB.SetMaxOpenConns(100)
592
- sqlDB.SetMaxIdleConns(10)
593
-
594
- // Auto-migrate models
595
- db.AutoMigrate(
596
- &User{},
597
- // Add other models here
598
- )
599
-
600
- return db
601
- }
602
- ```
603
-
604
- ### pkg/database/user.go
605
958
  ```go
606
- package database
607
-
608
- import (
609
- "time"
610
-
611
- "github.com/google/uuid"
612
- "gorm.io/gorm"
613
- )
614
-
615
- type User struct {
616
- ID string `gorm:"type:char(36);primaryKey" json:"id"`
617
- FirebaseUID string `gorm:"type:varchar(128);uniqueIndex" json:"-"`
618
- Email string `gorm:"type:varchar(255);uniqueIndex" json:"email"`
619
- Name *string `gorm:"type:varchar(255)" json:"name"`
620
- Phone *string `gorm:"type:varchar(20)" json:"phone"`
621
- AvatarURL *string `gorm:"type:varchar(500)" json:"avatar_url"`
622
- Status string `gorm:"type:varchar(20);default:active" json:"status"`
623
- CreatedAt time.Time `json:"created_at"`
624
- UpdatedAt time.Time `json:"updated_at"`
625
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
626
- }
627
-
628
- func (u *User) BeforeCreate(tx *gorm.DB) error {
629
- if u.ID == "" {
630
- u.ID = uuid.New().String()
631
- }
632
- return nil
633
- }
634
- ```
635
-
636
- ### pkg/response/response.go
637
- ```go
638
- package response
959
+ package http
639
960
 
640
961
  import "github.com/gin-gonic/gin"
641
962
 
@@ -654,42 +975,31 @@ type Meta struct {
654
975
  }
655
976
 
656
977
  func Success(c *gin.Context, data interface{}) {
657
- c.JSON(200, Response{
658
- Success: true,
659
- Data: data,
660
- })
978
+ c.JSON(200, Response{Success: true, Data: data})
661
979
  }
662
980
 
663
981
  func SuccessWithMeta(c *gin.Context, data interface{}, meta *Meta) {
664
- c.JSON(200, Response{
665
- Success: true,
666
- Data: data,
667
- Meta: meta,
668
- })
982
+ c.JSON(200, Response{Success: true, Data: data, Meta: meta})
669
983
  }
670
984
 
671
985
  func Error(c *gin.Context, status int, message string) {
672
- c.JSON(status, Response{
673
- Success: false,
674
- Error: message,
675
- })
986
+ c.JSON(status, Response{Success: false, Error: message})
676
987
  }
677
988
 
678
989
  func Created(c *gin.Context, data interface{}) {
679
- c.JSON(201, Response{
680
- Success: true,
681
- Data: data,
682
- })
990
+ c.JSON(201, Response{Success: true, Data: data})
683
991
  }
684
992
  ```
685
993
 
686
- ### pkg/queue/tasks.go (Asynq)
994
+ ---
995
+
996
+ ## Background Workers (Asynq)
997
+
687
998
  ```go
688
999
  package queue
689
1000
 
690
1001
  import (
691
1002
  "encoding/json"
692
-
693
1003
  "github.com/hibiken/asynq"
694
1004
  )
695
1005
 
@@ -700,10 +1010,10 @@ const (
700
1010
  )
701
1011
 
702
1012
  type NotificationPayload struct {
703
- UserID string `json:"user_id"`
704
- Title string `json:"title"`
705
- Body string `json:"body"`
706
- Data map[string]string `json:"data"`
1013
+ UserID string `json:"user_id"`
1014
+ Title string `json:"title"`
1015
+ Body string `json:"body"`
1016
+ Data map[string]string `json:"data"`
707
1017
  }
708
1018
 
709
1019
  func NewSendNotificationTask(payload *NotificationPayload) (*asynq.Task, error) {
@@ -715,206 +1025,11 @@ func NewSendNotificationTask(payload *NotificationPayload) (*asynq.Task, error)
715
1025
  }
716
1026
  ```
717
1027
 
718
- ### pkg/sse/hub.go (Server-Sent Events)
719
- ```go
720
- package sse
721
-
722
- import (
723
- "context"
724
- "encoding/json"
725
- "sync"
726
-
727
- "myapp/pkg/redis"
728
- )
729
-
730
- type Client struct {
731
- UserID string
732
- Channel chan []byte
733
- }
734
-
735
- type Hub struct {
736
- clients map[string]map[*Client]bool
737
- register chan *Client
738
- unregister chan *Client
739
- broadcast chan *Message
740
- mutex sync.RWMutex
741
- redisClient *redis.Client
742
- }
743
-
744
- type Message struct {
745
- UserID string `json:"user_id,omitempty"`
746
- Type string `json:"type"`
747
- Data interface{} `json:"data"`
748
- }
749
-
750
- func NewHub(redisClient *redis.Client) *Hub {
751
- return &Hub{
752
- clients: make(map[string]map[*Client]bool),
753
- register: make(chan *Client),
754
- unregister: make(chan *Client),
755
- broadcast: make(chan *Message, 256),
756
- redisClient: redisClient,
757
- }
758
- }
759
-
760
- func (h *Hub) Run() {
761
- // Subscribe to Redis for cross-instance communication
762
- go h.subscribeRedis()
763
-
764
- for {
765
- select {
766
- case client := <-h.register:
767
- h.mutex.Lock()
768
- if h.clients[client.UserID] == nil {
769
- h.clients[client.UserID] = make(map[*Client]bool)
770
- }
771
- h.clients[client.UserID][client] = true
772
- h.mutex.Unlock()
773
-
774
- case client := <-h.unregister:
775
- h.mutex.Lock()
776
- if clients, ok := h.clients[client.UserID]; ok {
777
- delete(clients, client)
778
- close(client.Channel)
779
- if len(clients) == 0 {
780
- delete(h.clients, client.UserID)
781
- }
782
- }
783
- h.mutex.Unlock()
784
-
785
- case message := <-h.broadcast:
786
- h.broadcastLocal(message)
787
- }
788
- }
789
- }
790
-
791
- func (h *Hub) BroadcastUser(userID string, msgType string, data interface{}) {
792
- msg := &Message{
793
- UserID: userID,
794
- Type: msgType,
795
- Data: data,
796
- }
797
-
798
- // Publish to Redis for cross-instance
799
- payload, _ := json.Marshal(msg)
800
- h.redisClient.Publish(context.Background(), "sse:broadcast", payload)
801
- }
802
-
803
- func (h *Hub) broadcastLocal(msg *Message) {
804
- h.mutex.RLock()
805
- defer h.mutex.RUnlock()
806
-
807
- payload, _ := json.Marshal(msg)
808
-
809
- if msg.UserID != "" {
810
- if clients, ok := h.clients[msg.UserID]; ok {
811
- for client := range clients {
812
- select {
813
- case client.Channel <- payload:
814
- default:
815
- close(client.Channel)
816
- delete(clients, client)
817
- }
818
- }
819
- }
820
- }
821
- }
822
-
823
- func (h *Hub) subscribeRedis() {
824
- pubsub := h.redisClient.Subscribe(context.Background(), "sse:broadcast")
825
- ch := pubsub.Channel()
826
-
827
- for msg := range ch {
828
- var message Message
829
- if err := json.Unmarshal([]byte(msg.Payload), &message); err == nil {
830
- h.broadcast <- &message
831
- }
832
- }
833
- }
834
-
835
- func (h *Hub) Register(client *Client) {
836
- h.register <- client
837
- }
838
-
839
- func (h *Hub) Unregister(client *Client) {
840
- h.unregister <- client
841
- }
842
- ```
843
-
844
- ### cmd/background/main.go (Cobra CLI)
845
- ```go
846
- package main
847
-
848
- import (
849
- "log"
850
-
851
- "github.com/spf13/cobra"
852
-
853
- "myapp/cmd/background/commands"
854
- )
855
-
856
- func main() {
857
- rootCmd := &cobra.Command{
858
- Use: "worker",
859
- Short: "Background worker CLI",
860
- }
861
-
862
- rootCmd.AddCommand(commands.ConsumerCmd())
863
- rootCmd.AddCommand(commands.SchedulerCmd())
864
- rootCmd.AddCommand(commands.SyncCmd())
865
-
866
- if err := rootCmd.Execute(); err != nil {
867
- log.Fatal(err)
868
- }
869
- }
870
- ```
871
-
872
- ### cmd/background/commands/consumer.go
873
- ```go
874
- package commands
875
-
876
- import (
877
- "log"
878
-
879
- "github.com/hibiken/asynq"
880
- "github.com/spf13/cobra"
881
-
882
- "myapp/cmd/background/consumers"
883
- "myapp/internal/config"
884
- "myapp/pkg/database"
885
- "myapp/pkg/queue"
886
- )
887
-
888
- func ConsumerCmd() *cobra.Command {
889
- return &cobra.Command{
890
- Use: "consumer",
891
- Short: "Start task consumer",
892
- Run: func(cmd *cobra.Command, args []string) {
893
- cfg := config.Load()
894
- db := database.Connect(cfg.Database)
895
-
896
- srv := asynq.NewServer(
897
- asynq.RedisClientOpt{Addr: cfg.Redis.Addr},
898
- asynq.Config{
899
- Concurrency: 10,
900
- },
901
- )
902
-
903
- mux := asynq.NewServeMux()
904
- mux.HandleFunc(queue.TaskSendNotification, consumers.HandleSendNotification(db))
905
- mux.HandleFunc(queue.TaskCleanup, consumers.HandleCleanup(db))
906
-
907
- if err := srv.Run(mux); err != nil {
908
- log.Fatalf("Failed to run server: %v", err)
909
- }
910
- },
911
- }
912
- }
913
- ```
1028
+ ---
914
1029
 
915
1030
  ## Validator Chain Pattern (Optional)
916
1031
 
917
- For complex validations:
1032
+ For complex domain validations that compose multiple rules:
918
1033
 
919
1034
  ```go
920
1035
  package validators
@@ -933,9 +1048,7 @@ type ValidatorChain struct {
933
1048
  }
934
1049
 
935
1050
  func NewValidatorChain() *ValidatorChain {
936
- return &ValidatorChain{
937
- validators: make([]Validator, 0),
938
- }
1051
+ return &ValidatorChain{validators: make([]Validator, 0)}
939
1052
  }
940
1053
 
941
1054
  func (c *ValidatorChain) Add(v Validator) *ValidatorChain {
@@ -951,89 +1064,53 @@ func (c *ValidatorChain) Validate(ctx context.Context, data interface{}) error {
951
1064
  }
952
1065
  return nil
953
1066
  }
954
-
955
- // Example validator
956
- type AmountValidator struct {
957
- MinAmount int64
958
- MaxAmount int64
959
- }
960
-
961
- func (v *AmountValidator) Validate(ctx context.Context, data interface{}) error {
962
- amount := data.(int64)
963
- if amount < v.MinAmount {
964
- return errors.New("amount too small")
965
- }
966
- if amount > v.MaxAmount {
967
- return errors.New("amount too large")
968
- }
969
- return nil
970
- }
971
1067
  ```
972
1068
 
973
- ## Conventions
974
-
975
- | Item | Convention | Example |
976
- |------|------------|---------|
977
- | Package | lowercase, short | `auth`, `user` |
978
- | Module folder | snake_case | `bank_account/` |
979
- | File | snake_case | `auth_controller.go` |
980
- | Struct | PascalCase | `AuthController` |
981
- | Interface | PascalCase | `Validator` |
982
- | Function | PascalCase | `GetByID`, `Create` |
983
- | Private func | camelCase | `validateToken` |
984
- | Constants | PascalCase | `TaskSendNotification` |
985
- | Enums | PascalCase | `StatusActive` |
1069
+ ---
986
1070
 
987
1071
  ## Makefile
988
1072
 
989
1073
  ```makefile
990
- .PHONY: dev run build test migrate
1074
+ .PHONY: dev run build test migrate lint domain-check
991
1075
 
992
1076
  dev:
993
1077
  air
994
1078
 
995
1079
  run:
996
- go run cmd/api/main.go
1080
+ go run internal/cmd/v1/main.go
997
1081
 
998
1082
  build:
999
- go build -o bin/api cmd/api/main.go
1000
- go build -o bin/worker cmd/background/main.go
1083
+ go build -o bin/api internal/cmd/v1/main.go
1001
1084
 
1002
1085
  test:
1003
1086
  go test -v ./...
1004
1087
 
1088
+ test-domain:
1089
+ go test -v ./internal/domain/...
1090
+
1005
1091
  migrate:
1006
1092
  go run cmd/migrate/main.go
1007
1093
 
1008
- worker-consumer:
1009
- go run cmd/background/main.go consumer
1094
+ lint:
1095
+ go vet ./...
1010
1096
 
1011
- worker-scheduler:
1012
- go run cmd/background/main.go scheduler
1097
+ domain-check:
1098
+ @echo "=== Domain Purity ===" && \
1099
+ grep -rn '"gorm.io\|"github.com/gin\|"github.com/redis\|"firebase.google.com\|"github.com/hibiken' internal/domain/ && echo "FAIL: infra in domain" || echo "PASS"
1013
1100
  ```
1014
1101
 
1015
- ## Tech Stack
1016
-
1017
- | Component | Technology |
1018
- |-----------|------------|
1019
- | Framework | Gin |
1020
- | ORM | GORM |
1021
- | Database | MySQL/PostgreSQL |
1022
- | Cache | Redis |
1023
- | Queue | Asynq |
1024
- | Auth | Firebase Auth |
1025
- | CLI | Cobra |
1026
- | gRPC | google.golang.org/grpc |
1027
- | Storage | AWS S3 / Cloudflare R2 |
1102
+ ---
1028
1103
 
1029
1104
  ## When to Use What
1030
1105
 
1031
1106
  | Scenario | Solution |
1032
1107
  |----------|----------|
1033
- | Simple CRUD | Controller Usecase Model |
1034
- | Complex validation | Validator Chain pattern |
1035
- | Background jobs | Asynq task queue |
1036
- | Real-time events | SSE Hub + Redis pub/sub |
1037
- | Inter-service | gRPC |
1038
- | File upload | Storage package (S3/R2) |
1039
- | Push notifications | FCM via queue |
1108
+ | New business capability | New domain with entities, ports, usecases |
1109
+ | Simple CRUD | Domain entity + store port + single usecase |
1110
+ | Complex validation | Validator Chain in domain/validators |
1111
+ | Background jobs | Asynq task queue (infrastructure) |
1112
+ | Real-time events | SSE Hub + Redis pub/sub (infrastructure) |
1113
+ | Cross-domain communication | Domain events via EventBus |
1114
+ | Inter-service | gRPC (infrastructure) |
1115
+ | File upload | Storage adapter (infrastructure) |
1116
+ | Push notifications | FCM via Asynq queue |