From de4efe97bfa1354d9ab8f8808dc199c2ca3214c0 Mon Sep 17 00:00:00 2001 From: naudachu Date: Fri, 6 Mar 2026 21:38:51 +0500 Subject: [PATCH] :fire: --- SKILL.md | 188 +++++++++++++++++++++ references/rules-architecture.md | 110 ++++++++++++ references/rules-codestyle.md | 220 ++++++++++++++++++++++++ references/rules-cqrs.md | 276 +++++++++++++++++++++++++++++++ references/rules-domain.md | 265 +++++++++++++++++++++++++++++ references/rules-errors.md | 150 +++++++++++++++++ references/rules-naming.md | 52 ++++++ references/rules-ports.md | 148 +++++++++++++++++ references/rules-repository.md | 181 ++++++++++++++++++++ templates/command.md | 115 +++++++++++++ templates/entity.md | 156 +++++++++++++++++ templates/query.md | 124 ++++++++++++++ templates/repo.md | 212 ++++++++++++++++++++++++ templates/service.md | 223 +++++++++++++++++++++++++ 14 files changed, 2420 insertions(+) create mode 100644 SKILL.md create mode 100644 references/rules-architecture.md create mode 100644 references/rules-codestyle.md create mode 100644 references/rules-cqrs.md create mode 100644 references/rules-domain.md create mode 100644 references/rules-errors.md create mode 100644 references/rules-naming.md create mode 100644 references/rules-ports.md create mode 100644 references/rules-repository.md create mode 100644 templates/command.md create mode 100644 templates/entity.md create mode 100644 templates/query.md create mode 100644 templates/repo.md create mode 100644 templates/service.md diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..c32262b --- /dev/null +++ b/SKILL.md @@ -0,0 +1,188 @@ +--- +name: threedots +description: "Three Dots Labs Go style/pattern guide. Audits Go code against CQRS/DDD/Clean Architecture patterns or scaffolds new code. /threedots audit [path] to audit, /threedots scaffold to generate." +user-invocable: true +argument-hint: " [type] [name]" +--- + +# Three Dots Labs Go Architecture Auditor + +You are a Go architecture auditor specializing in Three Dots Labs CQRS/DDD/Clean Architecture patterns. You enforce the conventions from the `wild-workouts-go-ddd-example` reference implementation and the four canonical blog articles: DDD Lite in Go, Introducing Clean Architecture, Basic CQRS in Go, and Repository Pattern in Go. + +## Setup — Load All Rules + +Before performing ANY operation, read ALL reference files to have the complete rule set in context: + +1. Read `~/.claude/skills/threedots/references/rules-architecture.md` +2. Read `~/.claude/skills/threedots/references/rules-domain.md` +3. Read `~/.claude/skills/threedots/references/rules-cqrs.md` +4. Read `~/.claude/skills/threedots/references/rules-repository.md` +5. Read `~/.claude/skills/threedots/references/rules-errors.md` +6. Read `~/.claude/skills/threedots/references/rules-ports.md` +7. Read `~/.claude/skills/threedots/references/rules-naming.md` +8. Read `~/.claude/skills/threedots/references/rules-codestyle.md` + +Read all 8 files in parallel before proceeding. + +## Argument Parsing + +Parse the user's arguments: + +- **No arguments** or **`audit`**: Run audit on current working directory +- **`audit `**: Run audit on the specified path +- **`scaffold service `**: Generate full service skeleton +- **`scaffold command `**: Generate command handler file +- **`scaffold query `**: Generate query handler file +- **`scaffold entity `**: Generate domain entity file +- **`scaffold repo `**: Generate repository interface + memory implementation + +If arguments don't match any pattern, show usage help. + +--- + +## Audit Procedure + +When running an audit: + +### Step 1 — Discover Project Structure + +1. Find `go.mod` to determine the module path +2. Glob for the standard directory layout: `domain/`, `app/`, `app/command/`, `app/query/`, `ports/`, `adapters/`, `service/` +3. Note any missing or non-standard directories + +### Step 2 — Scan by Rule Category + +For each rule category, scan the relevant files: + +| Category | Scan targets | +|----------|-------------| +| Architecture (ARCH-01..03) | Directory structure, all `.go` file imports | +| Domain (DOM-01..09) | All files in `domain/` | +| CQRS (CQRS-01..10) | Files in `app/command/`, `app/query/`, `app/app.go` | +| Repository (REPO-01..07) | Files in `domain/` (interfaces) and `adapters/` (implementations) | +| Errors (ERR-01..05) | All files in `domain/`, error-related files | +| Ports (PORT-01..05) | Files in `ports/` | +| Naming (NAME-*) | All `.go` files — function names, type names | +| Code Style (STYLE-01..08) | All `.go` files, `_test.go` files | + +### Step 3 — Report Violations + +For each violation found, report in this format: + +``` +VIOLATION [RULE-ID] (SEVERITY): file:line — description + → Suggested fix: ... +``` + +Severity levels: +- **CRITICAL**: Breaks core architecture rules (wrong dependency direction, exported domain fields, CRUD naming) +- **WARNING**: Deviates from best practices (missing decorators, no IsZero, missing factory) +- **INFO**: Minor style issues (import ordering, receiver naming) + +### Step 4 — Summary + +At the end, output: + +``` +═══ Audit Summary ═══ +CRITICAL: N violations +WARNING: N violations +INFO: N violations + +Conformance: X/35 rules passing + +Top priorities: +1. [RULE-ID]: brief description of most impactful fix +2. [RULE-ID]: ... +3. [RULE-ID]: ... +``` + +--- + +## Scaffold Procedure + +When generating code: + +### Step 1 — Gather Context + +1. Read `go.mod` to get the module path (`{{module}}`) +2. Detect existing directory structure +3. Determine proper package paths + +### Step 2 — Read Template + +Read the appropriate template from `~/.claude/skills/threedots/templates/`: + +| Type | Template file | +|------|--------------| +| `service` | `templates/service.md` | +| `command` | `templates/command.md` | +| `query` | `templates/query.md` | +| `entity` | `templates/entity.md` | +| `repo` | `templates/repo.md` | + +### Step 3 — Substitute and Create + +Replace placeholders: +- `{{Name}}` → PascalCase name (e.g., `ScheduleTraining`) +- `{{name}}` → camelCase name (e.g., `scheduleTraining`) +- `{{name_snake}}` → snake_case name (e.g., `schedule_training`) +- `{{module}}` → Go module path from go.mod +- `{{entity}}` → Domain entity name when applicable +- `{{Entity}}` → PascalCase entity name + +Create the files using the Write tool. After creation, list what was created and any manual steps needed (e.g., updating `app.go`). + +--- + +## Quick Rule Reference + +| ID | Rule | Severity | +|----|------|----------| +| ARCH-01 | Standard directory layout: domain/, app/{command,query}, ports/, adapters/, service/ | CRITICAL | +| ARCH-02 | Dependency direction: domain ← app ← ports/adapters; domain imports NOTHING from app/ports/adapters | CRITICAL | +| ARCH-03 | Composition root in service/ wires all dependencies | WARNING | +| DOM-01 | All entity fields private (unexported) | CRITICAL | +| DOM-02 | Factory constructors: New{Type}(...) (*Type, error) | WARNING | +| DOM-03 | MustNew{Type} panics on error, for tests/init | INFO | +| DOM-04 | UnmarshalFromDatabase for DB reconstruction, bypasses validation | WARNING | +| DOM-05 | Value objects as structs with private field, not raw strings/ints | CRITICAL | +| DOM-06 | IsZero() method on value objects and factories | WARNING | +| DOM-07 | Behavior methods use domain language, not CRUD | CRITICAL | +| DOM-08 | String constructors: New{Type}FromString validates input | WARNING | +| DOM-09 | Factory struct with config for complex entity creation | INFO | +| CQRS-01 | Commands: imperative verb+noun struct, no return value | CRITICAL | +| CQRS-02 | Queries: noun-phrase struct, returns typed result | CRITICAL | +| CQRS-03 | Exported handler type alias: type XHandler decorator.CommandHandler[X] | WARNING | +| CQRS-04 | Unexported handler struct: type xHandler struct{} | WARNING | +| CQRS-05 | Constructor wraps with ApplyCommandDecorators/ApplyQueryDecorators | WARNING | +| CQRS-06 | Constructor nil-checks all deps with panic | WARNING | +| CQRS-07 | Application struct with Commands + Queries sub-structs | CRITICAL | +| CQRS-08 | Read model interface for queries, separate from write repository | WARNING | +| CQRS-09 | Commands modify state only, queries read only | CRITICAL | +| CQRS-10 | No business logic in handler — delegate to domain methods | WARNING | +| REPO-01 | Repository interface defined in domain package | CRITICAL | +| REPO-02 | Update uses callback pattern: UpdateX(ctx, id, func(x) (x, error)) | WARNING | +| REPO-03 | Separate DB model structs from domain entities | WARNING | +| REPO-04 | Adapter constructor: New{Tech}{Type}Repository | INFO | +| REPO-05 | Technology suffix naming for adapters | INFO | +| REPO-06 | Shared test suite runs against all implementations | WARNING | +| REPO-07 | UnmarshalFromDatabase used in adapter to reconstruct domain objects | WARNING | +| ERR-01 | Sentinel error variables: var Err{Name} = errors.New(...) | WARNING | +| ERR-02 | Typed error structs with context fields for complex errors | WARNING | +| ERR-03 | SlugError for application-layer errors with machine-readable slugs | WARNING | +| ERR-04 | Error wrapping with context: errors.Wrap(err, "...") | INFO | +| ERR-05 | No bare fmt.Errorf in domain package | CRITICAL | +| PORT-01 | HTTP/gRPC handler struct holds app.Application | WARNING | +| PORT-02 | Error mapping via httperr.RespondWithSlugError or status.Error | WARNING | +| PORT-03 | Auth extracted from context, not parsed in handler | WARNING | +| PORT-04 | No business logic in port handlers — only marshal/unmarshal + delegate | CRITICAL | +| PORT-05 | Response model mapping functions separate from handlers | INFO | +| STYLE-01 | Import groups: stdlib, blank line, external packages | INFO | +| STYLE-02 | Pointer receivers for mutation, value for reads | INFO | +| STYLE-03 | t.Parallel() as first line in every test | WARNING | +| STYLE-04 | require for fatal setup, assert for test assertions | INFO | +| STYLE-05 | Loop variable capture before goroutines/subtests | WARNING | +| STYLE-06 | Table-driven tests with named cases | INFO | +| STYLE-07 | Interfaces defined where consumed, not where implemented | WARNING | +| STYLE-08 | context.Context as first parameter for I/O methods | WARNING | diff --git a/references/rules-architecture.md b/references/rules-architecture.md new file mode 100644 index 0000000..c93602d --- /dev/null +++ b/references/rules-architecture.md @@ -0,0 +1,110 @@ +# Architecture Rules (ARCH-01..03) + +## ARCH-01: Standard Directory Layout (CRITICAL) + +Every service MUST follow this directory structure: + +``` +/ +├── domain// # Pure business logic, entities, value objects, repository interfaces +├── app/ # Application struct (app.go) with Commands + Queries +│ ├── command/ # Write use cases (command handlers) +│ └── query/ # Read use cases (query handlers + read model interfaces) +├── ports/ # Inbound adapters: HTTP handlers, gRPC servers, CLI +├── adapters/ # Outbound adapters: repository implementations, external clients +└── service/ # Composition root: wires all dependencies together +``` + +**Check procedure:** +1. Glob for these directories relative to the service root +2. Flag any missing standard directories +3. Flag any non-standard directories at the same level (e.g., `controllers/`, `models/`, `handlers/`) +4. Multiple aggregates can exist under `domain/` as sub-packages (e.g., `domain/hour/`, `domain/training/`) + +**Reference (wild-workouts):** +``` +internal/trainer/ +├── domain/hour/ +├── app/ +│ ├── command/ +│ └── query/ +├── ports/ +├── adapters/ +└── service/ +``` + +--- + +## ARCH-02: Dependency Direction (CRITICAL) + +Dependencies MUST flow inward only: `ports/adapters → app → domain` + +The domain layer MUST NOT import from: +- `app/`, `app/command/`, `app/query/` +- `ports/` +- `adapters/` +- Any external infrastructure package (database drivers, HTTP frameworks, etc.) + +The app layer MUST NOT import from: +- `ports/` +- `adapters/` + +**Check procedure:** +1. For every `.go` file in `domain/`, scan import statements +2. Flag any import that references `app/`, `ports/`, `adapters/`, or the service's own non-domain packages +3. For every `.go` file in `app/`, scan imports for `ports/` or `adapters/` +4. Domain MAY import standard library and pure utility packages + +**Allowed domain imports:** +- Standard library (`context`, `time`, `errors`, `fmt`, `strings`, etc.) +- Pure value libraries (e.g., `github.com/google/uuid`) +- NOT: database drivers, HTTP routers, gRPC, logging libraries + +--- + +## ARCH-03: Composition Root in service/ (WARNING) + +All dependency wiring MUST happen in `service/application.go` (or equivalent in `service/`). + +This file: +- Creates infrastructure clients (database connections, external service clients) +- Instantiates adapters (repositories, gRPC clients) +- Instantiates command/query handlers with their dependencies +- Returns a fully-wired `app.Application` struct +- Is the ONLY place that knows about concrete adapter types + +**Check procedure:** +1. Look for `service/` directory and `application.go` or similar +2. Verify it returns `app.Application` +3. Check that adapter constructors are NOT called outside `service/` + +**Reference:** +```go +// service/application.go +func NewApplication(ctx context.Context) app.Application { + // Create infrastructure + firestoreClient, err := firestore.NewClient(ctx, os.Getenv("GCP_PROJECT")) + // ... + + // Create domain factories + hourFactory, err := hour.NewFactory(hour.FactoryConfig{...}) + + // Create adapters + hourRepository := adapters.NewFirestoreHourRepository(firestoreClient, hourFactory) + + // Wire application + logger := logrus.NewEntry(logrus.StandardLogger()) + metricsClient := metrics.NoOp{} + + return app.Application{ + Commands: app.Commands{ + CancelTraining: command.NewCancelTrainingHandler(hourRepository, logger, metricsClient), + ScheduleTraining: command.NewScheduleTrainingHandler(hourRepository, logger, metricsClient), + }, + Queries: app.Queries{ + HourAvailability: query.NewHourAvailabilityHandler(hourRepository, logger, metricsClient), + TrainerAvailableHours: query.NewAvailableHoursHandler(datesRepository, logger, metricsClient), + }, + } +} +``` diff --git a/references/rules-codestyle.md b/references/rules-codestyle.md new file mode 100644 index 0000000..34532d0 --- /dev/null +++ b/references/rules-codestyle.md @@ -0,0 +1,220 @@ +# Code Style Rules (STYLE-01..08) + +## STYLE-01: Import Grouping (INFO) + +Imports MUST be organized in groups separated by blank lines: +1. Standard library +2. External packages (third-party + internal modules) + +```go +import ( + "context" + "fmt" + "time" + + "github.com/sirupsen/logrus" + "github.com/example/myproject/internal/trainer/domain/hour" +) +``` + +**Wrong:** +```go +import ( + "context" + "github.com/sirupsen/logrus" // VIOLATION: mixed with stdlib + "fmt" + "time" +) +``` + +--- + +## STYLE-02: Receiver Conventions (INFO) + +- **Pointer receivers** (`*Type`) for methods that mutate state +- **Value receivers** (`Type`) for methods that only read state + +```go +// Mutates — pointer receiver +func (h *Hour) ScheduleTraining() error { + h.availability = TrainingScheduled + return nil +} + +// Read-only — value receiver +func (h Hour) IsAvailable() bool { + return h.availability == Available +} + +func (a Availability) IsZero() bool { + return a == Availability{} +} +``` + +Receiver names should be short (1-2 chars), typically the first letter of the type. + +--- + +## STYLE-03: t.Parallel() in Tests (WARNING) + +Every test function and subtest SHOULD call `t.Parallel()` as its first statement. + +```go +func TestScheduleTraining(t *testing.T) { + t.Parallel() + // ... test code +} + +func TestRepository(t *testing.T) { + t.Parallel() + for i := range testCases { + tc := testCases[i] + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + // ... test code + }) + } +} +``` + +--- + +## STYLE-04: require vs assert (INFO) + +Use the testify library with: +- **`require`** for setup/preconditions that must succeed (fatal on failure) +- **`assert`** for actual test assertions (non-fatal, continues test) + +```go +func TestSomething(t *testing.T) { + // Setup — use require (fatal if fails) + hour, err := hour.NewAvailableHour(testTime) + require.NoError(t, err) + + // Act + err = hour.ScheduleTraining() + + // Assert — use assert (non-fatal) + assert.NoError(t, err) + assert.Equal(t, hour.TrainingScheduled, hour.Availability()) +} +``` + +--- + +## STYLE-05: Loop Variable Capture (WARNING) + +When using loop variables in goroutines or subtests, ALWAYS capture them first. + +```go +for i := range repositories { + r := repositories[i] // capture before subtest + t.Run(r.Name, func(t *testing.T) { + t.Parallel() + testUpdateHour(t, r.Repository) + }) +} +``` + +**Note:** Go 1.22+ fixes loop variable capture for `range` loops, but the explicit capture pattern is still preferred for clarity and backward compatibility. + +--- + +## STYLE-06: Table-Driven Tests (INFO) + +Tests with multiple cases SHOULD use table-driven pattern with named test cases. + +```go +func TestValidateTime(t *testing.T) { + t.Parallel() + + testCases := []struct { + Name string + Hour time.Time + ExpectedErr error + }{ + { + Name: "valid_hour", + Hour: time.Now().Truncate(time.Hour).Add(24 * time.Hour), + ExpectedErr: nil, + }, + { + Name: "past_hour", + Hour: time.Now().Add(-time.Hour), + ExpectedErr: ErrPastHour, + }, + { + Name: "not_full_hour", + Hour: time.Now().Add(30 * time.Minute), + ExpectedErr: ErrNotFullHour, + }, + } + + for i := range testCases { + tc := testCases[i] + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + err := validateTime(tc.Hour) + assert.ErrorIs(t, err, tc.ExpectedErr) + }) + } +} +``` + +--- + +## STYLE-07: Interfaces Where Consumed (WARNING) + +Interfaces MUST be defined in the package that **uses** them, not the package that implements them. This follows Go's implicit interface philosophy. + +**Correct:** +```go +// domain/hour/repository.go — consumer defines what it needs +package hour + +type Repository interface { + GetHour(ctx context.Context, hourTime time.Time) (*Hour, error) + UpdateHour(ctx context.Context, hourTime time.Time, + updateFn func(h *Hour) (*Hour, error)) error +} + +// adapters/ — implicitly implements it +package adapters + +type FirestoreHourRepository struct { ... } +func (r *FirestoreHourRepository) GetHour(...) (*hour.Hour, error) { ... } +func (r *FirestoreHourRepository) UpdateHour(...) error { ... } +``` + +**Wrong:** +```go +// adapters/interfaces.go ← VIOLATION +package adapters + +type HourRepository interface { ... } // interface where implemented, not consumed +``` + +--- + +## STYLE-08: Context as First Parameter (WARNING) + +All methods that perform I/O (database, HTTP, gRPC, file) MUST accept `context.Context` as their first parameter. + +```go +// Repository methods +GetHour(ctx context.Context, hourTime time.Time) (*Hour, error) +UpdateHour(ctx context.Context, hourTime time.Time, updateFn func(h *Hour) (*Hour, error)) error + +// Handler methods +Handle(ctx context.Context, cmd CancelTraining) error +Handle(ctx context.Context, q AvailableHours) ([]Date, error) + +// Adapter methods +func (r *FirestoreHourRepository) GetHour(ctx context.Context, hourTime time.Time) (*hour.Hour, error) +``` + +**Wrong:** +```go +func (r *Repo) GetHour(hourTime time.Time) (*Hour, error) // VIOLATION: no context +func (r *Repo) GetHour(hourTime time.Time, ctx context.Context) (*Hour, error) // VIOLATION: ctx not first +``` diff --git a/references/rules-cqrs.md b/references/rules-cqrs.md new file mode 100644 index 0000000..084c1fe --- /dev/null +++ b/references/rules-cqrs.md @@ -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 +} +``` diff --git a/references/rules-domain.md b/references/rules-domain.md new file mode 100644 index 0000000..c5a5227 --- /dev/null +++ b/references/rules-domain.md @@ -0,0 +1,265 @@ +# Domain Rules (DOM-01..09) + +## DOM-01: Private Entity Fields (CRITICAL) + +ALL entity struct fields MUST be unexported (lowercase). Entities are "types with behavior," not data bags. + +**Check:** Scan all structs in `domain/` for exported fields. Any uppercase field name is a violation. + +**Correct:** +```go +type Hour struct { + hour time.Time + availability Availability +} +``` + +**Wrong:** +```go +type Hour struct { + Hour time.Time // VIOLATION: exported field + Availability Availability // VIOLATION: exported field +} +``` + +**Exception:** DB model structs in `adapters/` MAY have exported fields for serialization tags. + +--- + +## DOM-02: Factory Constructors (WARNING) + +Entities MUST be created through factory constructors, never by direct struct literal. + +Pattern: `func New{Type}(args...) (*Type, error)` + +The constructor: +- Validates all invariants +- Returns an error if validation fails +- Returns a pointer to the new entity + +**Reference:** +```go +func NewAvailableHour(hour time.Time) (*Hour, error) { + if err := validateTime(hour); err != nil { + return nil, err + } + return &Hour{hour: hour, availability: Available}, nil +} + +func NewTraining(uuid, userUUID, userName string, trainingTime time.Time) (*Training, error) { + if uuid == "" { + return nil, errors.New("empty training uuid") + } + if userUUID == "" { + return nil, errors.New("empty training user uuid") + } + // ... validate all fields + return &Training{uuid: uuid, userUUID: userUUID, userName: userName, time: trainingTime}, nil +} +``` + +--- + +## DOM-03: MustNew Panic Constructors (INFO) + +For use in tests and initialization code, provide `MustNew{Type}` that panics on error. + +```go +func MustNewFactory(fc FactoryConfig) Factory { + f, err := NewFactory(fc) + if err != nil { + panic(err) + } + return f +} +``` + +--- + +## DOM-04: UnmarshalFromDatabase (WARNING) + +Entities MUST provide an `Unmarshal{Type}FromDatabase` function for reconstruction from persistence. This function: +- Bypasses normal validation (data was already valid when stored) +- Accepts all fields needed to reconstruct full state +- Is used ONLY by repository adapters + +**Reference:** +```go +func UnmarshalHourFromDatabase(hour time.Time, availability Availability) *Hour { + return &Hour{hour: hour, availability: availability} +} + +func UnmarshalTrainingFromDatabase( + uuid, userUUID, userName string, + trainingTime time.Time, + notes string, + canceled bool, + proposedNewTime time.Time, + moveProposedBy UserType, +) (*Training, error) { + return &Training{ + uuid: uuid, userUUID: userUUID, userName: userName, + time: trainingTime, notes: notes, canceled: canceled, + proposedNewTime: proposedNewTime, moveProposedBy: moveProposedBy, + }, nil +} +``` + +--- + +## DOM-05: Value Objects as Structs (CRITICAL) + +Value objects MUST be structs wrapping a private field, NOT raw strings, ints, or type aliases. + +This ensures they cannot be constructed with arbitrary values — only through validated constructors or predefined constants. + +**Correct:** +```go +type Availability struct { + a string // private — cannot be set directly +} + +var ( + Available = Availability{"available"} + NotAvailable = Availability{"not_available"} + TrainingScheduled = Availability{"training_scheduled"} +) + +type UserType struct { + s string +} + +var ( + Trainer = UserType{"trainer"} + Attendee = UserType{"attendee"} +) +``` + +**Wrong:** +```go +type Availability string // VIOLATION: can be set to any string + +const ( + Available Availability = "available" + NotAvailable Availability = "not_available" +) +``` + +--- + +## DOM-06: IsZero Method (WARNING) + +Value objects and factory structs SHOULD implement `IsZero() bool` to check for zero-value state. + +```go +func (a Availability) IsZero() bool { + return a == Availability{} +} + +func (f Factory) IsZero() bool { + return f == Factory{} +} +``` + +--- + +## DOM-07: Behavior Methods Use Domain Language (CRITICAL) + +Entity methods MUST use domain-specific language, NOT generic CRUD terms. + +| Forbidden | Use Instead | +|-----------|------------| +| `SetStatus`, `Update` | `ScheduleTraining`, `CancelTraining`, `MakeAvailable` | +| `Create` | `Schedule`, `Register`, `Place`, `Submit` | +| `Delete` | `Cancel`, `Archive`, `Revoke` | +| `Get` | Use query noun phrases | + +**Reference:** +```go +func (h *Hour) ScheduleTraining() error { + if !h.IsAvailable() { + return ErrHourNotAvailable + } + h.availability = TrainingScheduled + return nil +} + +func (h *Hour) CancelTraining() error { ... } +func (h *Hour) MakeAvailable() error { ... } +func (h *Hour) MakeNotAvailable() error { ... } + +func (t *Training) ProposeReschedule(newTime time.Time, proposedBy UserType) error { ... } +func (t *Training) ApproveReschedule(approvedBy UserType) error { ... } +func (t *Training) RejectReschedule() error { ... } +``` + +--- + +## DOM-08: String Constructors Validate Input (WARNING) + +When a value object can be constructed from a string, use `New{Type}FromString` with validation. + +```go +func NewAvailabilityFromString(availabilityStr string) (Availability, error) { + switch availabilityStr { + case "available": + return Available, nil + case "not_available": + return NotAvailable, nil + case "training_scheduled": + return TrainingScheduled, nil + default: + return Availability{}, fmt.Errorf("unknown availability: %s", availabilityStr) + } +} +``` + +--- + +## DOM-09: Factory Struct for Complex Creation (INFO) + +When entity creation requires configuration or external dependencies, use a Factory struct pattern. + +```go +type FactoryConfig struct { + MaxWeeksInTheFutureToSet int + MinUtcHour int + MaxUtcHour int +} + +func (c FactoryConfig) Validate() error { + var errs []error + if c.MaxWeeksInTheFutureToSet <= 0 { + errs = append(errs, errors.New("MaxWeeksInTheFutureToSet must be > 0")) + } + // ... more validations + return multierr.Combine(errs...) +} + +type Factory struct { + fc FactoryConfig +} + +func NewFactory(fc FactoryConfig) (Factory, error) { + if err := fc.Validate(); err != nil { + return Factory{}, err + } + return Factory{fc: fc}, nil +} + +func MustNewFactory(fc FactoryConfig) Factory { + f, err := NewFactory(fc) + if err != nil { + panic(err) + } + return f +} + +func (f Factory) IsZero() bool { + return f == Factory{} +} + +func (f Factory) NewAvailableHour(hour time.Time) (*Hour, error) { + // uses f.fc for validation bounds +} +``` diff --git a/references/rules-errors.md b/references/rules-errors.md new file mode 100644 index 0000000..9c18cc4 --- /dev/null +++ b/references/rules-errors.md @@ -0,0 +1,150 @@ +# Error Rules (ERR-01..05) + +## Three-Tier Error Architecture + +The error system has three tiers: + +1. **Domain errors** — sentinel variables and typed structs in `domain/` +2. **Application errors** — `SlugError` with machine-readable slugs in `app/` +3. **Port errors** — protocol-specific error mapping in `ports/` + +--- + +## ERR-01: Sentinel Error Variables (WARNING) + +Simple domain errors without context SHOULD use sentinel `var` declarations. + +```go +// domain/hour/errors.go +var ( + ErrNotFullHour = errors.New("hour should be a full hour") + ErrPastHour = errors.New("cannot create hour in the past") + ErrTrainingScheduled = errors.New("unable to modify hour, because scheduled training") + ErrHourNotAvailable = errors.New("hour is not available") + ErrNoTrainingScheduled = errors.New("no training scheduled") +) +``` + +**Naming:** `Err{DescriptiveName}` — always starts with `Err`. + +**Usage in domain methods:** +```go +func (h *Hour) ScheduleTraining() error { + if !h.IsAvailable() { + return ErrHourNotAvailable + } + h.availability = TrainingScheduled + return nil +} +``` + +--- + +## ERR-02: Typed Error Structs (WARNING) + +Errors that carry context (values for logging/display) SHOULD be typed structs implementing the `error` interface. + +```go +type TooDistantDateError struct { + MaxWeeksInTheFutureToSet int + ProvidedDate time.Time +} + +func (e TooDistantDateError) Error() string { + return fmt.Sprintf( + "schedule can be only set for next %d weeks, provided date: %s", + e.MaxWeeksInTheFutureToSet, e.ProvidedDate, + ) +} + +type TooEarlyHourError struct { + MinUtcHour int + ProvidedTime time.Time +} + +type ForbiddenToSeeTrainingError struct { + RequestingUserUUID string + TrainingOwnerUUID string +} + +type NotFoundError struct { + TrainingUUID string +} +``` + +**Naming:** `{Condition}Error` — describes the error condition. + +--- + +## ERR-03: SlugError for Application Layer (WARNING) + +Application-layer errors (command/query handlers) SHOULD use `SlugError` from the common errors package. SlugErrors carry: +- Human-readable error message +- Machine-readable slug (used by API clients) +- Error type (authorization, incorrect-input, unknown) + +```go +// common/errors/errors.go +type ErrorType struct { + t string +} + +var ( + ErrorTypeUnknown = ErrorType{"unknown"} + ErrorTypeAuthorization = ErrorType{"authorization"} + ErrorTypeIncorrectInput = ErrorType{"incorrect-input"} +) + +type SlugError struct { + error string + slug string + errorType ErrorType +} + +func NewSlugError(error string, slug string) SlugError +func NewAuthorizationError(error string, slug string) SlugError +func NewIncorrectInputError(error string, slug string) SlugError +``` + +**Usage in handlers:** +```go +func (h cancelTrainingHandler) Handle(ctx context.Context, cmd CancelTraining) error { + if err := h.hourRepo.UpdateHour(ctx, cmd.Hour, func(h *hour.Hour) (*hour.Hour, error) { + if err := h.CancelTraining(); err != nil { + return nil, err + } + return h, nil + }); err != nil { + return errors.NewSlugError(err.Error(), "unable-to-update-availability") + } + return nil +} +``` + +--- + +## ERR-04: Error Wrapping with Context (INFO) + +When re-raising errors, wrap them with context using `fmt.Errorf("context: %w", err)` or a wrapping library. + +```go +// In adapters +if err := doc.DataTo(&model); err != nil { + return nil, fmt.Errorf("unmarshaling hour from firestore: %w", err) +} +``` + +--- + +## ERR-05: No Bare fmt.Errorf in Domain (CRITICAL) + +The domain package MUST NOT use `fmt.Errorf` for error creation. Domain errors must be either: +- Sentinel variables (`var ErrX = errors.New(...)`) +- Typed error structs +- Standard `errors.New(...)` for simple cases + +**Check:** Grep `domain/` for `fmt.Errorf`. Any match in non-test files is a violation. + +**Rationale:** `fmt.Errorf` creates untyped errors that cannot be checked with `errors.Is` or `errors.As`. Domain errors should be programmatically handleable. + +**Exception:** `fmt.Errorf` with `%w` for wrapping IS acceptable in domain validation helpers that combine multiple checks, but prefer typed errors or sentinel variables. diff --git a/references/rules-naming.md b/references/rules-naming.md new file mode 100644 index 0000000..ef572ca --- /dev/null +++ b/references/rules-naming.md @@ -0,0 +1,52 @@ +# Naming Rules + +## Strict Naming Convention Table + +| Pattern | Convention | Example | +|---------|-----------|---------| +| Entity constructor | `New{Type}(args...) (*Type, error)` | `NewTraining(...)`, `NewAvailableHour(...)` | +| Panic constructor | `MustNew{Type}(args...) Type` | `MustNewFactory(...)`, `MustNewUser(...)` | +| DB reconstruction | `Unmarshal{Type}FromDatabase(...)` | `UnmarshalHourFromDatabase(...)` | +| Value from string | `New{Type}FromString(s string) (Type, error)` | `NewAvailabilityFromString(...)` | +| Command struct | Imperative verb + noun (PascalCase) | `ScheduleTraining`, `CancelTraining`, `MakeHoursAvailable` | +| Query struct | Noun phrase (PascalCase) | `AvailableHours`, `HourAvailability`, `AllTrainings` | +| Handler type (exported) | `{ActionName}Handler` | `ScheduleTrainingHandler`, `CancelTrainingHandler` | +| Handler struct (unexported) | `{actionName}Handler` | `scheduleTrainingHandler`, `cancelTrainingHandler` | +| Handler constructor | `New{ActionName}Handler(...)` | `NewScheduleTrainingHandler(...)` | +| Adapter type | Technology suffix | `FirestoreHourRepository`, `MySQLHourRepository`, `MemoryHourRepository` | +| Adapter constructor | `New{Tech}{Entity}Repository(...)` | `NewFirestoreHourRepository(...)` | +| DB model (SQL) | Tech prefix, unexported | `mysqlHour`, `postgresTraining` | +| DB model (NoSQL) | `{Entity}Model` (exported for tags) | `TrainingModel`, `DateModel` | +| Sentinel errors | `Err{Name}` | `ErrNotFullHour`, `ErrHourNotAvailable` | +| Typed errors | `{Condition}Error` | `TooDistantDateError`, `NotFoundError` | +| Zero check | `IsZero() bool` | `Availability.IsZero()`, `Factory.IsZero()` | +| Application struct | `Application` in `app/` package | `app.Application` | +| App sub-structs | `Commands`, `Queries` | `app.Commands`, `app.Queries` | +| Composition root | `NewApplication(...)` in `service/` | `service.NewApplication(ctx)` | +| gRPC client adapter | `{Service}Grpc` | `TrainerGrpc`, `UsersGrpc` | +| Read model interface | `{Query}ReadModel` | `AvailableHoursReadModel` | + +## CRUD-to-Domain-Language Mapping + +CRUD terms are **forbidden** in domain code, commands, queries, and API endpoints. Use domain-specific language instead. + +| CRUD Term | Replacement Options | Example | +|-----------|-------------------|---------| +| Create | Schedule, Register, Place, Submit, Open, Enroll | `ScheduleTraining`, not `CreateTraining` | +| Read | *(use noun phrase queries)* | `AvailableHours`, not `GetHours` | +| Update | Approve, Reject, Reschedule, Move, Modify, Assign | `ApproveReschedule`, not `UpdateTraining` | +| Delete | Cancel, Archive, Revoke, Close, Withdraw | `CancelTraining`, not `DeleteTraining` | +| Get | *(avoid as prefix)* | `HourAvailability`, not `GetHourAvailability` | +| Set | *(use specific verb)* | `MakeAvailable`, not `SetAvailability` | +| List | *(use noun phrase)* | `AllTrainings`, not `ListTrainings` | +| Fetch | *(avoid entirely)* | Use noun phrase queries | + +## Check Procedure + +1. Scan all type declarations and function names +2. Flag any use of Create/Read/Update/Delete/Get/Set/List/Fetch in: + - Command struct names + - Query struct names + - Domain entity method names + - Handler type names +3. Severity: CRITICAL for command/query names, WARNING for methods diff --git a/references/rules-ports.md b/references/rules-ports.md new file mode 100644 index 0000000..65fc2f6 --- /dev/null +++ b/references/rules-ports.md @@ -0,0 +1,148 @@ +# Port Rules (PORT-01..05) + +## PORT-01: Handler Struct Holds Application (WARNING) + +HTTP and gRPC handler structs MUST hold `app.Application` and delegate to it. They are thin wrappers. + +```go +// ports/http.go +type HttpServer struct { + app app.Application +} + +// ports/grpc.go +type GrpcServer struct { + app app.Application +} +``` + +--- + +## PORT-02: Error Mapping (WARNING) + +Ports MUST map application errors to protocol-specific responses. They must NOT leak internal error details. + +**HTTP — using httperr helper:** +```go +func (h HttpServer) MakeHourAvailable(w http.ResponseWriter, r *http.Request) { + err = h.app.Commands.MakeHoursAvailable.Handle(r.Context(), command.MakeHoursAvailable{...}) + if err != nil { + httperr.RespondWithSlugError(err, w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} +``` + +**The httperr mapper:** +```go +func RespondWithSlugError(err error, w http.ResponseWriter, r *http.Request) { + slugError, ok := err.(errors.SlugError) + if !ok { + InternalError("internal-server-error", err, w, r) + return + } + switch slugError.ErrorType() { + case errors.ErrorTypeAuthorization: + Unauthorised(slugError.Slug(), slugError, w, r) // 401 + case errors.ErrorTypeIncorrectInput: + BadRequest(slugError.Slug(), slugError, w, r) // 400 + default: + InternalError(slugError.Slug(), slugError, w, r) // 500 + } +} +``` + +**gRPC — using status codes:** +```go +func (g GrpcServer) ScheduleTraining(ctx context.Context, req *trainer.UpdateHourRequest) (*empty.Empty, error) { + if err := g.app.Commands.ScheduleTraining.Handle(ctx, command.ScheduleTraining{...}); err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &empty.Empty{}, nil +} +``` + +--- + +## PORT-03: Auth Extracted from Context (WARNING) + +Authentication/authorization data MUST be extracted from the request context using a shared auth package, NOT parsed directly in the handler. + +**Correct:** +```go +func (h HttpServer) MakeHourAvailable(w http.ResponseWriter, r *http.Request) { + user, err := auth.UserFromCtx(r.Context()) + if err != nil { + httperr.RespondWithSlugError(err, w, r) + return + } + if user.Role != "trainer" { + httperr.Unauthorised("invalid-role", nil, w, r) + return + } + // ... delegate to app +} +``` + +**Wrong:** +```go +func (h HttpServer) MakeHourAvailable(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") // VIOLATION: parsing auth in handler + claims, err := jwt.Parse(token, keyFunc) // VIOLATION: JWT logic in port + // ... +} +``` + +--- + +## PORT-04: No Business Logic in Ports (CRITICAL) + +Port handlers MUST only: +1. Parse/decode the request +2. Extract auth from context +3. Construct command/query struct +4. Call `app.Commands.X.Handle()` or `app.Queries.X.Handle()` +5. Map the result/error to a response + +They MUST NOT contain: +- Domain validation logic +- Business rule checks +- Direct database calls +- State manipulation + +**Check:** Port files should only import `app/`, `app/command/`, `app/query/`, and infrastructure packages (HTTP, gRPC, auth). They should NOT import `domain/` directly (except for response mapping types). + +--- + +## PORT-05: Response Model Mapping (INFO) + +Response transformation SHOULD be in separate mapping functions, not inline in handlers. + +```go +// Mapping function +func dateModelsToResponse(models []query.Date) []Date { + var dates []Date + for _, m := range models { + dates = append(dates, Date{ + Date: m.Date, + Hours: hourModelsToResponse(m.Hours), + }) + } + return dates +} + +// Handler uses it cleanly +func (h HttpServer) GetTrainerAvailableHours(w http.ResponseWriter, r *http.Request, params GetTrainerAvailableHoursParams) { + dateModels, err := h.app.Queries.TrainerAvailableHours.Handle(r.Context(), query.AvailableHours{ + From: params.DateFrom, + To: params.DateTo, + }) + if err != nil { + httperr.RespondWithSlugError(err, w, r) + return + } + dates := dateModelsToResponse(dateModels) + render.Respond(w, r, dates) +} +``` diff --git a/references/rules-repository.md b/references/rules-repository.md new file mode 100644 index 0000000..e09599b --- /dev/null +++ b/references/rules-repository.md @@ -0,0 +1,181 @@ +# Repository Rules (REPO-01..07) + +## REPO-01: Interface Defined in Domain (CRITICAL) + +Repository interfaces MUST be defined in the domain package, next to the entity they persist. This follows the Dependency Inversion Principle — the domain defines what it needs, adapters implement it. + +**Correct:** +```go +// domain/hour/repository.go +package hour + +type Repository interface { + GetHour(ctx context.Context, hourTime time.Time) (*Hour, error) + UpdateHour(ctx context.Context, hourTime time.Time, + updateFn func(h *Hour) (*Hour, error)) error +} +``` + +**Wrong:** +```go +// adapters/repository.go ← VIOLATION: interface in adapter layer +package adapters + +type HourRepository interface { ... } +``` + +**Check:** Grep `domain/` for `type.*Repository interface`. Grep `adapters/` for the same — if found in adapters, it's a violation. + +--- + +## REPO-02: Update Callback Pattern (WARNING) + +Repository update methods SHOULD use a callback/closure pattern. The repository handles transaction lifecycle; the callback handles domain logic. + +```go +// Interface +UpdateHour(ctx context.Context, hourTime time.Time, + updateFn func(h *Hour) (*Hour, error)) error + +// Usage in handler +err := h.hourRepo.UpdateHour(ctx, cmd.Hour, func(h *hour.Hour) (*hour.Hour, error) { + if err := h.CancelTraining(); err != nil { + return nil, err + } + return h, nil +}) +``` + +Benefits: +- Transaction scope is clear +- Domain logic is isolated from persistence details +- Enables optimistic locking, retries, etc. transparently + +--- + +## REPO-03: Separate DB Model Structs (WARNING) + +Adapter implementations MUST use separate structs for database representation. Domain entities should NOT have serialization tags. + +**Correct:** +```go +// adapters/ — DB model +type mysqlHour struct { + ID int `db:"id"` + Hour time.Time `db:"hour"` + Availability string `db:"availability"` +} + +// or for Firestore (needs exported fields for tags) +type TrainingModel struct { + UUID string `firestore:"Uuid"` + UserUUID string `firestore:"UserUuid"` + Time time.Time `firestore:"Time"` +} + +// Conversion in adapter +func (r *MySQLHourRepository) toHour(m mysqlHour) (*hour.Hour, error) { + availability, err := hour.NewAvailabilityFromString(m.Availability) + if err != nil { + return nil, err + } + return hour.UnmarshalHourFromDatabase(m.Hour, availability), nil +} +``` + +**Wrong:** +```go +// domain/hour/hour.go +type Hour struct { + Hour time.Time `json:"hour" db:"hour"` // VIOLATION: DB tags on domain entity + Availability string `json:"availability"` // VIOLATION: serialization concern in domain +} +``` + +--- + +## REPO-04: Adapter Constructor Naming (INFO) + +Repository adapter constructors follow: `New{Technology}{Entity}Repository` + +```go +func NewFirestoreHourRepository(client *firestore.Client, factory hour.Factory) *FirestoreHourRepository +func NewMySQLHourRepository(db *sqlx.DB) *MySQLHourRepository +func NewMemoryHourRepository(factory hour.Factory) *MemoryHourRepository +``` + +--- + +## REPO-05: Technology Suffix Naming (INFO) + +Adapter types use technology as a suffix/prefix to distinguish implementations. + +```go +type FirestoreHourRepository struct { ... } +type MySQLHourRepository struct { ... } +type MemoryHourRepository struct { ... } + +// For external service clients +type TrainerGrpc struct { ... } +type UsersGrpc struct { ... } +``` + +--- + +## REPO-06: Shared Test Suite (WARNING) + +Repository tests SHOULD run the same test logic against ALL implementations (memory, MySQL, Firestore, etc.). This ensures behavioral consistency. + +**Pattern:** +```go +func createRepositories(t *testing.T) []Repository { + return []Repository{ + {Name: "Firebase", Repository: newFirebaseRepository(t)}, + {Name: "MySQL", Repository: newMySQLRepository(t)}, + {Name: "memory", Repository: adapters.NewMemoryHourRepository(testFactory)}, + } +} + +func TestRepository(t *testing.T) { + repositories := createRepositories(t) + for i := range repositories { + r := repositories[i] // capture loop variable + t.Run(r.Name, func(t *testing.T) { + t.Parallel() + testUpdateHour(t, r.Repository) + testUpdateHour_parallel(t, r.Repository) + }) + } +} +``` + +**Check:** Look for test files in `adapters/` that test repository implementations. Verify they use a shared test function or table-driven approach. + +--- + +## REPO-07: UnmarshalFromDatabase Usage (WARNING) + +Adapter implementations MUST use the entity's `UnmarshalFromDatabase` function to reconstruct domain objects from persistence, not the regular constructor. + +**Correct:** +```go +func (r *FirestoreHourRepository) toHour(doc *firestore.DocumentSnapshot) (*hour.Hour, error) { + var m HourModel + if err := doc.DataTo(&m); err != nil { + return nil, err + } + availability, err := hour.NewAvailabilityFromString(m.Availability) + if err != nil { + return nil, err + } + return hour.UnmarshalHourFromDatabase(m.Hour, availability), nil +} +``` + +**Wrong:** +```go +func (r *FirestoreHourRepository) toHour(doc *firestore.DocumentSnapshot) (*hour.Hour, error) { + // VIOLATION: using business constructor for DB reconstruction + return hour.NewAvailableHour(m.Hour) // This re-validates and may reject valid stored data +} +``` diff --git a/templates/command.md b/templates/command.md new file mode 100644 index 0000000..fd674db --- /dev/null +++ b/templates/command.md @@ -0,0 +1,115 @@ +# Command Handler Scaffold Template + +Generate a single command handler file following the 4-component pattern. + +## Placeholders + +- `{{Name}}` — PascalCase command name (e.g., `ScheduleTraining`) +- `{{name}}` — camelCase (e.g., `scheduleTraining`) +- `{{module}}` — Go module path from go.mod +- `{{entity}}` — Domain entity name, lowercase (e.g., `hour`) +- `{{Entity}}` — Domain entity name, PascalCase (e.g., `Hour`) + +## File: `app/command/{{name_snake}}.go` + +```go +package command + +import ( + "context" + + "github.com/sirupsen/logrus" + + "{{module}}/domain/{{entity}}" + "{{module_common}}/decorator" +) + +// 1. Command struct — imperative verb + noun, plain data +type {{Name}} struct { + // TODO: Add command fields + // Example: + // UUID string + // Hour time.Time +} + +// 2. Exported handler type alias +type {{Name}}Handler decorator.CommandHandler[{{Name}}] + +// 3. Unexported concrete handler struct +type {{name}}Handler struct { + {{entity}}Repo {{entity}}.Repository +} + +// 4. Constructor with nil-checks + decorator wrapping +func New{{Name}}Handler( + {{entity}}Repo {{entity}}.Repository, + logger *logrus.Entry, + metricsClient decorator.MetricsClient, +) {{Name}}Handler { + if {{entity}}Repo == nil { + panic("nil {{entity}}Repo") + } + if logger == nil { + panic("nil logger") + } + if metricsClient == nil { + panic("nil metricsClient") + } + + return decorator.ApplyCommandDecorators[{{Name}}]( + {{name}}Handler{{"{"}}{{entity}}Repo: {{entity}}Repo}, + logger, + metricsClient, + ) +} + +// Handle — orchestrates domain logic, does NOT contain business rules +func (h {{name}}Handler) Handle(ctx context.Context, cmd {{Name}}) error { + // TODO: Implement command handling + // + // Typical patterns: + // + // Pattern A — Update via callback: + // return h.{{entity}}Repo.Update{{Entity}}(ctx, cmd.UUID, func(e *{{entity}}.{{Entity}}) (*{{entity}}.{{Entity}}, error) { + // if err := e.SomeDomainAction(); err != nil { + // return nil, err + // } + // return e, nil + // }) + // + // Pattern B — Create new entity: + // entity, err := {{entity}}.New{{Entity}}(cmd.UUID, ...) + // if err != nil { + // return err + // } + // return h.{{entity}}Repo.Save(ctx, entity) + + return nil +} +``` + +## Update `app/app.go` + +After creating the handler, add it to the `Commands` struct: + +```go +type Commands struct { + // ... existing handlers ... + {{Name}} command.{{Name}}Handler +} +``` + +## Update `service/application.go` + +Wire the handler in the composition root: + +```go +Commands: app.Commands{ + // ... existing handlers ... + {{Name}}: command.New{{Name}}Handler( + {{entity}}Repository, + logger, + metricsClient, + ), +}, +``` diff --git a/templates/entity.md b/templates/entity.md new file mode 100644 index 0000000..12e38ef --- /dev/null +++ b/templates/entity.md @@ -0,0 +1,156 @@ +# Domain Entity Scaffold Template + +Generate a domain entity with factory constructor, value objects, and errors. + +## Placeholders + +- `{{Name}}` — PascalCase entity name (e.g., `Training`, `Hour`, `Order`) +- `{{name}}` — camelCase (e.g., `training`) +- `{{name_lower}}` — all lowercase package name (e.g., `training`) +- `{{name_snake}}` — snake_case (e.g., `training`) + +## File: `domain/{{name_lower}}/{{name_snake}}.go` + +```go +package {{name_lower}} + +import ( + "errors" + "time" +) + +// {{Name}} is the aggregate root for the {{name_lower}} domain. +type {{Name}} struct { + uuid string + createdAt time.Time + // TODO: Add domain fields (all private) + // status Status // value object, not raw string +} + +// New{{Name}} creates a new {{Name}} with validated invariants. +func New{{Name}}(uuid string) (*{{Name}}, error) { + if uuid == "" { + return nil, errors.New("empty {{name_lower}} uuid") + } + + return &{{Name}}{ + uuid: uuid, + createdAt: time.Now(), + }, nil +} + +// Unmarshal{{Name}}FromDatabase reconstructs a {{Name}} from persistence. +// Bypasses validation — data was valid when stored. +func Unmarshal{{Name}}FromDatabase( + uuid string, + createdAt time.Time, + // TODO: Add all persisted fields +) *{{Name}} { + return &{{Name}}{ + uuid: uuid, + createdAt: createdAt, + } +} + +// Accessor methods — expose state without allowing mutation. + +func (t {{Name}}) UUID() string { + return t.uuid +} + +func (t {{Name}}) CreatedAt() time.Time { + return t.createdAt +} + +// TODO: Add behavior methods using domain language. +// Examples: +// +// func (t *{{Name}}) Approve() error { +// if t.status != Pending { +// return ErrNotPending +// } +// t.status = Approved +// return nil +// } +// +// func (t *{{Name}}) Cancel() error { ... } +// func (t *{{Name}}) Submit(details string) error { ... } +``` + +## File: `domain/{{name_lower}}/errors.go` + +```go +package {{name_lower}} + +import "errors" + +// Sentinel errors — simple, no context needed. +var ( + ErrNotFound = errors.New("{{name_lower}} not found") + // TODO: Add domain-specific errors + // ErrAlreadyCanceled = errors.New("{{name_lower}} already canceled") + // ErrNotPending = errors.New("{{name_lower}} is not in pending state") +) + +// Typed errors — carry context for logging/display. +// Example: +// +// type ForbiddenError struct { +// RequestingUserUUID string +// OwnerUUID string +// } +// +// func (e ForbiddenError) Error() string { +// return fmt.Sprintf("user %s cannot access {{name_lower}} owned by %s", +// e.RequestingUserUUID, e.OwnerUUID) +// } +``` + +## File: `domain/{{name_lower}}/status.go` (Optional Value Object) + +```go +package {{name_lower}} + +import "fmt" + +// Status is a value object — cannot be constructed with arbitrary values. +type Status struct { + s string +} + +var ( + Pending = Status{"pending"} + Approved = Status{"approved"} + Canceled = Status{"canceled"} +) + +func NewStatusFromString(s string) (Status, error) { + switch s { + case "pending": + return Pending, nil + case "approved": + return Approved, nil + case "canceled": + return Canceled, nil + default: + return Status{}, fmt.Errorf("unknown {{name_lower}} status: %s", s) + } +} + +func (s Status) String() string { + return s.s +} + +func (s Status) IsZero() bool { + return s == Status{} +} +``` + +## Post-Creation Checklist + +- [ ] All struct fields are private (unexported) +- [ ] Factory constructor validates all invariants +- [ ] UnmarshalFromDatabase accepts all persisted fields +- [ ] Value objects are struct wrappers, not type aliases +- [ ] Behavior methods use domain language, not CRUD +- [ ] Errors are sentinel vars or typed structs diff --git a/templates/query.md b/templates/query.md new file mode 100644 index 0000000..96b3e35 --- /dev/null +++ b/templates/query.md @@ -0,0 +1,124 @@ +# Query Handler Scaffold Template + +Generate a query handler file with a read model interface. + +## Placeholders + +- `{{Name}}` — PascalCase query name (e.g., `AvailableHours`) +- `{{name}}` — camelCase (e.g., `availableHours`) +- `{{name_snake}}` — snake_case (e.g., `available_hours`) +- `{{module}}` — Go module path from go.mod +- `{{Result}}` — Result type (e.g., `[]Date`, `*HourDetails`) + +## File: `app/query/{{name_snake}}.go` + +```go +package query + +import ( + "context" + + "github.com/sirupsen/logrus" + + "{{module_common}}/decorator" +) + +// Read model — defines what data the query needs +// Implemented by adapters (repository or dedicated read store) +type {{Name}}ReadModel interface { + {{Name}}(ctx context.Context /* TODO: add query params */) ({{Result}}, error) +} + +// 1. Query struct — noun phrase, plain data +type {{Name}} struct { + // TODO: Add query parameters + // Example: + // From time.Time + // To time.Time +} + +// Result types — optimized for reading, may differ from domain entities +// type Date struct { +// Date time.Time +// Hours []Hour +// } + +// 2. Exported handler type alias +type {{Name}}Handler decorator.QueryHandler[{{Name}}, {{Result}}] + +// 3. Unexported concrete handler struct +type {{name}}Handler struct { + readModel {{Name}}ReadModel +} + +// 4. Constructor with nil-checks + decorator wrapping +func New{{Name}}Handler( + readModel {{Name}}ReadModel, + logger *logrus.Entry, + metricsClient decorator.MetricsClient, +) {{Name}}Handler { + if readModel == nil { + panic("nil readModel") + } + if logger == nil { + panic("nil logger") + } + if metricsClient == nil { + panic("nil metricsClient") + } + + return decorator.ApplyQueryDecorators[{{Name}}, {{Result}}]( + {{name}}Handler{readModel: readModel}, + logger, + metricsClient, + ) +} + +// Handle — delegates to read model, may add input validation +func (h {{name}}Handler) Handle(ctx context.Context, q {{Name}}) ({{Result}}, error) { + // TODO: Add input validation if needed + // Example: + // if q.From.After(q.To) { + // return nil, errors.NewIncorrectInputError("date-from-after-date-to", "date from is after date to") + // } + + return h.readModel.{{Name}}(ctx /* TODO: pass query params */) +} +``` + +## Update `app/app.go` + +Add to the `Queries` struct: + +```go +type Queries struct { + // ... existing handlers ... + {{Name}} query.{{Name}}Handler +} +``` + +## Update `service/application.go` + +Wire the handler. The read model is typically implemented by the same repository adapter or a dedicated read adapter: + +```go +Queries: app.Queries{ + // ... existing handlers ... + {{Name}}: query.New{{Name}}Handler( + {{entity}}Repository, // implements {{Name}}ReadModel + logger, + metricsClient, + ), +}, +``` + +## Implement ReadModel on Adapter + +Add the read model method to your repository adapter: + +```go +// In adapters/ +func (r *Memory{{Entity}}Repository) {{Name}}(ctx context.Context /* params */) ({{Result}}, error) { + // TODO: Implement query against storage +} +``` diff --git a/templates/repo.md b/templates/repo.md new file mode 100644 index 0000000..d4e95cf --- /dev/null +++ b/templates/repo.md @@ -0,0 +1,212 @@ +# Repository Scaffold Template + +Generate a repository interface in the domain package and a memory implementation in adapters. + +## Placeholders + +- `{{Name}}` — PascalCase entity name (e.g., `Training`) +- `{{name}}` — camelCase (e.g., `training`) +- `{{name_lower}}` — all lowercase package name (e.g., `training`) +- `{{name_snake}}` — snake_case (e.g., `training`) +- `{{module}}` — Go module path from go.mod + +## File: `domain/{{name_lower}}/repository.go` + +```go +package {{name_lower}} + +import "context" + +// Repository defines persistence operations for {{Name}}. +// Defined in domain — adapters implement it implicitly. +type Repository interface { + // Get{{Name}} retrieves a {{Name}} by its UUID. + Get{{Name}}(ctx context.Context, uuid string) (*{{Name}}, error) + + // Update{{Name}} loads a {{Name}}, applies the update function within a + // transaction, and persists the result. The callback pattern ensures + // domain logic is separated from transaction management. + Update{{Name}}(ctx context.Context, uuid string, + updateFn func(t *{{Name}}) (*{{Name}}, error)) error + + // TODO: Add other methods as needed. Examples: + // Save{{Name}}(ctx context.Context, t *{{Name}}) error + // Delete{{Name}}(ctx context.Context, uuid string) error +} +``` + +## File: `adapters/memory_{{name_snake}}_repository.go` + +```go +package adapters + +import ( + "context" + "sync" + + "{{module}}/domain/{{name_lower}}" +) + +// Memory{{Name}}Repository is an in-memory implementation of {{name_lower}}.Repository. +// Useful for tests and local development. +type Memory{{Name}}Repository struct { + {{name}}s map[string]{{name_lower}}.{{Name}} + mu sync.RWMutex +} + +func NewMemory{{Name}}Repository() *Memory{{Name}}Repository { + return &Memory{{Name}}Repository{ + {{name}}s: make(map[string]{{name_lower}}.{{Name}}), + } +} + +func (r *Memory{{Name}}Repository) Get{{Name}}(ctx context.Context, uuid string) (*{{name_lower}}.{{Name}}, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + t, ok := r.{{name}}s[uuid] + if !ok { + return nil, {{name_lower}}.ErrNotFound + } + + // Return a copy to prevent mutation of stored value + return &t, nil +} + +func (r *Memory{{Name}}Repository) Update{{Name}}( + ctx context.Context, + uuid string, + updateFn func(t *{{name_lower}}.{{Name}}) (*{{name_lower}}.{{Name}}, error), +) error { + r.mu.Lock() + defer r.mu.Unlock() + + current, ok := r.{{name}}s[uuid] + if !ok { + return {{name_lower}}.ErrNotFound + } + + updated, err := updateFn(¤t) + if err != nil { + return err + } + + r.{{name}}s[uuid] = *updated + return nil +} + +// Save{{Name}} stores a new {{Name}}. Used for initial creation. +func (r *Memory{{Name}}Repository) Save{{Name}}(ctx context.Context, t *{{name_lower}}.{{Name}}) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.{{name}}s[t.UUID()] = *t + return nil +} +``` + +## File: `adapters/memory_{{name_snake}}_repository_test.go` + +```go +package adapters_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "{{module}}/adapters" + "{{module}}/domain/{{name_lower}}" +) + +func TestMemory{{Name}}Repository_Get(t *testing.T) { + t.Parallel() + + ctx := context.Background() + repo := adapters.NewMemory{{Name}}Repository() + + // Setup: create and save a {{name_lower}} + entity, err := {{name_lower}}.New{{Name}}("test-uuid") + require.NoError(t, err) + + err = repo.Save{{Name}}(ctx, entity) + require.NoError(t, err) + + // Test: retrieve it + got, err := repo.Get{{Name}}(ctx, "test-uuid") + assert.NoError(t, err) + assert.Equal(t, "test-uuid", got.UUID()) +} + +func TestMemory{{Name}}Repository_GetNotFound(t *testing.T) { + t.Parallel() + + ctx := context.Background() + repo := adapters.NewMemory{{Name}}Repository() + + _, err := repo.Get{{Name}}(ctx, "nonexistent") + assert.ErrorIs(t, err, {{name_lower}}.ErrNotFound) +} + +func TestMemory{{Name}}Repository_Update(t *testing.T) { + t.Parallel() + + ctx := context.Background() + repo := adapters.NewMemory{{Name}}Repository() + + // Setup + entity, err := {{name_lower}}.New{{Name}}("test-uuid") + require.NoError(t, err) + err = repo.Save{{Name}}(ctx, entity) + require.NoError(t, err) + + // Test: update via callback + err = repo.Update{{Name}}(ctx, "test-uuid", func(t *{{name_lower}}.{{Name}}) (*{{name_lower}}.{{Name}}, error) { + // TODO: Apply domain action + return t, nil + }) + assert.NoError(t, err) +} +``` + +## Extending to Production Adapters + +When adding a real database adapter (e.g., PostgreSQL): + +### 1. Create DB model struct + +```go +// adapters/postgres_{{name_snake}}_repository.go + +type postgres{{Name}} struct { + UUID string `db:"uuid"` + CreatedAt time.Time `db:"created_at"` + // ... map all persisted fields +} +``` + +### 2. Implement conversion methods + +```go +func (r *Postgres{{Name}}Repository) to{{Name}}(m postgres{{Name}}) *{{name_lower}}.{{Name}} { + return {{name_lower}}.Unmarshal{{Name}}FromDatabase(m.UUID, m.CreatedAt) +} +``` + +### 3. Run shared tests against all implementations + +```go +type TestRepository struct { + Name string + Repository {{name_lower}}.Repository +} + +func createRepositories(t *testing.T) []TestRepository { + return []TestRepository{ + {Name: "memory", Repository: adapters.NewMemory{{Name}}Repository()}, + {Name: "postgres", Repository: newPostgresRepository(t)}, + } +} +``` diff --git a/templates/service.md b/templates/service.md new file mode 100644 index 0000000..04d8ae1 --- /dev/null +++ b/templates/service.md @@ -0,0 +1,223 @@ +# Service Scaffold Template + +Generate a complete service skeleton with all standard directories and stub files. + +## Placeholders + +- `{{Name}}` — PascalCase service/aggregate name (e.g., `Training`) +- `{{name}}` — camelCase (e.g., `training`) +- `{{name_snake}}` — snake_case (e.g., `training`) +- `{{name_lower}}` — all lowercase (e.g., `training`) +- `{{module}}` — Go module path from go.mod + +## Files to Create + +### 1. `domain/{{name_lower}}/{{name_snake}}.go` + +```go +package {{name_lower}} + +import ( + "errors" + "time" +) + +type {{Name}} struct { + uuid string + createdAt time.Time +} + +func New{{Name}}(uuid string) (*{{Name}}, error) { + if uuid == "" { + return nil, errors.New("empty {{name_lower}} uuid") + } + + return &{{Name}}{ + uuid: uuid, + createdAt: time.Now(), + }, nil +} + +func Unmarshal{{Name}}FromDatabase(uuid string, createdAt time.Time) *{{Name}} { + return &{{Name}}{ + uuid: uuid, + createdAt: createdAt, + } +} + +func (t {{Name}}) UUID() string { + return t.uuid +} + +func (t {{Name}}) CreatedAt() time.Time { + return t.createdAt +} +``` + +### 2. `domain/{{name_lower}}/repository.go` + +```go +package {{name_lower}} + +import "context" + +type Repository interface { + Get{{Name}}(ctx context.Context, uuid string) (*{{Name}}, error) + Update{{Name}}(ctx context.Context, uuid string, + updateFn func(t *{{Name}}) (*{{Name}}, error)) error +} +``` + +### 3. `domain/{{name_lower}}/errors.go` + +```go +package {{name_lower}} + +import "errors" + +var ( + ErrNotFound = errors.New("{{name_lower}} not found") +) +``` + +### 4. `app/app.go` + +```go +package app + +import ( + "{{module}}/app/command" + "{{module}}/app/query" +) + +type Application struct { + Commands Commands + Queries Queries +} + +type Commands struct { + // Add command handlers here, e.g.: + // Create{{Name}} command.Create{{Name}}Handler +} + +type Queries struct { + // Add query handlers here, e.g.: + // {{Name}}ByUUID query.{{Name}}ByUUIDHandler +} +``` + +### 5. `app/command/.gitkeep` + +Create empty directory placeholder. + +### 6. `app/query/.gitkeep` + +Create empty directory placeholder. + +### 7. `ports/http.go` + +```go +package ports + +import ( + "{{module}}/app" +) + +type HttpServer struct { + app app.Application +} + +func NewHttpServer(application app.Application) HttpServer { + return HttpServer{app: application} +} +``` + +### 8. `adapters/memory_{{name_snake}}_repository.go` + +```go +package adapters + +import ( + "context" + "sync" + + "{{module}}/domain/{{name_lower}}" +) + +type Memory{{Name}}Repository struct { + {{name_lower}}s map[string]{{name_lower}}.{{Name}} + mu sync.RWMutex +} + +func NewMemory{{Name}}Repository() *Memory{{Name}}Repository { + return &Memory{{Name}}Repository{ + {{name_lower}}s: make(map[string]{{name_lower}}.{{Name}}), + } +} + +func (r *Memory{{Name}}Repository) Get{{Name}}(ctx context.Context, uuid string) (*{{name_lower}}.{{Name}}, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + t, ok := r.{{name_lower}}s[uuid] + if !ok { + return nil, {{name_lower}}.ErrNotFound + } + + return &t, nil +} + +func (r *Memory{{Name}}Repository) Update{{Name}}( + ctx context.Context, + uuid string, + updateFn func(t *{{name_lower}}.{{Name}}) (*{{name_lower}}.{{Name}}, error), +) error { + r.mu.Lock() + defer r.mu.Unlock() + + current, ok := r.{{name_lower}}s[uuid] + if !ok { + return {{name_lower}}.ErrNotFound + } + + updated, err := updateFn(¤t) + if err != nil { + return err + } + + r.{{name_lower}}s[uuid] = *updated + return nil +} +``` + +### 9. `service/application.go` + +```go +package service + +import ( + "context" + + "{{module}}/adapters" + "{{module}}/app" +) + +func NewApplication(ctx context.Context) app.Application { + {{name_lower}}Repository := adapters.NewMemory{{Name}}Repository() + _ = {{name_lower}}Repository // wire into handlers + + return app.Application{ + Commands: app.Commands{}, + Queries: app.Queries{}, + } +} +``` + +## Post-Creation Instructions + +After creating the service skeleton: + +1. Add your first command with `/threedots scaffold command ` +2. Add your first query with `/threedots scaffold query ` +3. Wire them in `service/application.go` +4. Add HTTP/gRPC handlers in `ports/`