This commit is contained in:
commit
de4efe97bf
|
|
@ -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 |
|
||||||
|
|
@ -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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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 <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/`
|
||||||
Loading…
Reference in New Issue