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.
- 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 +115 -74
- 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,14 +1,16 @@
|
|
|
1
|
-
# CLAUDE.md - Go + Gin Backend
|
|
1
|
+
# CLAUDE.md - Go + Gin Backend (DDD Architecture)
|
|
2
2
|
|
|
3
3
|
## Project Overview
|
|
4
4
|
|
|
5
5
|
Backend API service built with:
|
|
6
|
-
- **Go 1.
|
|
6
|
+
- **Go 1.24+** - Programming language
|
|
7
7
|
- **Gin** - HTTP web framework
|
|
8
8
|
- **GORM** - ORM for database operations
|
|
9
9
|
- **Redis** - Caching and session storage
|
|
10
10
|
- **Viper** - Configuration management
|
|
11
11
|
|
|
12
|
+
Architecture: **Domain-Driven Design** with strict layer separation. Domain layer has zero infrastructure dependencies.
|
|
13
|
+
|
|
12
14
|
## Quick Start
|
|
13
15
|
|
|
14
16
|
```bash
|
|
@@ -16,10 +18,10 @@ Backend API service built with:
|
|
|
16
18
|
go mod download
|
|
17
19
|
|
|
18
20
|
# Run development server
|
|
19
|
-
go run cmd/
|
|
21
|
+
go run cmd/v1/main.go
|
|
20
22
|
|
|
21
23
|
# Build for production
|
|
22
|
-
go build -o bin/api cmd/
|
|
24
|
+
go build -o bin/api cmd/v1/main.go
|
|
23
25
|
|
|
24
26
|
# Run tests
|
|
25
27
|
go test ./...
|
|
@@ -29,168 +31,610 @@ go test ./...
|
|
|
29
31
|
|
|
30
32
|
```
|
|
31
33
|
{project_name}/
|
|
32
|
-
├── cmd/
|
|
33
|
-
│
|
|
34
|
-
│
|
|
34
|
+
├── cmd/v1/
|
|
35
|
+
│ ├── main.go # Entry point + AutoMigrate
|
|
36
|
+
│ └── router.go # Route registration
|
|
35
37
|
├── internal/
|
|
36
|
-
│ ├──
|
|
37
|
-
│ │
|
|
38
|
-
│
|
|
39
|
-
│ │
|
|
40
|
-
│ │
|
|
41
|
-
│ │
|
|
42
|
-
│ └──
|
|
43
|
-
│ ├──
|
|
44
|
-
│ │ └──
|
|
45
|
-
│
|
|
46
|
-
│
|
|
47
|
-
│
|
|
48
|
-
│
|
|
49
|
-
│
|
|
50
|
-
│
|
|
51
|
-
│
|
|
52
|
-
│
|
|
53
|
-
|
|
54
|
-
│ ├──
|
|
55
|
-
│ │ └──
|
|
56
|
-
│ ├──
|
|
57
|
-
│ │ └──
|
|
58
|
-
│ └──
|
|
59
|
-
│
|
|
60
|
-
|
|
38
|
+
│ ├── domain/
|
|
39
|
+
│ │ ├── shared/ # Shared types (EventCollector, BaseEvent, EventDispatcher)
|
|
40
|
+
│ │ └── {domain}/
|
|
41
|
+
│ │ ├── entities/ # Aggregates with behavior + EventCollector
|
|
42
|
+
│ │ │ └── {entity}.go
|
|
43
|
+
│ │ ├── valueobjects/ # Typed values, only stdlib
|
|
44
|
+
│ │ │ └── {vo}.go
|
|
45
|
+
│ │ ├── ports/ # 1 interface per file
|
|
46
|
+
│ │ │ └── {store_name}.go
|
|
47
|
+
│ │ ├── events/ # 1 event per file, embed BaseEvent
|
|
48
|
+
│ │ │ └── {event_name}.go
|
|
49
|
+
│ │ ├── usecases/ # Business logic, NO infra imports
|
|
50
|
+
│ │ │ └── {action}.go
|
|
51
|
+
│ │ └── validators/ # (optional) Pure validation
|
|
52
|
+
│ ├── application/
|
|
53
|
+
│ │ ├── ports/http/
|
|
54
|
+
│ │ │ ├── {module}_handler.go # Register{Module}Routes(r gin.IRouter, app *bootstrap.App)
|
|
55
|
+
│ │ │ └── {module}_dtos.go
|
|
56
|
+
│ │ ├── services/
|
|
57
|
+
│ │ │ └── {module}_service.go
|
|
58
|
+
│ │ ├── listeners/
|
|
59
|
+
│ │ │ └── on_{event_name}.go
|
|
60
|
+
│ │ └── eventbus/
|
|
61
|
+
│ │ ├── dispatcher.go
|
|
62
|
+
│ │ └── registry.go
|
|
63
|
+
│ ├── infrastructure/
|
|
64
|
+
│ │ ├── database/
|
|
65
|
+
│ │ │ ├── mysql.go
|
|
66
|
+
│ │ │ └── {domain}_{entity}_store.go
|
|
67
|
+
│ │ ├── http/
|
|
68
|
+
│ │ │ ├── response.go
|
|
69
|
+
│ │ │ └── pagination.go
|
|
70
|
+
│ │ ├── cache/
|
|
71
|
+
│ │ ├── messaging/
|
|
72
|
+
│ │ ├── auth/
|
|
73
|
+
│ │ └── logger/
|
|
74
|
+
│ ├── models/ # GORM models (DB representation)
|
|
75
|
+
│ │ └── {entity}.go
|
|
76
|
+
│ ├── bootstrap/
|
|
77
|
+
│ │ └── app.go
|
|
78
|
+
│ └── config/
|
|
79
|
+
│ └── config.go
|
|
80
|
+
├── config.yaml
|
|
61
81
|
├── go.mod
|
|
62
82
|
└── go.sum
|
|
63
83
|
```
|
|
64
84
|
|
|
65
|
-
## Key Patterns
|
|
85
|
+
## Key Patterns
|
|
66
86
|
|
|
67
|
-
###
|
|
68
|
-
|
|
69
|
-
|
|
87
|
+
### Entity Pattern
|
|
88
|
+
|
|
89
|
+
Entities live in `internal/domain/{domain}/entities/`. They hold behavior, embed `EventCollector`, and are created via a `New()` constructor.
|
|
90
|
+
|
|
91
|
+
```go
|
|
92
|
+
// internal/domain/order/entities/order.go
|
|
93
|
+
package entities
|
|
94
|
+
|
|
95
|
+
import (
|
|
96
|
+
"time"
|
|
97
|
+
|
|
98
|
+
"github.com/google/uuid"
|
|
99
|
+
"yourproject/internal/domain/order/events"
|
|
100
|
+
"yourproject/internal/domain/order/valueobjects"
|
|
101
|
+
"yourproject/internal/domain/shared"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
type Order struct {
|
|
105
|
+
shared.EventCollector
|
|
106
|
+
ID string
|
|
107
|
+
CustomerID string
|
|
108
|
+
Status valueobjects.OrderStatus
|
|
109
|
+
Total valueobjects.Money
|
|
110
|
+
Items []OrderItem
|
|
111
|
+
CreatedAt time.Time
|
|
112
|
+
UpdatedAt time.Time
|
|
113
|
+
}
|
|
70
114
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
115
|
+
func New(customerID string, items []OrderItem, total valueobjects.Money) *Order {
|
|
116
|
+
id := uuid.New().String()
|
|
117
|
+
o := &Order{
|
|
118
|
+
ID: id,
|
|
119
|
+
CustomerID: customerID,
|
|
120
|
+
Status: valueobjects.OrderStatusPending,
|
|
121
|
+
Total: total,
|
|
122
|
+
Items: items,
|
|
123
|
+
CreatedAt: time.Now(),
|
|
124
|
+
UpdatedAt: time.Now(),
|
|
125
|
+
}
|
|
126
|
+
o.Record(events.NewOrderCreated(id, customerID, total.Amount()))
|
|
127
|
+
return o
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func (o *Order) Confirm() error {
|
|
131
|
+
if o.Status != valueobjects.OrderStatusPending {
|
|
132
|
+
return ErrOrderNotPending
|
|
133
|
+
}
|
|
134
|
+
o.Status = valueobjects.OrderStatusConfirmed
|
|
135
|
+
o.UpdatedAt = time.Now()
|
|
136
|
+
o.Record(events.NewOrderConfirmed(o.ID))
|
|
137
|
+
return nil
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func (o *Order) Cancel(reason string) error {
|
|
141
|
+
if o.Status == valueobjects.OrderStatusCancelled {
|
|
142
|
+
return ErrOrderAlreadyCancelled
|
|
143
|
+
}
|
|
144
|
+
o.Status = valueobjects.OrderStatusCancelled
|
|
145
|
+
o.UpdatedAt = time.Now()
|
|
146
|
+
o.Record(events.NewOrderCancelled(o.ID, reason))
|
|
147
|
+
return nil
|
|
148
|
+
}
|
|
149
|
+
```
|
|
76
150
|
|
|
77
|
-
###
|
|
151
|
+
### Shared EventCollector
|
|
78
152
|
|
|
79
153
|
```go
|
|
80
|
-
// internal/
|
|
81
|
-
package
|
|
154
|
+
// internal/domain/shared/event_collector.go
|
|
155
|
+
package shared
|
|
82
156
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
157
|
+
type Event interface {
|
|
158
|
+
EventName() string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type BaseEvent struct {
|
|
162
|
+
Name string
|
|
163
|
+
OccurredAt int64
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func (e BaseEvent) EventName() string { return e.Name }
|
|
167
|
+
|
|
168
|
+
type EventCollector struct {
|
|
169
|
+
events []Event
|
|
170
|
+
}
|
|
87
171
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
172
|
+
func (ec *EventCollector) Record(e Event) {
|
|
173
|
+
ec.events = append(ec.events, e)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func (ec *EventCollector) PullEvents() []Event {
|
|
177
|
+
evts := ec.events
|
|
178
|
+
ec.events = nil
|
|
179
|
+
return evts
|
|
96
180
|
}
|
|
97
181
|
```
|
|
98
182
|
|
|
99
|
-
###
|
|
183
|
+
### Value Object Pattern
|
|
184
|
+
|
|
185
|
+
Value objects live in `internal/domain/{domain}/valueobjects/`. They use only stdlib, are immutable, and validate on construction.
|
|
100
186
|
|
|
101
187
|
```go
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
"data": result,
|
|
105
|
-
})
|
|
188
|
+
// internal/domain/order/valueobjects/money.go
|
|
189
|
+
package valueobjects
|
|
106
190
|
|
|
107
|
-
|
|
108
|
-
c.JSON(http.StatusBadRequest, gin.H{
|
|
109
|
-
"error": "validation failed",
|
|
110
|
-
"details": errors,
|
|
111
|
-
})
|
|
191
|
+
import "errors"
|
|
112
192
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
193
|
+
var ErrNegativeAmount = errors.New("amount must not be negative")
|
|
194
|
+
|
|
195
|
+
type Money struct {
|
|
196
|
+
amount float64
|
|
197
|
+
currency string
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func NewMoney(amount float64, currency string) (Money, error) {
|
|
201
|
+
if amount < 0 {
|
|
202
|
+
return Money{}, ErrNegativeAmount
|
|
203
|
+
}
|
|
204
|
+
return Money{amount: amount, currency: currency}, nil
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func (m Money) Amount() float64 { return m.amount }
|
|
208
|
+
func (m Money) Currency() string { return m.currency }
|
|
209
|
+
|
|
210
|
+
func (m Money) Add(other Money) (Money, error) {
|
|
211
|
+
if m.currency != other.currency {
|
|
212
|
+
return Money{}, errors.New("currency mismatch")
|
|
213
|
+
}
|
|
214
|
+
return NewMoney(m.amount+other.amount, m.currency)
|
|
215
|
+
}
|
|
121
216
|
```
|
|
122
217
|
|
|
123
|
-
###
|
|
218
|
+
### Port Pattern
|
|
219
|
+
|
|
220
|
+
Ports live in `internal/domain/{domain}/ports/`. One interface per file. Domain defines what it needs, infrastructure implements it.
|
|
124
221
|
|
|
125
222
|
```go
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
223
|
+
// internal/domain/order/ports/order_store.go
|
|
224
|
+
package ports
|
|
225
|
+
|
|
226
|
+
import "yourproject/internal/domain/order/entities"
|
|
227
|
+
|
|
228
|
+
type OrderStore interface {
|
|
229
|
+
FindByID(id string) (*entities.Order, error)
|
|
230
|
+
FindByCustomerID(customerID string, page, limit int) ([]*entities.Order, int64, error)
|
|
231
|
+
Save(order *entities.Order) error
|
|
232
|
+
Update(order *entities.Order) error
|
|
132
233
|
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Event Pattern
|
|
133
237
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
238
|
+
Events live in `internal/domain/{domain}/events/`. One event per file, embed `BaseEvent`.
|
|
239
|
+
|
|
240
|
+
```go
|
|
241
|
+
// internal/domain/order/events/order_created.go
|
|
242
|
+
package events
|
|
243
|
+
|
|
244
|
+
import (
|
|
245
|
+
"time"
|
|
246
|
+
"yourproject/internal/domain/shared"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
type OrderCreated struct {
|
|
250
|
+
shared.BaseEvent
|
|
251
|
+
OrderID string
|
|
252
|
+
CustomerID string
|
|
253
|
+
Total float64
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func NewOrderCreated(orderID, customerID string, total float64) OrderCreated {
|
|
257
|
+
return OrderCreated{
|
|
258
|
+
BaseEvent: shared.BaseEvent{Name: "order.created", OccurredAt: time.Now().Unix()},
|
|
259
|
+
OrderID: orderID,
|
|
260
|
+
CustomerID: customerID,
|
|
261
|
+
Total: total,
|
|
262
|
+
}
|
|
137
263
|
}
|
|
138
264
|
```
|
|
139
265
|
|
|
140
|
-
|
|
266
|
+
### UseCase Pattern
|
|
141
267
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
268
|
+
Usecases live in `internal/domain/{domain}/usecases/`. One action per file. They depend only on ports (interfaces), never on infrastructure.
|
|
269
|
+
|
|
270
|
+
```go
|
|
271
|
+
// internal/domain/order/usecases/create_order.go
|
|
272
|
+
package usecases
|
|
273
|
+
|
|
274
|
+
import (
|
|
275
|
+
"yourproject/internal/domain/order/entities"
|
|
276
|
+
"yourproject/internal/domain/order/ports"
|
|
277
|
+
"yourproject/internal/domain/order/valueobjects"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
type CreateOrder struct {
|
|
281
|
+
orderStore ports.OrderStore
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
func NewCreateOrder(orderStore ports.OrderStore) *CreateOrder {
|
|
285
|
+
return &CreateOrder{orderStore: orderStore}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
type CreateOrderInput struct {
|
|
289
|
+
CustomerID string
|
|
290
|
+
Items []entities.OrderItem
|
|
291
|
+
Total float64
|
|
292
|
+
Currency string
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func (uc *CreateOrder) Execute(input CreateOrderInput) (*entities.Order, error) {
|
|
296
|
+
total, err := valueobjects.NewMoney(input.Total, input.Currency)
|
|
297
|
+
if err != nil {
|
|
298
|
+
return nil, err
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
order := entities.New(input.CustomerID, input.Items, total)
|
|
302
|
+
if err := uc.orderStore.Save(order); err != nil {
|
|
303
|
+
return nil, err
|
|
304
|
+
}
|
|
305
|
+
return order, nil
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Handler Wiring Pattern
|
|
310
|
+
|
|
311
|
+
Handlers live in `internal/application/ports/http/`. Each module exposes `Register{Module}Routes` that receives `gin.IRouter` and `*bootstrap.App`.
|
|
312
|
+
|
|
313
|
+
```go
|
|
314
|
+
// internal/application/ports/http/order_handler.go
|
|
315
|
+
package http
|
|
316
|
+
|
|
317
|
+
import (
|
|
318
|
+
"net/http"
|
|
319
|
+
|
|
320
|
+
"github.com/gin-gonic/gin"
|
|
321
|
+
"yourproject/internal/bootstrap"
|
|
322
|
+
"yourproject/internal/domain/order/usecases"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
func RegisterOrderRoutes(r gin.IRouter, app *bootstrap.App) {
|
|
326
|
+
h := &orderHandler{
|
|
327
|
+
createOrder: usecases.NewCreateOrder(app.OrderStore),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
g := r.Group("/orders")
|
|
331
|
+
{
|
|
332
|
+
g.POST("/", h.Create)
|
|
333
|
+
g.GET("/:id", h.GetByID)
|
|
334
|
+
g.GET("/", h.List)
|
|
335
|
+
g.PUT("/:id/confirm", h.Confirm)
|
|
336
|
+
g.PUT("/:id/cancel", h.Cancel)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
type orderHandler struct {
|
|
341
|
+
createOrder *usecases.CreateOrder
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
func (h *orderHandler) Create(c *gin.Context) {
|
|
345
|
+
var dto CreateOrderDTO
|
|
346
|
+
if err := c.ShouldBindJSON(&dto); err != nil {
|
|
347
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
order, err := h.createOrder.Execute(dto.ToInput())
|
|
352
|
+
if err != nil {
|
|
353
|
+
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
c.JSON(http.StatusCreated, gin.H{"data": order})
|
|
357
|
+
}
|
|
145
358
|
```
|
|
146
359
|
|
|
147
|
-
|
|
360
|
+
### DTOs
|
|
361
|
+
|
|
148
362
|
```go
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
363
|
+
// internal/application/ports/http/order_dtos.go
|
|
364
|
+
package http
|
|
365
|
+
|
|
366
|
+
import "yourproject/internal/domain/order/usecases"
|
|
367
|
+
|
|
368
|
+
type CreateOrderDTO struct {
|
|
369
|
+
CustomerID string `json:"customer_id" binding:"required"`
|
|
370
|
+
Items []OrderItemDTO `json:"items" binding:"required,dive"`
|
|
371
|
+
Currency string `json:"currency" binding:"required"`
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
func (d CreateOrderDTO) ToInput() usecases.CreateOrderInput {
|
|
375
|
+
// map DTO to usecase input
|
|
376
|
+
return usecases.CreateOrderInput{
|
|
377
|
+
CustomerID: d.CustomerID,
|
|
378
|
+
Currency: d.Currency,
|
|
379
|
+
}
|
|
152
380
|
}
|
|
153
381
|
```
|
|
154
382
|
|
|
155
|
-
|
|
383
|
+
### Store Pattern (Infrastructure)
|
|
384
|
+
|
|
385
|
+
Stores live in `internal/infrastructure/database/`. They implement domain ports using GORM models.
|
|
386
|
+
|
|
156
387
|
```go
|
|
157
|
-
|
|
158
|
-
|
|
388
|
+
// internal/infrastructure/database/order_order_store.go
|
|
389
|
+
package database
|
|
390
|
+
|
|
391
|
+
import (
|
|
392
|
+
"gorm.io/gorm"
|
|
393
|
+
"yourproject/internal/domain/order/entities"
|
|
394
|
+
"yourproject/internal/domain/order/ports"
|
|
395
|
+
"yourproject/internal/models"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
type orderStore struct {
|
|
399
|
+
db *gorm.DB
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
func NewOrderStore(db *gorm.DB) ports.OrderStore {
|
|
403
|
+
return &orderStore{db: db}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
func (s *orderStore) FindByID(id string) (*entities.Order, error) {
|
|
407
|
+
var m models.Order
|
|
408
|
+
if err := s.db.First(&m, "id = ?", id).Error; err != nil {
|
|
409
|
+
return nil, err
|
|
410
|
+
}
|
|
411
|
+
return m.ToEntity(), nil
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
func (s *orderStore) Save(order *entities.Order) error {
|
|
415
|
+
m := models.OrderFromEntity(order)
|
|
416
|
+
return s.db.Create(&m).Error
|
|
159
417
|
}
|
|
160
418
|
|
|
161
|
-
func (
|
|
162
|
-
|
|
163
|
-
|
|
419
|
+
func (s *orderStore) Update(order *entities.Order) error {
|
|
420
|
+
m := models.OrderFromEntity(order)
|
|
421
|
+
return s.db.Save(&m).Error
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
func (s *orderStore) FindByCustomerID(customerID string, page, limit int) ([]*entities.Order, int64, error) {
|
|
425
|
+
var items []models.Order
|
|
426
|
+
var total int64
|
|
427
|
+
|
|
428
|
+
s.db.Model(&models.Order{}).Where("customer_id = ?", customerID).Count(&total)
|
|
429
|
+
err := s.db.Where("customer_id = ?", customerID).
|
|
430
|
+
Offset((page - 1) * limit).Limit(limit).
|
|
431
|
+
Find(&items).Error
|
|
432
|
+
|
|
433
|
+
result := make([]*entities.Order, len(items))
|
|
434
|
+
for i, m := range items {
|
|
435
|
+
result[i] = m.ToEntity()
|
|
436
|
+
}
|
|
437
|
+
return result, total, err
|
|
164
438
|
}
|
|
165
439
|
```
|
|
166
440
|
|
|
167
|
-
|
|
441
|
+
### GORM Model Pattern
|
|
442
|
+
|
|
443
|
+
Models live in `internal/models/`. They are pure GORM structs with `ToEntity()` and `FromEntity()` converters.
|
|
444
|
+
|
|
168
445
|
```go
|
|
169
|
-
|
|
170
|
-
|
|
446
|
+
// internal/models/order.go
|
|
447
|
+
package models
|
|
448
|
+
|
|
449
|
+
import (
|
|
450
|
+
"time"
|
|
451
|
+
|
|
452
|
+
"yourproject/internal/domain/order/entities"
|
|
453
|
+
"yourproject/internal/domain/order/valueobjects"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
type Order struct {
|
|
457
|
+
ID string `gorm:"type:char(36);primaryKey" json:"id"`
|
|
458
|
+
CustomerID string `gorm:"type:char(36);not null;index" json:"customer_id"`
|
|
459
|
+
Status string `gorm:"type:varchar(50);default:'pending'" json:"status"`
|
|
460
|
+
Total float64 `gorm:"type:decimal(10,2)" json:"total"`
|
|
461
|
+
Currency string `gorm:"type:varchar(10)" json:"currency"`
|
|
462
|
+
CreatedAt time.Time `json:"created_at"`
|
|
463
|
+
UpdatedAt time.Time `json:"updated_at"`
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
func (m *Order) ToEntity() *entities.Order {
|
|
467
|
+
total, _ := valueobjects.NewMoney(m.Total, m.Currency)
|
|
468
|
+
return &entities.Order{
|
|
469
|
+
ID: m.ID,
|
|
470
|
+
CustomerID: m.CustomerID,
|
|
471
|
+
Status: valueobjects.OrderStatus(m.Status),
|
|
472
|
+
Total: total,
|
|
473
|
+
CreatedAt: m.CreatedAt,
|
|
474
|
+
UpdatedAt: m.UpdatedAt,
|
|
475
|
+
}
|
|
171
476
|
}
|
|
172
477
|
|
|
173
|
-
func (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
478
|
+
func OrderFromEntity(e *entities.Order) Order {
|
|
479
|
+
return Order{
|
|
480
|
+
ID: e.ID,
|
|
481
|
+
CustomerID: e.CustomerID,
|
|
482
|
+
Status: string(e.Status),
|
|
483
|
+
Total: e.Total.Amount(),
|
|
484
|
+
Currency: e.Total.Currency(),
|
|
485
|
+
CreatedAt: e.CreatedAt,
|
|
486
|
+
UpdatedAt: e.UpdatedAt,
|
|
487
|
+
}
|
|
180
488
|
}
|
|
181
489
|
```
|
|
182
490
|
|
|
183
|
-
|
|
491
|
+
### Bootstrap Pattern
|
|
492
|
+
|
|
493
|
+
```go
|
|
494
|
+
// internal/bootstrap/app.go
|
|
495
|
+
package bootstrap
|
|
496
|
+
|
|
497
|
+
import (
|
|
498
|
+
"yourproject/internal/domain/order/ports"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
type App struct {
|
|
502
|
+
OrderStore ports.OrderStore
|
|
503
|
+
// add more stores/services here
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Listener Pattern
|
|
508
|
+
|
|
509
|
+
Listeners live in `internal/application/listeners/`. They react to domain events.
|
|
510
|
+
|
|
511
|
+
```go
|
|
512
|
+
// internal/application/listeners/on_order_created.go
|
|
513
|
+
package listeners
|
|
514
|
+
|
|
515
|
+
import (
|
|
516
|
+
"log"
|
|
517
|
+
"yourproject/internal/domain/order/events"
|
|
518
|
+
"yourproject/internal/domain/shared"
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
type OnOrderCreated struct{}
|
|
522
|
+
|
|
523
|
+
func (l *OnOrderCreated) Handle(evt shared.Event) {
|
|
524
|
+
e := evt.(events.OrderCreated)
|
|
525
|
+
log.Printf("Order created: %s for customer %s, total: %.2f", e.OrderID, e.CustomerID, e.Total)
|
|
526
|
+
// send email, update analytics, etc.
|
|
527
|
+
}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### EventBus Pattern
|
|
531
|
+
|
|
532
|
+
```go
|
|
533
|
+
// internal/application/eventbus/dispatcher.go
|
|
534
|
+
package eventbus
|
|
535
|
+
|
|
536
|
+
import "yourproject/internal/domain/shared"
|
|
537
|
+
|
|
538
|
+
type Handler interface {
|
|
539
|
+
Handle(event shared.Event)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
type Dispatcher struct {
|
|
543
|
+
handlers map[string][]Handler
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
func NewDispatcher() *Dispatcher {
|
|
547
|
+
return &Dispatcher{handlers: make(map[string][]Handler)}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
func (d *Dispatcher) Register(eventName string, h Handler) {
|
|
551
|
+
d.handlers[eventName] = append(d.handlers[eventName], h)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
func (d *Dispatcher) Dispatch(events []shared.Event) {
|
|
555
|
+
for _, evt := range events {
|
|
556
|
+
for _, h := range d.handlers[evt.EventName()] {
|
|
557
|
+
go h.Handle(evt)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### Router Pattern
|
|
564
|
+
|
|
565
|
+
```go
|
|
566
|
+
// cmd/v1/router.go
|
|
567
|
+
package main
|
|
568
|
+
|
|
569
|
+
import (
|
|
570
|
+
"github.com/gin-gonic/gin"
|
|
571
|
+
"yourproject/internal/bootstrap"
|
|
572
|
+
apphttp "yourproject/internal/application/ports/http"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
func setupRouter(app *bootstrap.App) *gin.Engine {
|
|
576
|
+
r := gin.Default()
|
|
577
|
+
|
|
578
|
+
v1 := r.Group("/api/v1")
|
|
579
|
+
apphttp.RegisterOrderRoutes(v1, app)
|
|
580
|
+
// apphttp.RegisterProductRoutes(v1, app)
|
|
581
|
+
|
|
582
|
+
return r
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
## Adding a New Domain Module
|
|
587
|
+
|
|
588
|
+
### Step 1: Domain Layer (no dependencies)
|
|
589
|
+
|
|
590
|
+
```bash
|
|
591
|
+
mkdir -p internal/domain/{domain}/{entities,valueobjects,ports,events,usecases}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
1. Define **value objects** in `valueobjects/` (validate on construction, stdlib only)
|
|
595
|
+
2. Define **entity** in `entities/` with `New()` constructor, behavior methods, embed `EventCollector`
|
|
596
|
+
3. Define **events** in `events/` (one per file, embed `BaseEvent`)
|
|
597
|
+
4. Define **port interfaces** in `ports/` (one per file)
|
|
598
|
+
5. Write **usecases** in `usecases/` (one action per file, depend on ports only)
|
|
599
|
+
|
|
600
|
+
### Step 2: Infrastructure Layer
|
|
601
|
+
|
|
602
|
+
1. Create GORM model in `internal/models/` with `ToEntity()` / `FromEntity()`
|
|
603
|
+
2. Implement store in `internal/infrastructure/database/` satisfying the domain port
|
|
604
|
+
3. Add `AutoMigrate(&models.YourEntity{})` in `cmd/v1/main.go`
|
|
605
|
+
|
|
606
|
+
### Step 3: Application Layer
|
|
607
|
+
|
|
608
|
+
1. Add store field to `internal/bootstrap/app.go`
|
|
609
|
+
2. Create handler in `internal/application/ports/http/` with `Register{Module}Routes`
|
|
610
|
+
3. Create DTOs in `internal/application/ports/http/{module}_dtos.go`
|
|
611
|
+
4. Register routes in `cmd/v1/router.go`
|
|
612
|
+
5. (Optional) Add listeners in `internal/application/listeners/`
|
|
613
|
+
|
|
614
|
+
## Import Rules
|
|
615
|
+
|
|
616
|
+
Domain purity is enforced by convention:
|
|
617
|
+
|
|
618
|
+
| Layer | Can Import | CANNOT Import |
|
|
619
|
+
|-------|-----------|---------------|
|
|
620
|
+
| `domain/` | Only stdlib + `domain/shared/` | `application/`, `infrastructure/`, `models/`, any external lib |
|
|
621
|
+
| `domain/{x}/usecases/` | `domain/{x}/entities/`, `domain/{x}/ports/`, `domain/{x}/valueobjects/`, `domain/{x}/events/` | Anything outside its own domain |
|
|
622
|
+
| `application/` | `domain/`, `bootstrap/`, `infrastructure/` (for wiring only) | — |
|
|
623
|
+
| `infrastructure/` | `domain/ports/`, `domain/entities/`, `models/` | `application/` |
|
|
624
|
+
| `models/` | `domain/entities/`, `domain/valueobjects/` | `application/`, `infrastructure/` |
|
|
625
|
+
|
|
626
|
+
**Golden rule**: If you need to add `gorm.io` or any external import to a file under `domain/`, you are doing it wrong.
|
|
184
627
|
|
|
185
628
|
## API Endpoints Pattern
|
|
186
629
|
|
|
187
630
|
| Method | Path | Description |
|
|
188
631
|
|--------|------|-------------|
|
|
189
|
-
| GET | /api/{resource}/ | List
|
|
190
|
-
| GET | /api/{resource}/:id | Get single item |
|
|
191
|
-
| POST | /api/{resource}/ | Create item |
|
|
192
|
-
| PUT | /api/{resource}/:id | Update item |
|
|
193
|
-
|
|
|
632
|
+
| GET | /api/v1/{resource}/ | List items (paginated) |
|
|
633
|
+
| GET | /api/v1/{resource}/:id | Get single item |
|
|
634
|
+
| POST | /api/v1/{resource}/ | Create item |
|
|
635
|
+
| PUT | /api/v1/{resource}/:id | Update item |
|
|
636
|
+
| PUT | /api/v1/{resource}/:id/{action} | Trigger domain action |
|
|
637
|
+
| DELETE | /api/v1/{resource}/:id | Delete item |
|
|
194
638
|
|
|
195
639
|
### Query Parameters for List
|
|
196
640
|
- `page` - Page number (default: 1)
|
|
@@ -199,6 +643,27 @@ func (ctrl *Controller) List(c *gin.Context) {
|
|
|
199
643
|
- `sort_by` - Sort field
|
|
200
644
|
- `order` - Sort order (asc/desc)
|
|
201
645
|
|
|
646
|
+
### Response Format
|
|
647
|
+
|
|
648
|
+
```go
|
|
649
|
+
// internal/infrastructure/http/response.go
|
|
650
|
+
|
|
651
|
+
// Success
|
|
652
|
+
c.JSON(http.StatusOK, gin.H{"data": result})
|
|
653
|
+
|
|
654
|
+
// Error
|
|
655
|
+
c.JSON(http.StatusBadRequest, gin.H{"error": "validation failed", "details": errors})
|
|
656
|
+
|
|
657
|
+
// Paginated
|
|
658
|
+
c.JSON(http.StatusOK, gin.H{
|
|
659
|
+
"data": items,
|
|
660
|
+
"total": total,
|
|
661
|
+
"page": page,
|
|
662
|
+
"limit": limit,
|
|
663
|
+
"total_pages": totalPages,
|
|
664
|
+
})
|
|
665
|
+
```
|
|
666
|
+
|
|
202
667
|
## Configuration
|
|
203
668
|
|
|
204
669
|
### config.yaml
|
|
@@ -231,14 +696,99 @@ jwt:
|
|
|
231
696
|
|
|
232
697
|
## Testing
|
|
233
698
|
|
|
699
|
+
### Domain Unit Tests (no mocks needed)
|
|
700
|
+
|
|
701
|
+
```go
|
|
702
|
+
// internal/domain/order/entities/order_test.go
|
|
703
|
+
func TestNewOrder(t *testing.T) {
|
|
704
|
+
total, _ := valueobjects.NewMoney(100, "USD")
|
|
705
|
+
order := entities.New("customer-1", nil, total)
|
|
706
|
+
|
|
707
|
+
assert.NotEmpty(t, order.ID)
|
|
708
|
+
assert.Equal(t, valueobjects.OrderStatusPending, order.Status)
|
|
709
|
+
|
|
710
|
+
events := order.PullEvents()
|
|
711
|
+
assert.Len(t, events, 1)
|
|
712
|
+
assert.Equal(t, "order.created", events[0].EventName())
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
func TestOrder_Confirm(t *testing.T) {
|
|
716
|
+
total, _ := valueobjects.NewMoney(100, "USD")
|
|
717
|
+
order := entities.New("customer-1", nil, total)
|
|
718
|
+
order.PullEvents() // clear creation events
|
|
719
|
+
|
|
720
|
+
err := order.Confirm()
|
|
721
|
+
assert.NoError(t, err)
|
|
722
|
+
assert.Equal(t, valueobjects.OrderStatusConfirmed, order.Status)
|
|
723
|
+
|
|
724
|
+
events := order.PullEvents()
|
|
725
|
+
assert.Len(t, events, 1)
|
|
726
|
+
assert.Equal(t, "order.confirmed", events[0].EventName())
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
func TestOrder_Confirm_WhenNotPending_ReturnsError(t *testing.T) {
|
|
730
|
+
total, _ := valueobjects.NewMoney(100, "USD")
|
|
731
|
+
order := entities.New("customer-1", nil, total)
|
|
732
|
+
_ = order.Confirm()
|
|
733
|
+
|
|
734
|
+
err := order.Confirm()
|
|
735
|
+
assert.ErrorIs(t, err, entities.ErrOrderNotPending)
|
|
736
|
+
}
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
### Value Object Tests
|
|
740
|
+
|
|
741
|
+
```go
|
|
742
|
+
// internal/domain/order/valueobjects/money_test.go
|
|
743
|
+
func TestNewMoney_NegativeAmount(t *testing.T) {
|
|
744
|
+
_, err := valueobjects.NewMoney(-10, "USD")
|
|
745
|
+
assert.ErrorIs(t, err, valueobjects.ErrNegativeAmount)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
func TestMoney_Add_CurrencyMismatch(t *testing.T) {
|
|
749
|
+
usd, _ := valueobjects.NewMoney(10, "USD")
|
|
750
|
+
eur, _ := valueobjects.NewMoney(5, "EUR")
|
|
751
|
+
_, err := usd.Add(eur)
|
|
752
|
+
assert.Error(t, err)
|
|
753
|
+
}
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### UseCase Tests (mock ports)
|
|
757
|
+
|
|
234
758
|
```go
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
759
|
+
// internal/domain/order/usecases/create_order_test.go
|
|
760
|
+
type mockOrderStore struct {
|
|
761
|
+
saved *entities.Order
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
func (m *mockOrderStore) Save(o *entities.Order) error {
|
|
765
|
+
m.saved = o
|
|
766
|
+
return nil
|
|
767
|
+
}
|
|
768
|
+
func (m *mockOrderStore) FindByID(id string) (*entities.Order, error) { return nil, nil }
|
|
769
|
+
func (m *mockOrderStore) Update(o *entities.Order) error { return nil }
|
|
770
|
+
func (m *mockOrderStore) FindByCustomerID(string, int, int) ([]*entities.Order, int64, error) {
|
|
771
|
+
return nil, 0, nil
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
func TestCreateOrder_Execute(t *testing.T) {
|
|
775
|
+
store := &mockOrderStore{}
|
|
776
|
+
uc := usecases.NewCreateOrder(store)
|
|
777
|
+
|
|
778
|
+
order, err := uc.Execute(usecases.CreateOrderInput{
|
|
779
|
+
CustomerID: "cust-1",
|
|
780
|
+
Total: 99.99,
|
|
781
|
+
Currency: "USD",
|
|
782
|
+
})
|
|
241
783
|
|
|
242
|
-
|
|
784
|
+
assert.NoError(t, err)
|
|
785
|
+
assert.NotNil(t, order)
|
|
786
|
+
assert.NotNil(t, store.saved)
|
|
787
|
+
assert.Equal(t, "cust-1", store.saved.CustomerID)
|
|
243
788
|
}
|
|
244
789
|
```
|
|
790
|
+
|
|
791
|
+
### File Naming
|
|
792
|
+
- Use `snake_case.go` for all Go files
|
|
793
|
+
- One struct/interface per file when possible
|
|
794
|
+
- Test files: `{name}_test.go` in the same package
|