This commit is contained in:
naudachu 2026-03-06 21:38:51 +05:00
commit de4efe97bf
14 changed files with 2420 additions and 0 deletions

188
SKILL.md Normal file
View File

@ -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 <type> <name> to generate."
user-invocable: true
argument-hint: "<audit|scaffold> [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 <path>`**: Run audit on the specified path
- **`scaffold service <Name>`**: Generate full service skeleton
- **`scaffold command <Name>`**: Generate command handler file
- **`scaffold query <Name>`**: Generate query handler file
- **`scaffold entity <Name>`**: Generate domain entity file
- **`scaffold repo <Name>`**: 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 |

View File

@ -0,0 +1,110 @@
# Architecture Rules (ARCH-01..03)
## ARCH-01: Standard Directory Layout (CRITICAL)
Every service MUST follow this directory structure:
```
<service>/
├── domain/<aggregate>/ # 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),
},
}
}
```

View File

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

276
references/rules-cqrs.md Normal file
View File

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

265
references/rules-domain.md Normal file
View File

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

150
references/rules-errors.md Normal file
View File

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

View File

@ -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

148
references/rules-ports.md Normal file
View File

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

View File

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

115
templates/command.md Normal file
View File

@ -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,
),
},
```

156
templates/entity.md Normal file
View File

@ -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

124
templates/query.md Normal file
View File

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

212
templates/repo.md Normal file
View File

@ -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(&current)
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)},
}
}
```

223
templates/service.md Normal file
View File

@ -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(&current)
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 <ActionName>`
2. Add your first query with `/threedots scaffold query <QueryName>`
3. Wire them in `service/application.go`
4. Add HTTP/gRPC handlers in `ports/`