277 lines
6.9 KiB
Markdown
277 lines
6.9 KiB
Markdown
# CQRS Rules (CQRS-01..10)
|
|
|
|
## CQRS-01: Command Struct Pattern (CRITICAL)
|
|
|
|
Commands MUST be:
|
|
- Named with imperative verb + noun (domain language, NOT CRUD)
|
|
- Plain data structs (no methods, no interfaces)
|
|
- Their handler returns `error` only — no data
|
|
|
|
**Correct:**
|
|
```go
|
|
type ScheduleTraining struct {
|
|
Hour time.Time
|
|
}
|
|
|
|
type CancelTraining struct {
|
|
Hour time.Time
|
|
}
|
|
|
|
type MakeHoursAvailable struct {
|
|
Hours []time.Time
|
|
}
|
|
```
|
|
|
|
**Wrong:**
|
|
```go
|
|
type CreateTraining struct { ... } // VIOLATION: CRUD naming
|
|
type UpdateHour struct { ... } // VIOLATION: CRUD naming
|
|
```
|
|
|
|
---
|
|
|
|
## CQRS-02: Query Struct Pattern (CRITICAL)
|
|
|
|
Queries MUST be:
|
|
- Named with noun phrases (NOT "Get" + noun)
|
|
- Plain data structs
|
|
- Their handler returns `(ResultType, error)`
|
|
|
|
**Correct:**
|
|
```go
|
|
type AvailableHours struct {
|
|
From time.Time
|
|
To time.Time
|
|
}
|
|
|
|
type HourAvailability struct {
|
|
Hour time.Time
|
|
}
|
|
```
|
|
|
|
**Wrong:**
|
|
```go
|
|
type GetAvailableHours struct { ... } // VIOLATION: "Get" prefix
|
|
type FetchTrainings struct { ... } // VIOLATION: "Fetch" prefix
|
|
```
|
|
|
|
---
|
|
|
|
## CQRS-03: Exported Handler Type Alias (WARNING)
|
|
|
|
Each handler file MUST define an exported type alias using the generic decorator interface.
|
|
|
|
```go
|
|
// For commands:
|
|
type CancelTrainingHandler decorator.CommandHandler[CancelTraining]
|
|
|
|
// For queries:
|
|
type AvailableHoursHandler decorator.QueryHandler[AvailableHours, []Date]
|
|
```
|
|
|
|
This allows callers to depend on the decorated interface, not the concrete struct.
|
|
|
|
---
|
|
|
|
## CQRS-04: Unexported Handler Struct (WARNING)
|
|
|
|
The concrete handler struct MUST be unexported (lowercase). It holds dependencies injected via constructor.
|
|
|
|
```go
|
|
type cancelTrainingHandler struct {
|
|
hourRepo hour.Repository
|
|
}
|
|
|
|
type availableHoursHandler struct {
|
|
readModel AvailableHoursReadModel
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## CQRS-05: Constructor Wraps with Decorators (WARNING)
|
|
|
|
Handler constructors MUST wrap the concrete handler with `ApplyCommandDecorators` or `ApplyQueryDecorators`.
|
|
|
|
```go
|
|
func NewCancelTrainingHandler(
|
|
hourRepo hour.Repository,
|
|
logger *logrus.Entry,
|
|
metricsClient decorator.MetricsClient,
|
|
) CancelTrainingHandler {
|
|
return decorator.ApplyCommandDecorators[CancelTraining](
|
|
cancelTrainingHandler{hourRepo: hourRepo},
|
|
logger,
|
|
metricsClient,
|
|
)
|
|
}
|
|
|
|
func NewAvailableHoursHandler(
|
|
readModel AvailableHoursReadModel,
|
|
logger *logrus.Entry,
|
|
metricsClient decorator.MetricsClient,
|
|
) AvailableHoursHandler {
|
|
return decorator.ApplyQueryDecorators[AvailableHours, []Date](
|
|
availableHoursHandler{readModel: readModel},
|
|
logger,
|
|
metricsClient,
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## CQRS-06: Constructor Nil-Checks with Panic (WARNING)
|
|
|
|
Handler constructors SHOULD nil-check all injected dependencies and panic if any are nil. This is a fail-fast pattern — misconfiguration is caught at startup, not at runtime.
|
|
|
|
```go
|
|
func NewCancelTrainingHandler(
|
|
hourRepo hour.Repository,
|
|
logger *logrus.Entry,
|
|
metricsClient decorator.MetricsClient,
|
|
) CancelTrainingHandler {
|
|
if hourRepo == nil {
|
|
panic("nil hourRepo")
|
|
}
|
|
if logger == nil {
|
|
panic("nil logger")
|
|
}
|
|
if metricsClient == nil {
|
|
panic("nil metricsClient")
|
|
}
|
|
return decorator.ApplyCommandDecorators[CancelTraining](
|
|
cancelTrainingHandler{hourRepo: hourRepo},
|
|
logger,
|
|
metricsClient,
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## CQRS-07: Application Struct (CRITICAL)
|
|
|
|
The `app/app.go` file MUST define an `Application` struct that bundles `Commands` and `Queries` sub-structs.
|
|
|
|
```go
|
|
type Application struct {
|
|
Commands Commands
|
|
Queries Queries
|
|
}
|
|
|
|
type Commands struct {
|
|
CancelTraining command.CancelTrainingHandler
|
|
ScheduleTraining command.ScheduleTrainingHandler
|
|
MakeHoursAvailable command.MakeHoursAvailableHandler
|
|
MakeHoursUnavailable command.MakeHoursUnavailableHandler
|
|
}
|
|
|
|
type Queries struct {
|
|
HourAvailability query.HourAvailabilityHandler
|
|
TrainerAvailableHours query.AvailableHoursHandler
|
|
}
|
|
```
|
|
|
|
**Check:** Look for `app.go` in the `app/` package. Verify it has `Application`, `Commands`, and `Queries` types.
|
|
|
|
---
|
|
|
|
## CQRS-08: Read Model Interface for Queries (WARNING)
|
|
|
|
Query handlers SHOULD depend on a dedicated read model interface, not the write repository.
|
|
|
|
```go
|
|
// In app/query/ — defines what it needs
|
|
type AvailableHoursReadModel interface {
|
|
AvailableHours(ctx context.Context, from, to time.Time) ([]Date, error)
|
|
}
|
|
```
|
|
|
|
This keeps reads and writes separate. The same adapter may implement both the write `Repository` and a read model interface, but the query handler only knows about the read model.
|
|
|
|
---
|
|
|
|
## CQRS-09: Command/Query Separation (CRITICAL)
|
|
|
|
- **Commands** MUST modify state and return only `error`
|
|
- **Queries** MUST read state and return `(ResultType, error)` — they MUST NOT modify state
|
|
|
|
A handler that both reads and writes violates CQRS.
|
|
|
|
**Check:** Command handlers returning anything besides `error` is a violation. Query handlers calling mutation methods on repositories is a violation.
|
|
|
|
---
|
|
|
|
## CQRS-10: No Business Logic in Handlers (WARNING)
|
|
|
|
Handlers are orchestrators. Business rules live in domain entities.
|
|
|
|
**Correct** — handler delegates to domain:
|
|
```go
|
|
func (h cancelTrainingHandler) Handle(ctx context.Context, cmd CancelTraining) error {
|
|
return h.hourRepo.UpdateHour(ctx, cmd.Hour, func(h *hour.Hour) (*hour.Hour, error) {
|
|
if err := h.CancelTraining(); err != nil { // domain method
|
|
return nil, err
|
|
}
|
|
return h, nil
|
|
})
|
|
}
|
|
```
|
|
|
|
**Wrong** — business logic in handler:
|
|
```go
|
|
func (h cancelTrainingHandler) Handle(ctx context.Context, cmd CancelTraining) error {
|
|
hour, _ := h.hourRepo.GetHour(ctx, cmd.Hour)
|
|
if hour.Availability != "training_scheduled" { // VIOLATION: logic belongs in domain
|
|
return errors.New("no training to cancel")
|
|
}
|
|
hour.Availability = "available" // VIOLATION: direct field mutation
|
|
return h.hourRepo.Save(ctx, hour)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Complete Handler File Template
|
|
|
|
Every command/query handler file follows this 4-component pattern:
|
|
|
|
```go
|
|
package command
|
|
|
|
// 1. Command struct
|
|
type CancelTraining struct {
|
|
Hour time.Time
|
|
}
|
|
|
|
// 2. Exported handler type (alias to decorator interface)
|
|
type CancelTrainingHandler decorator.CommandHandler[CancelTraining]
|
|
|
|
// 3. Unexported concrete handler
|
|
type cancelTrainingHandler struct {
|
|
hourRepo hour.Repository
|
|
}
|
|
|
|
// 4. Constructor with nil-checks + decorator wrapping
|
|
func NewCancelTrainingHandler(
|
|
hourRepo hour.Repository,
|
|
logger *logrus.Entry,
|
|
metricsClient decorator.MetricsClient,
|
|
) CancelTrainingHandler {
|
|
if hourRepo == nil {
|
|
panic("nil hourRepo")
|
|
}
|
|
return decorator.ApplyCommandDecorators[CancelTraining](
|
|
cancelTrainingHandler{hourRepo: hourRepo},
|
|
logger,
|
|
metricsClient,
|
|
)
|
|
}
|
|
|
|
// Handle method on unexported struct
|
|
func (h cancelTrainingHandler) Handle(ctx context.Context, cmd CancelTraining) error {
|
|
// orchestration only — delegate to domain
|
|
}
|
|
```
|