moicle 1.3.0 → 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.
- package/README.md +2 -1
- package/assets/architecture/ddd-architecture.md +337 -0
- package/assets/architecture/go-backend.md +770 -693
- package/assets/architecture/laravel-backend.md +388 -156
- package/assets/skills/architect-review/SKILL.md +292 -372
- package/assets/skills/deep-debug/SKILL.md +114 -0
- package/assets/skills/new-feature/SKILL.md +232 -252
- package/assets/skills/refactor/SKILL.md +261 -679
- package/assets/skills/sync-docs/SKILL.md +575 -0
- package/assets/templates/go-gin/CLAUDE.md +671 -121
- package/package.json +1 -1
- package/assets/architecture/clean-architecture.md +0 -143
- package/assets/skills/go-module/SKILL.md +0 -77
- package/assets/skills/ship/SKILL.md +0 -297
|
@@ -1,464 +1,867 @@
|
|
|
1
1
|
# Go Backend Architecture
|
|
2
2
|
|
|
3
|
-
> Production-grade
|
|
3
|
+
> Production-grade DDD with Hexagonal Architecture for Go + Gin
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
├──
|
|
10
|
-
│ ├──
|
|
11
|
-
│ │
|
|
12
|
-
│
|
|
13
|
-
│ └──
|
|
14
|
-
│
|
|
15
|
-
│
|
|
16
|
-
│
|
|
17
|
-
│
|
|
18
|
-
│
|
|
19
|
-
│
|
|
20
|
-
│
|
|
21
|
-
│
|
|
22
|
-
├──
|
|
23
|
-
│ ├──
|
|
24
|
-
│
|
|
25
|
-
│
|
|
26
|
-
|
|
27
|
-
│
|
|
28
|
-
│ │ ├──
|
|
29
|
-
│ │
|
|
30
|
-
│
|
|
31
|
-
│ │ └──
|
|
32
|
-
│ ├──
|
|
33
|
-
│ │ └──
|
|
34
|
-
│ └──
|
|
35
|
-
│ ├──
|
|
36
|
-
│
|
|
37
|
-
│
|
|
38
|
-
|
|
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
|
-
│ │
|
|
57
|
-
│
|
|
58
|
-
│
|
|
59
|
-
│ ├──
|
|
60
|
-
│ ├──
|
|
61
|
-
│ ├──
|
|
62
|
-
│
|
|
63
|
-
│
|
|
64
|
-
│
|
|
65
|
-
│
|
|
66
|
-
|
|
67
|
-
│
|
|
68
|
-
│
|
|
69
|
-
|
|
70
|
-
│
|
|
71
|
-
├──
|
|
72
|
-
├──
|
|
73
|
-
├── .
|
|
74
|
-
├── go
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Hard Rules
|
|
92
121
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
132
|
+
---
|
|
101
133
|
|
|
102
|
-
|
|
134
|
+
## Forbidden Imports in Domain
|
|
103
135
|
|
|
104
136
|
```
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Domain Layer Examples
|
|
147
|
+
|
|
148
|
+
### Entity with Behavior
|
|
118
149
|
|
|
119
|
-
### cmd/api/main.go
|
|
120
150
|
```go
|
|
121
|
-
package
|
|
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/
|
|
133
|
-
"myapp/
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
183
|
+
func (w *Wallet) IsActive() bool {
|
|
184
|
+
return w.Status == valueobjects.WalletStatusActive
|
|
185
|
+
}
|
|
150
186
|
|
|
151
|
-
|
|
152
|
-
|
|
187
|
+
func (w *Wallet) CanWithdraw(amount valueobjects.Money) bool {
|
|
188
|
+
return w.IsActive() && w.Balance.GreaterOrEqual(amount)
|
|
189
|
+
}
|
|
153
190
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
248
|
+
const (
|
|
249
|
+
WalletStatusActive WalletStatus = "active"
|
|
250
|
+
WalletStatusFrozen WalletStatus = "frozen"
|
|
251
|
+
WalletStatusClosed WalletStatus = "closed"
|
|
252
|
+
)
|
|
175
253
|
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
###
|
|
270
|
+
### Port Interface
|
|
271
|
+
|
|
183
272
|
```go
|
|
184
|
-
package
|
|
273
|
+
package ports
|
|
185
274
|
|
|
186
275
|
import (
|
|
187
|
-
"
|
|
188
|
-
"gorm.io/gorm"
|
|
276
|
+
"context"
|
|
189
277
|
|
|
190
|
-
"myapp/internal/
|
|
191
|
-
"myapp/internal/
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
r.Use(middleware.Recovery())
|
|
210
|
-
r.Use(middleware.Logger())
|
|
211
|
-
r.Use(middleware.CORS(cfg.AllowedOrigins))
|
|
290
|
+
### Domain Event
|
|
212
291
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
c.JSON(200, gin.H{"status": "ok"})
|
|
216
|
-
})
|
|
292
|
+
```go
|
|
293
|
+
package events
|
|
217
294
|
|
|
218
|
-
|
|
219
|
-
authMiddleware := middleware.NewAuthMiddleware(db, redisClient, cfg.Firebase)
|
|
295
|
+
import "myapp/internal/domain/shared"
|
|
220
296
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
297
|
+
type WalletWithdrawalCreated struct {
|
|
298
|
+
shared.BaseEvent
|
|
299
|
+
WalletID string
|
|
300
|
+
UserID string
|
|
301
|
+
Amount int64
|
|
302
|
+
}
|
|
224
303
|
|
|
225
|
-
|
|
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
|
-
###
|
|
314
|
+
### Use Case
|
|
315
|
+
|
|
230
316
|
```go
|
|
231
|
-
package
|
|
317
|
+
package usecases
|
|
232
318
|
|
|
233
319
|
import (
|
|
234
|
-
"
|
|
235
|
-
"
|
|
320
|
+
"context"
|
|
321
|
+
"errors"
|
|
236
322
|
|
|
237
|
-
"myapp/internal/
|
|
238
|
-
"myapp/internal/
|
|
239
|
-
"myapp/internal/
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
253
|
-
|
|
332
|
+
func NewWithdrawUseCase(walletStore ports.WalletStore) *WithdrawUseCase {
|
|
333
|
+
return &WithdrawUseCase{walletStore: walletStore}
|
|
334
|
+
}
|
|
254
335
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
{
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Application Layer Examples
|
|
357
|
+
|
|
358
|
+
### Handler (ports/http)
|
|
359
|
+
|
|
272
360
|
```go
|
|
273
|
-
package
|
|
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/
|
|
281
|
-
"myapp/internal/
|
|
282
|
-
"myapp/
|
|
368
|
+
"myapp/internal/application/services"
|
|
369
|
+
"myapp/internal/bootstrap"
|
|
370
|
+
infrahttp "myapp/internal/infrastructure/http"
|
|
283
371
|
)
|
|
284
372
|
|
|
285
|
-
type
|
|
286
|
-
|
|
373
|
+
type WalletHandler struct {
|
|
374
|
+
service *services.WalletService
|
|
287
375
|
}
|
|
288
376
|
|
|
289
|
-
func
|
|
290
|
-
|
|
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 (
|
|
294
|
-
var req
|
|
295
|
-
if err :=
|
|
296
|
-
|
|
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
|
-
|
|
397
|
+
wallet, err := h.service.Withdraw(c, c.Param("id"), req.Amount, req.Currency)
|
|
301
398
|
if err != nil {
|
|
302
|
-
|
|
399
|
+
infrahttp.Error(c, http.StatusUnprocessableEntity, err.Error())
|
|
303
400
|
return
|
|
304
401
|
}
|
|
305
402
|
|
|
306
|
-
|
|
403
|
+
infrahttp.Success(c, ToWalletResponse(wallet))
|
|
307
404
|
}
|
|
405
|
+
```
|
|
308
406
|
|
|
309
|
-
|
|
310
|
-
userID := ctx.GetString("user_id")
|
|
407
|
+
### DTOs (ports/http)
|
|
311
408
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
response.Error(ctx, http.StatusNotFound, "User not found")
|
|
315
|
-
return
|
|
316
|
-
}
|
|
409
|
+
```go
|
|
410
|
+
package http
|
|
317
411
|
|
|
318
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
436
|
+
### Service (thin wrapper)
|
|
437
|
+
|
|
438
|
+
```go
|
|
439
|
+
package services
|
|
440
|
+
|
|
441
|
+
import (
|
|
442
|
+
"context"
|
|
335
443
|
|
|
336
|
-
|
|
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
|
-
###
|
|
463
|
+
### Listener
|
|
464
|
+
|
|
341
465
|
```go
|
|
342
|
-
package
|
|
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/
|
|
351
|
-
"myapp/
|
|
352
|
-
"myapp/
|
|
353
|
-
"myapp/
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
509
|
+
var _ ports.WalletStore = (*WalletStore)(nil)
|
|
510
|
+
|
|
511
|
+
type WalletStore struct {
|
|
512
|
+
db *gorm.DB
|
|
359
513
|
}
|
|
360
514
|
|
|
361
|
-
func
|
|
362
|
-
return &
|
|
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 (
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
579
|
+
return nil
|
|
401
580
|
}
|
|
581
|
+
```
|
|
402
582
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
414
|
-
|
|
713
|
+
if wallet.Balance.Amount() != 5000 {
|
|
714
|
+
t.Fatalf("expected balance 5000, got %d", wallet.Balance.Amount())
|
|
415
715
|
}
|
|
716
|
+
}
|
|
416
717
|
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
743
|
+
### UseCase Tests (mock ports)
|
|
744
|
+
|
|
426
745
|
```go
|
|
427
|
-
package
|
|
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
|
-
|
|
757
|
+
type mockWalletStore struct {
|
|
758
|
+
wallet *entities.Wallet
|
|
759
|
+
saved *entities.Wallet
|
|
760
|
+
}
|
|
430
761
|
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
769
|
+
func (m *mockWalletStore) Save(ctx context.Context, wallet *entities.Wallet) error {
|
|
770
|
+
m.saved = wallet
|
|
771
|
+
return nil
|
|
438
772
|
}
|
|
439
773
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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/
|
|
474
|
-
"myapp/
|
|
475
|
-
"myapp/
|
|
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
|
-
|
|
883
|
+
cache cache.Client
|
|
481
884
|
}
|
|
482
885
|
|
|
483
|
-
func NewAuthMiddleware(db *gorm.DB,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 :=
|
|
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
|
|
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
|
-
|
|
559
|
-
```go
|
|
560
|
-
package database
|
|
954
|
+
---
|
|
561
955
|
|
|
562
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
704
|
-
Title
|
|
705
|
-
Body
|
|
706
|
-
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
|
-
|
|
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
|
-
|
|
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/
|
|
1080
|
+
go run internal/cmd/v1/main.go
|
|
997
1081
|
|
|
998
1082
|
build:
|
|
999
|
-
go build -o bin/api cmd/
|
|
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
|
-
|
|
1009
|
-
go
|
|
1094
|
+
lint:
|
|
1095
|
+
go vet ./...
|
|
1010
1096
|
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
1034
|
-
|
|
|
1035
|
-
|
|
|
1036
|
-
|
|
|
1037
|
-
|
|
|
1038
|
-
|
|
|
1039
|
-
|
|
|
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 |
|