threedotslab/references/rules-cqrs.md

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
}
```