threedotslab/references/rules-cqrs.md

6.9 KiB

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:

type ScheduleTraining struct {
    Hour time.Time
}

type CancelTraining struct {
    Hour time.Time
}

type MakeHoursAvailable struct {
    Hours []time.Time
}

Wrong:

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:

type AvailableHours struct {
    From time.Time
    To   time.Time
}

type HourAvailability struct {
    Hour time.Time
}

Wrong:

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.

// 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.

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.

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.

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.

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.

// 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:

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:

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:

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
}