refactor: turn the repo into a two-plugin marketplace
tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.
The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.
tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.
test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
# 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
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user