merge: unify tdl and tea into one marketplace
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"name": "tea",
|
||||
"name": "claude-skills",
|
||||
"owner": {
|
||||
"name": "naudachu"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "tea",
|
||||
"source": "./",
|
||||
"source": "./plugins/tea",
|
||||
"description": "Gitea issues as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph), /tea:sync moves them to and from Gitea, /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login."
|
||||
},
|
||||
{
|
||||
"name": "tdl",
|
||||
"source": "./plugins/tdl",
|
||||
"description": "Three Dots Labs Go conventions as an enforceable rule set: /tdl:audit scans a Go project against 63 CQRS/DDD/Clean-Architecture rules and reports violations by severity, or scaffolds new services, handlers, entities, repositories and Watermill adapters from templates that already follow them."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,186 +1,63 @@
|
||||
# tea — Claude Code plugin for the Gitea CLI
|
||||
# claude-skills — a Claude Code plugin marketplace
|
||||
|
||||
A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforces a hard rule: every `tea` command runs under the login **the operator chose**, never one Claude picked.
|
||||
|
||||
## What it ships
|
||||
|
||||
| Piece | What it does |
|
||||
|---|---|
|
||||
| `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project |
|
||||
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
|
||||
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment, close, evict |
|
||||
| `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand |
|
||||
| `tea-runner` agent | Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
|
||||
| `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation |
|
||||
|
||||
## The layering
|
||||
|
||||
An issue is a unit of work first and a Gitea row second. That is two layers,
|
||||
and knowledge flows one way:
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||
▲ offline — no tracker, no network, stdlib only
|
||||
│ imports
|
||||
skills/sync BRIDGE md <-> Gitea issue JSON, then over the wire
|
||||
▲
|
||||
│ calls
|
||||
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
||||
```
|
||||
|
||||
Delete `skills/sync` and the issue domain keeps working. Work that lives only
|
||||
on your machine is first-class, not a draft waiting to be uploaded. That is the
|
||||
point of the split: you can plan, write, and validate without a tracker, and
|
||||
publish only what you choose to.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Claude Code** — CLI, desktop app, or IDE extension
|
||||
- **Python 3** — required by the `tea-guard` hook (`python3` must be on `$PATH`)
|
||||
- **`tea`** — Gitea's official CLI. Install with `brew install tea` (macOS) or from [gitea.com/gitea/tea/releases](https://gitea.com/gitea/tea/releases)
|
||||
- At least one login configured: `tea logins add` (interactive — run it in a terminal, not via Claude)
|
||||
One repository, one marketplace, several plugins. Register it once and install
|
||||
whichever pieces you want; each plugin is independent and carries its own
|
||||
manifest, docs, and tests.
|
||||
|
||||
## Installation
|
||||
|
||||
This is a Claude Code plugin — install it through the plugin marketplace, not by hand-editing `settings.json`.
|
||||
|
||||
1. Register this repo as a marketplace:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.noodles.cam/claude-skills/tea.git
|
||||
```
|
||||
|
||||
Already have a local clone? Point at the directory instead:
|
||||
|
||||
```
|
||||
/plugin marketplace add /path/to/tea
|
||||
```
|
||||
|
||||
2. Install the plugin:
|
||||
|
||||
```
|
||||
/plugin install tea@tea
|
||||
```
|
||||
|
||||
The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-guard` hook load immediately. Use `/plugin` to enable, disable, or update it later.
|
||||
|
||||
> The marketplace registration is written to `extraKnownMarketplaces` and the plugin to `enabledPlugins` in your settings automatically — you don't edit those by hand. There is **no** top-level `"plugins"` settings key; if you've added one from older instructions, remove it.
|
||||
|
||||
## First use
|
||||
|
||||
Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to the project root's `.claude/settings.local.json` and takes effect immediately — no restart needed.
|
||||
|
||||
Once per *project*, not once per checkout: a `git worktree` shares its main checkout's pin. Both the hook and the scripts find it from inside a worktree, so don't run `/tea:auth` there — it would leave a second pin in a directory that disappears with the branch.
|
||||
|
||||
```
|
||||
/tea:auth
|
||||
/plugin marketplace add https://git.noodles.cam/claude-skills/marketplace.git
|
||||
```
|
||||
|
||||
After that, just ask Claude to do something with issues or Gitea — it loads the
|
||||
right skill automatically. `/tea:auth` is only needed for the tracker side;
|
||||
`/tea:issue` works without any login at all.
|
||||
Working from a local clone? Point at the directory instead:
|
||||
|
||||
## How the login guard works
|
||||
```
|
||||
/plugin marketplace add /path/to/marketplace
|
||||
```
|
||||
|
||||
Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it. The hook and the scripts look it up the same way — one search order, in `skills/auth/scripts/pin.py`.
|
||||
Then install what you need:
|
||||
|
||||
Claude is **blocked** from:
|
||||
- running `tea` without `--login` at all
|
||||
- naming a login itself (e.g. `--login myaccount`)
|
||||
- using any variable other than `$GITEA_LOGIN`
|
||||
```
|
||||
/plugin install tea@claude-skills
|
||||
/plugin install tdl@claude-skills
|
||||
```
|
||||
|
||||
This prevents silent fallback to the machine's default login (often a personal account) when working in a project that belongs to a different identity.
|
||||
Use `/plugin` to enable, disable, or update them later.
|
||||
|
||||
`tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data.
|
||||
## What ships here
|
||||
|
||||
## The tea-runner agent
|
||||
| Plugin | Commands | What it does |
|
||||
|---|---|---|
|
||||
| [`tea`](plugins/tea) | `/tea:auth` `/tea:issue` `/tea:sync` `/tea:use` | Gitea issues as local markdown, cleanly layered. Issues are units of work offline first and tracker rows second; a PreToolUse hook blocks any `tea` command that would run under a login Claude picked instead of the operator |
|
||||
| [`tdl`](plugins/tdl) | `/tdl:audit` | Three Dots Labs Go conventions as an enforceable rule set — audits a Go project against 63 CQRS/DDD/Clean-Architecture rules by severity, or scaffolds services, handlers, entities, repositories and Watermill adapters from templates that already follow them |
|
||||
|
||||
The skills carry meaning; the scripts carry work. `tea-runner` is a subagent on
|
||||
Haiku that does the second half in its own context and hands back a receipt —
|
||||
what ran, what it touched, what failed, verbatim.
|
||||
|
||||
Delegate a **batch**: pull a milestone and rebuild the index, push the three
|
||||
issues you just wrote, bootstrap the label set, post a comment from a file you
|
||||
prepared. Spawning it for a single `pull.py 42` costs more than running the
|
||||
command yourself; the saving is in the loop, the retry, and reading somebody
|
||||
else's stderr.
|
||||
|
||||
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no closing or
|
||||
retitling, no raw `tea`, no pushing beyond the set it was handed. A missing
|
||||
type, a failed validation, an unpushed dependency come back as a question, not
|
||||
as a guess. The `tea-guard` hook applies to it exactly as it does to the main
|
||||
session — the pinned login is enforced on every call it makes.
|
||||
|
||||
## Project layout
|
||||
## Layout
|
||||
|
||||
```
|
||||
.claude-plugin/
|
||||
plugin.json plugin manifest
|
||||
marketplace.json marketplace catalog (makes `/plugin install` work)
|
||||
agents/
|
||||
tea-runner.md subagent (Haiku) that executes the scripts
|
||||
hooks/
|
||||
hooks.json registers the PreToolUse hooks
|
||||
tea-guard.sh the guard (Python 3, no deps)
|
||||
agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it
|
||||
skills/
|
||||
auth/ /tea:auth — the identity layer
|
||||
SKILL.md
|
||||
scripts/pin.py where the login pin is and how it is found —
|
||||
imported by _gitea.py AND by tea-guard.sh
|
||||
issue/ /tea:issue — the issue domain, offline
|
||||
SKILL.md
|
||||
references/format.md canonical issue format (identity, types, templates)
|
||||
scripts/ Python 3, stdlib only, no network:
|
||||
issue.py domain module: slug identity, parse/render,
|
||||
validation, taxonomy, dependency graph,
|
||||
body checkboxes
|
||||
issue_new.py create a local issue from its type template
|
||||
issue_check.py validate against the format
|
||||
issue_ac.py list the body's checkboxes; tick one
|
||||
issue_tree.py draw the dependency graph
|
||||
issue_evict.py drop closed issues the tracker also has
|
||||
issue_index.py rebuild tmp/issues/INDEX.md
|
||||
sync/ /tea:sync — the bridge to Gitea
|
||||
SKILL.md
|
||||
scripts/
|
||||
map.py md <-> Gitea JSON, pure functions, no I/O
|
||||
_gitea.py transport: login pin, tea api, pagination, filters
|
||||
pull.py Gitea -> tmp/issues/
|
||||
push.py tmp/issues/ -> Gitea, then drops the local file
|
||||
remote.py discovery listing to stdout
|
||||
comment.py post or edit a comment
|
||||
close.py the state field, both ways
|
||||
evict.py refresh state: from Gitea, then evict
|
||||
labels.py put the canonical label set into a repository
|
||||
use/ /tea:use — tea CLI reference (non-issue entities)
|
||||
SKILL.md
|
||||
references/tea/ command docs
|
||||
marketplace.json the catalog — one entry per plugin, source is a
|
||||
path into plugins/
|
||||
plugins/
|
||||
tea/
|
||||
.claude-plugin/plugin.json
|
||||
agents/ hooks/ skills/ tests/
|
||||
README.md AGENTS.md
|
||||
tdl/
|
||||
.claude-plugin/plugin.json
|
||||
skills/
|
||||
```
|
||||
|
||||
`AGENTS.md` carries the same layout with the reasoning behind it; if the two
|
||||
ever disagree, `AGENTS.md` is the one being worked from.
|
||||
A plugin's root is its directory under `plugins/`, so `${CLAUDE_PLUGIN_ROOT}`
|
||||
resolves inside it and every path a plugin uses stays relative to itself.
|
||||
Adding a plugin means adding a directory here plus one entry in
|
||||
`marketplace.json` — nothing else in the repo needs to know about it.
|
||||
|
||||
## Local issue store
|
||||
## Development
|
||||
|
||||
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
|
||||
field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without
|
||||
a parser.
|
||||
`tea` has a test suite; run it from its own directory so the tests resolve
|
||||
their root correctly:
|
||||
|
||||
An `origin: local` file **is** the issue — the store, and the only copy.
|
||||
Anything with `origin: gitea` is a working copy of something the tracker
|
||||
already has, and it is deleted as soon as a push confirms the tracker is up to
|
||||
date:
|
||||
|
||||
- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers
|
||||
live in a `gitea:` field.
|
||||
- `origin: local` is a complete state. An issue that never leaves your machine
|
||||
is valid and finished — but it is not permanent: pushing ends it.
|
||||
- **A successful push deletes the local file** (`--update` too) and prints the
|
||||
number and URL it now lives at. Only after a confirmed response: a failed
|
||||
call leaves the file exactly where it was. Get it back with `pull.py <n>` —
|
||||
same slug, same `depends:`, even after a rename in Gitea.
|
||||
- Pulling overwrites the body: a fetch, not a merge. It is also how a pushed
|
||||
issue comes back.
|
||||
- Nothing tracks drift, and there is no second copy to drift. A file that is
|
||||
still here has not been pushed.
|
||||
```
|
||||
cd plugins/tea && python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "tdl",
|
||||
"description": "Three Dots Labs Go conventions as an enforceable rule set: /tdl:audit scans a Go project against 63 CQRS/DDD/Clean-Architecture rules and reports violations by severity, or scaffolds new services, handlers, entities, repositories and Watermill adapters from templates that already follow them.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "naudachu"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": ["go", "ddd", "cqrs", "clean-architecture", "watermill", "audit"]
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
name: audit
|
||||
description: "Three Dots Labs Go style/pattern guide. Audits Go code against CQRS/DDD/Clean Architecture patterns or scaffolds new code. /tdl:audit [path] to audit, /tdl:audit scaffold <type> <name> to generate."
|
||||
user-invocable: true
|
||||
argument-hint: "[path] | 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 `<skill-base-dir>/references/rules-architecture.md`
|
||||
2. Read `<skill-base-dir>/references/rules-domain.md`
|
||||
3. Read `<skill-base-dir>/references/rules-cqrs.md`
|
||||
4. Read `<skill-base-dir>/references/rules-repository.md`
|
||||
5. Read `<skill-base-dir>/references/rules-errors.md`
|
||||
6. Read `<skill-base-dir>/references/rules-ports.md`
|
||||
7. Read `<skill-base-dir>/references/rules-naming.md`
|
||||
8. Read `<skill-base-dir>/references/rules-codestyle.md`
|
||||
9. Read `<skill-base-dir>/references/rules-watermill.md`
|
||||
|
||||
Read all 9 files in parallel before proceeding.
|
||||
|
||||
## Argument Parsing
|
||||
|
||||
Parse the user's arguments:
|
||||
|
||||
- **No arguments** or **`audit`**: Run audit on current working directory
|
||||
- **`<path>`** or **`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
|
||||
- **`scaffold unified_server`**: Generate unified server with named components, OnShutdown, With* options
|
||||
- **`scaffold watermill_router`**: Generate WithWatermillRouter option + publisher client
|
||||
- **`scaffold event_handler <Name>`**: Generate event handler port (inbound Watermill adapter)
|
||||
- **`scaffold event_publisher <Name>`**: Generate event publisher adapter (outbound Watermill adapter)
|
||||
|
||||
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..08) | Directory structure, all `.go` file imports, `service/`, `main.go` |
|
||||
| Watermill (WM-01..10) | `main.go`, `server/watermill.go`, `client/watermill.go`, `ports/event.go`, `adapters/*event*.go`, `app/command/services.go` |
|
||||
| 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..06) | 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/63 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 `<skill-base-dir>/templates/`:
|
||||
|
||||
| Type | Template file |
|
||||
|------|--------------|
|
||||
| `service` | `templates/service.md` |
|
||||
| `command` | `templates/command.md` |
|
||||
| `query` | `templates/query.md` |
|
||||
| `entity` | `templates/entity.md` |
|
||||
| `repo` | `templates/repo.md` |
|
||||
| `unified_server` | `templates/unified_server.md` |
|
||||
| `watermill_router` | `templates/watermill_router.md` |
|
||||
| `event_handler` | `templates/event_handler.md` |
|
||||
| `event_publisher` | `templates/event_publisher.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 isolation — only service/ knows concrete adapters and infra | CRITICAL |
|
||||
| ARCH-04 | Dual constructor pattern — shared private wiring, prod + test constructors | WARNING |
|
||||
| ARCH-05 | Cleanup function returned from NewApplication for resource lifecycle | WARNING |
|
||||
| ARCH-06 | Server startup via callback — main.go provides handler, never configures internals | WARNING |
|
||||
| ARCH-07 | Composition root must not own server lifecycle — no servers, listeners, signals in service/ | CRITICAL |
|
||||
| ARCH-08 | Unified server with named components and OnShutdown — explicit shutdown ordering | 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 |
|
||||
| PORT-06 | No Unimplemented embedding in gRPC servers — compile-time compliance | CRITICAL |
|
||||
| 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 |
|
||||
| WM-01 | Router factory via callback — same pattern as gRPC/HTTP | CRITICAL |
|
||||
| WM-02 | Publisher factory returns (Publisher, Close, Error) triple | CRITICAL |
|
||||
| WM-03 | Event handlers live in ports/ — same as HTTP/gRPC handlers | CRITICAL |
|
||||
| WM-04 | Event publisher adapter implements domain interface | WARNING |
|
||||
| WM-05 | Topic naming uses domain language with dot notation | WARNING |
|
||||
| WM-06 | Event structs live in ports/ or adapters/, not domain/ | INFO |
|
||||
| WM-07 | Watermill middleware in server factory only | WARNING |
|
||||
| WM-08 | Publisher cleanup in composition root cleanup function | WARNING |
|
||||
| WM-09 | Named components replace SERVER_TO_RUN switch | INFO |
|
||||
| WM-10 | No sync side effects replaced by fire-and-forget without saga | CRITICAL |
|
||||
@@ -0,0 +1,480 @@
|
||||
# Architecture Rules (ARCH-01..08)
|
||||
|
||||
## 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 Isolation (CRITICAL)
|
||||
|
||||
All dependency wiring MUST happen exclusively in `service/`. The composition root is the **only** place that knows about concrete adapter types, infrastructure clients, and how dependencies connect.
|
||||
|
||||
**`main.go`** MUST only:
|
||||
1. Initialize cross-cutting concerns (logging)
|
||||
2. Call `service.NewApplication()`
|
||||
3. Wire ports (pass `app.Application` to port constructors)
|
||||
4. Start the server
|
||||
|
||||
`main.go` MUST NOT import `adapters/`, create infrastructure clients, or instantiate command/query handlers directly.
|
||||
|
||||
**Check procedure:**
|
||||
1. Scan `main.go` imports — flag any reference to `adapters/`, database drivers, or external service clients
|
||||
2. Scan all files outside `service/` — flag any call to adapter constructors (e.g., `adapters.New*`)
|
||||
3. Verify `service/` returns `app.Application`
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// main.go — only knows about service and ports
|
||||
func main() {
|
||||
logs.Init()
|
||||
ctx := context.Background()
|
||||
|
||||
app, cleanup := service.NewApplication(ctx)
|
||||
defer cleanup()
|
||||
|
||||
server.RunHTTPServer(func(router chi.Router) http.Handler {
|
||||
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// main.go — VIOLATION: wiring infrastructure directly
|
||||
func main() {
|
||||
client, _ := firestore.NewClient(ctx, os.Getenv("GCP_PROJECT")) // VIOLATION
|
||||
repo := adapters.NewFirestoreRepository(client) // VIOLATION
|
||||
handler := command.NewScheduleTrainingHandler(repo, logger, mc) // VIOLATION
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ARCH-04: Dual Constructor Pattern for Testability (WARNING)
|
||||
|
||||
The composition root MUST provide two constructors sharing a single private wiring function:
|
||||
1. **`NewApplication(ctx) (app.Application, func())`** — production constructor, creates real infrastructure
|
||||
2. **`NewComponentTestApplication(ctx) app.Application`** — test constructor, injects mocks/stubs
|
||||
|
||||
Both MUST delegate to a **private** `newApplication(...)` that accepts dependencies as interfaces, so the real vs test paths only differ in what they pass in.
|
||||
|
||||
This ensures:
|
||||
- Test mocks never leak into production wiring
|
||||
- All wiring logic is shared — no drift between prod and test setups
|
||||
- The private function signature documents the full set of external dependencies
|
||||
|
||||
**Check procedure:**
|
||||
1. Look for exported `NewApplication` and `NewComponentTestApplication` in `service/`
|
||||
2. Verify both call the same unexported function
|
||||
3. The unexported function MUST accept dependencies as interfaces, not concrete types
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// service/service.go
|
||||
func NewApplication(ctx context.Context) (app.Application, func()) {
|
||||
trainerClient, closeTrainer, err := client.NewTrainerClient()
|
||||
if err != nil { panic(err) }
|
||||
|
||||
trainerService := adapters.NewTrainerGrpc(trainerClient)
|
||||
|
||||
return newApplication(ctx, trainerService),
|
||||
func() { _ = closeTrainer() }
|
||||
}
|
||||
|
||||
func NewComponentTestApplication(ctx context.Context) app.Application {
|
||||
return newApplication(ctx, TrainerServiceMock{})
|
||||
}
|
||||
|
||||
func newApplication(ctx context.Context, trainerService command.TrainerService) app.Application {
|
||||
// shared wiring logic — accepts interfaces, not concrete types
|
||||
repo := adapters.NewFirestoreRepository(client)
|
||||
return app.Application{ /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// VIOLATION: separate wiring paths, no shared private function
|
||||
func NewApplication(ctx context.Context) app.Application {
|
||||
repo := adapters.NewFirestoreRepository(client)
|
||||
return app.Application{
|
||||
Commands: app.Commands{
|
||||
ScheduleTraining: command.NewScheduleTrainingHandler(repo, logger, mc),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewTestApplication() app.Application {
|
||||
repo := NewMockRepo() // VIOLATION: duplicated wiring, can drift
|
||||
return app.Application{
|
||||
Commands: app.Commands{
|
||||
ScheduleTraining: command.NewScheduleTrainingHandler(repo, logger, mc),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ARCH-05: Cleanup Function for Resource Lifecycle (WARNING)
|
||||
|
||||
When the composition root creates resources that require cleanup (connections, clients, subscriptions), `NewApplication` MUST return a cleanup function alongside the application. The caller owns the lifecycle via `defer`.
|
||||
|
||||
This ensures:
|
||||
- Resources are released even on panic
|
||||
- `main.go` doesn't need to know *what* to clean up — just *that* it must
|
||||
- Adding new infrastructure only changes `service/`, not `main.go`
|
||||
|
||||
**Check procedure:**
|
||||
1. If `NewApplication` creates closeable resources (clients, connections), it MUST return `func()`
|
||||
2. `main.go` MUST call `defer cleanup()` immediately after receiving it
|
||||
3. The cleanup function MUST NOT be ignored (assigned to `_`)
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// service/service.go
|
||||
func NewApplication(ctx context.Context) (app.Application, func()) {
|
||||
trainerClient, closeTrainer, err := client.NewTrainerClient()
|
||||
if err != nil { panic(err) }
|
||||
usersClient, closeUsers, err := client.NewUsersClient()
|
||||
if err != nil { panic(err) }
|
||||
|
||||
return newApplication(ctx, adapters.NewTrainerGrpc(trainerClient), adapters.NewUsersGrpc(usersClient)),
|
||||
func() {
|
||||
_ = closeTrainer()
|
||||
_ = closeUsers()
|
||||
}
|
||||
}
|
||||
|
||||
// main.go
|
||||
app, cleanup := service.NewApplication(ctx)
|
||||
defer cleanup()
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// VIOLATION: caller must know internals to clean up
|
||||
func NewApplication(ctx context.Context) (app.Application, *firestore.Client, *grpc.ClientConn) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// VIOLATION: cleanup responsibility leaks into main
|
||||
app, fsClient, conn := service.NewApplication(ctx)
|
||||
defer fsClient.Close() // main.go shouldn't know about Firestore
|
||||
defer conn.Close() // main.go shouldn't know about gRPC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ARCH-06: Server Startup via Callback (WARNING)
|
||||
|
||||
Server startup MUST be delegated to a shared `server.Run*Server()` function. `main.go` provides **only the application handler** via a callback. It MUST NOT configure server internals: middleware, routing, listening address, or transport-level concerns.
|
||||
|
||||
This ensures:
|
||||
- Middleware stack (auth, logging, recovery, CORS, security headers) is consistent across all services
|
||||
- Adding or changing middleware is a single change, not per-service
|
||||
- `main.go` remains a thin orchestrator: init → wire app → provide handler → run
|
||||
|
||||
**Check procedure:**
|
||||
1. `main.go` MUST call a shared `Run*Server()` function as the final blocking call
|
||||
2. The callback passed to `Run*Server()` MUST only construct the handler from port constructors — no middleware setup, no router configuration, no listener creation
|
||||
3. `main.go` MUST NOT import server infrastructure packages (e.g., `net/http.ListenAndServe`, `net.Listen`, middleware libraries)
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// main.go — provides handler, delegates everything else
|
||||
func main() {
|
||||
logs.Init()
|
||||
ctx := context.Background()
|
||||
|
||||
app, cleanup := service.NewApplication(ctx)
|
||||
defer cleanup()
|
||||
|
||||
server.RunHTTPServer(func(router chi.Router) http.Handler {
|
||||
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// VIOLATION: main.go configures server internals
|
||||
func main() {
|
||||
app, cleanup := service.NewApplication(ctx)
|
||||
defer cleanup()
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.Logger) // VIOLATION: middleware in main
|
||||
router.Use(middleware.Recoverer) // VIOLATION: middleware in main
|
||||
router.Mount("/api", ports.NewHttpServer(app))
|
||||
|
||||
http.ListenAndServe(":8080", router) // VIOLATION: listening in main
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ARCH-07: Composition Root Must Not Own Server Lifecycle (CRITICAL)
|
||||
|
||||
The `service/` package wires dependencies and returns `app.Application`. It MUST NOT create transport servers, bind to network ports, handle OS signals, or manage graceful shutdown. Server lifecycle is a **separate concern** that belongs in a shared server package or the entry point.
|
||||
|
||||
`service/` MUST NOT:
|
||||
- Create transport servers (`grpc.NewServer()`, `http.Server{}`, `message.NewRouter()`)
|
||||
- Bind to network ports (`net.Listen()`)
|
||||
- Handle OS signals (`signal.NotifyContext()`, `signal.Notify()`)
|
||||
- Manage graceful shutdown (`GracefulStop()`, `router.Close()`)
|
||||
- Import port packages (`ports/grpc`, `ports/amqp`, `ports/http`)
|
||||
|
||||
`service/` MUST only:
|
||||
- Create infrastructure clients and adapters
|
||||
- Wire command/query handlers with dependencies
|
||||
- Return `app.Application` (and optionally a cleanup function)
|
||||
|
||||
**Check procedure:**
|
||||
1. Scan all files in `service/` for imports of `net`, `os/signal`, `syscall`, transport packages, or `ports/`
|
||||
2. Flag any function in `service/` that accepts or creates a server, listener, or router
|
||||
3. A file named `server.go` in `service/` is a strong signal of violation
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// service/service.go — only wires the application
|
||||
func NewApplication(ctx context.Context, cfg *config.Config) (app.Application, func()) {
|
||||
repo := adapters.NewFirestoreRepository(client)
|
||||
syncer := tokensync.NewSyncer(fetchers, syncRepo, progressTracker)
|
||||
|
||||
return newApplication(repo, syncer),
|
||||
func() { _ = client.Close() }
|
||||
}
|
||||
|
||||
// Server lifecycle lives elsewhere (shared server package or entry point)
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// service/server.go — VIOLATION: server lifecycle in composition root
|
||||
func RunServer(application app.Application, cfg *config.Config) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), ...) // VIOLATION: signal handling
|
||||
defer stop()
|
||||
|
||||
grpcServer := grpc.NewServer() // VIOLATION: transport server
|
||||
pb.RegisterCommandsServer(grpcServer, ports.NewServer(app)) // VIOLATION: imports ports/
|
||||
|
||||
lis, _ := net.Listen("tcp", fmt.Sprintf(":%s", cfg.Port)) // VIOLATION: network binding
|
||||
go grpcServer.Serve(lis) // VIOLATION: server lifecycle
|
||||
|
||||
<-ctx.Done()
|
||||
grpcServer.GracefulStop() // VIOLATION: shutdown management
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ARCH-08: Unified Server with Named Components and OnShutdown (WARNING)
|
||||
|
||||
When a project has multiple transports (gRPC, HTTP, AMQP/Watermill), the shared server package SHOULD provide a **single `server.New(...).Run(ctx)`** with functional options per transport and an explicit `OnShutdown` that declares the shutdown sequence.
|
||||
|
||||
### Why explicit shutdown ordering matters
|
||||
|
||||
Different services have different dependency graphs between transports:
|
||||
- A consumer that calls gRPC must stop consuming *before* gRPC clients close
|
||||
- An HTTP API that publishes events must drain HTTP *before* the publisher closes
|
||||
- Two independent ingress points (HTTP + gRPC) can shut down in parallel
|
||||
|
||||
Implicit ordering (LIFO based on registration) is fragile — reordering lines silently changes shutdown behavior. `OnShutdown` makes the sequence a readable, reviewable declaration.
|
||||
|
||||
### Core types
|
||||
|
||||
```go
|
||||
// server/server.go
|
||||
type Server struct {
|
||||
components map[string]component
|
||||
startOrder []string
|
||||
shutdownSteps []ShutdownStep
|
||||
}
|
||||
|
||||
type component struct {
|
||||
name string
|
||||
start func(ctx context.Context) error
|
||||
stop func(ctx context.Context) error
|
||||
}
|
||||
|
||||
type Option func(*Server)
|
||||
|
||||
type ShutdownStep struct {
|
||||
componentNames []string
|
||||
fn func(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
```go
|
||||
// Stop creates a step that stops named components.
|
||||
// Multiple names = parallel shutdown within the step.
|
||||
func Stop(names ...string) ShutdownStep
|
||||
|
||||
// StopFunc creates a step that runs an arbitrary cleanup function.
|
||||
func StopFunc(fn func()) ShutdownStep
|
||||
|
||||
// StopFuncWithErr creates a step with error return.
|
||||
func StopFuncWithErr(fn func(ctx context.Context) error) ShutdownStep
|
||||
|
||||
// OnShutdown declares the shutdown sequence.
|
||||
// Steps execute top-to-bottom. Each step completes before the next starts.
|
||||
// Components not mentioned stop last (with a warning log).
|
||||
func OnShutdown(steps ...ShutdownStep) Option
|
||||
```
|
||||
|
||||
### Shutdown execution
|
||||
|
||||
1. Steps execute sequentially in declaration order
|
||||
2. Within a `Stop("a", "b")` call, components stop in parallel
|
||||
3. Each step's `wg.Wait()` completes before the next step begins
|
||||
4. Components not mentioned in any `Stop()` get a catch-all parallel stop after all explicit steps (with a warning log — every component should be in OnShutdown)
|
||||
5. A global timeout (default 30s) bounds the entire sequence
|
||||
|
||||
### Key design principles
|
||||
|
||||
- Each `With*` option takes a `name string` as first argument — used in `Stop(name)` to reference it
|
||||
- `OnShutdown` reads top-to-bottom as a shutdown script
|
||||
- The factory owns `signal.NotifyContext` — callers never handle signals
|
||||
- `defer cleanup()` from `NewApplication` naturally runs after `Run()` returns — it is the implicit last phase
|
||||
- Duplicate component names panic at startup — caught immediately
|
||||
|
||||
**Check procedure:**
|
||||
1. If a project uses 2+ transports, verify `server.New()` is used (not multiple `Run*Server` calls)
|
||||
2. Verify `OnShutdown` is present and lists all components
|
||||
3. Verify shutdown order makes sense: consumers before servers, servers before clients
|
||||
4. No `signal.NotifyContext`, `net.Listen`, or `GracefulStop` calls outside `common/server/`
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// Trainer: HTTP + gRPC + Watermill consumer
|
||||
func main() {
|
||||
logs.Init()
|
||||
ctx := context.Background()
|
||||
|
||||
app, cleanup := service.NewApplication(ctx)
|
||||
defer cleanup()
|
||||
|
||||
server.New(
|
||||
server.WithWatermillRouter("events", func(r *message.Router, sub message.Subscriber) {
|
||||
ports.RegisterEventHandlers(r, sub, app)
|
||||
}),
|
||||
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
|
||||
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
|
||||
}),
|
||||
server.WithGRPCServer("grpc", func(s *grpc.Server) {
|
||||
trainer.RegisterTrainerServiceServer(s, ports.NewGrpcServer(app))
|
||||
}),
|
||||
server.OnShutdown(
|
||||
server.Stop("events"), // 1. stop consuming
|
||||
server.Stop("api", "grpc"), // 2. drain both servers in parallel
|
||||
server.StopFunc(cleanup), // 3. close clients & publisher
|
||||
),
|
||||
).Run(ctx)
|
||||
}
|
||||
|
||||
// Trainings: HTTP-only, publishes events (publisher in cleanup)
|
||||
func main() {
|
||||
logs.Init()
|
||||
ctx := context.Background()
|
||||
|
||||
app, cleanup := service.NewApplication(ctx)
|
||||
defer cleanup()
|
||||
|
||||
server.New(
|
||||
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
|
||||
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
|
||||
}),
|
||||
server.OnShutdown(
|
||||
server.Stop("api"), // 1. drain HTTP (in-flight may publish events)
|
||||
server.StopFunc(cleanup), // 2. close publisher + gRPC clients
|
||||
),
|
||||
).Run(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// VIOLATION: implicit LIFO ordering — fragile
|
||||
server.New(
|
||||
server.WithHTTPHandler("api", createHandler),
|
||||
server.WithWatermillRouter("events", configureRouter),
|
||||
// no OnShutdown — relies on registration order
|
||||
).Run(ctx)
|
||||
|
||||
// VIOLATION: manual lifecycle per transport
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
go grpcServer.Serve(lis)
|
||||
|
||||
router, _ := message.NewRouter(...)
|
||||
go router.Run(ctx)
|
||||
|
||||
<-ctx.Done()
|
||||
grpcServer.GracefulStop()
|
||||
router.Close()
|
||||
}
|
||||
```
|
||||
@@ -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,179 @@
|
||||
# Port Rules (PORT-01..06)
|
||||
|
||||
## 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)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PORT-06: No Unimplemented Embedding in gRPC Servers (CRITICAL)
|
||||
|
||||
gRPC server structs MUST NOT embed `Unimplemented*Server` structs. Omitting the embed enforces **compile-time interface compliance** — if a new RPC is added to the proto definition, the code will fail to compile until the method is explicitly implemented.
|
||||
|
||||
Embedding `Unimplemented*Server` silently returns "unimplemented" at runtime for missing methods, hiding broken contracts until a request hits the missing endpoint in production.
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
type GrpcServer struct {
|
||||
app app.Application
|
||||
}
|
||||
// Compile error if any RPC method from TrainerServiceServer is missing.
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
type GrpcServer struct {
|
||||
trainer.UnimplementedTrainerServiceServer // VIOLATION: hides missing methods at compile time
|
||||
app app.Application
|
||||
}
|
||||
```
|
||||
|
||||
**Check:** Scan all structs in `ports/grpc.go` for embedded `Unimplemented*Server` fields. Any match is a CRITICAL violation.
|
||||
|
||||
**Proto generation:** When generating gRPC code, use `require_unimplemented_servers=false` to keep the interface strict:
|
||||
```
|
||||
protoc --go-grpc_out=require_unimplemented_servers=false:. *.proto
|
||||
```
|
||||
@@ -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,407 @@
|
||||
# Watermill Rules (WM-01..10)
|
||||
|
||||
## WM-01: Watermill as a Named Component in Unified Server (CRITICAL)
|
||||
|
||||
Watermill router MUST be registered as a named component via `server.WithWatermillRouter(name, configure)` — same pattern as `WithHTTPHandler` and `WithGRPCServer`. The `With*` option owns AMQP connection, middleware, and router lifecycle. The caller provides **only handler registration** via callback.
|
||||
|
||||
This ensures:
|
||||
- Middleware stack (retry, correlation, recovery) is consistent across all services
|
||||
- Broker config is centralized — swapping AMQP for Kafka changes one file
|
||||
- Shutdown ordering is explicit via `server.OnShutdown(server.Stop(name))`
|
||||
|
||||
**Check procedure:**
|
||||
1. Scan `main.go` for direct Watermill router creation (`message.NewRouter`, `amqp.NewSubscriber`)
|
||||
2. Flag any middleware setup outside `server/watermill.go`
|
||||
3. Verify Watermill component appears in `OnShutdown` with correct ordering
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// internal/common/server/watermill.go
|
||||
func WithWatermillRouter(
|
||||
name string,
|
||||
configure func(*message.Router, message.Subscriber),
|
||||
) Option {
|
||||
return func(s *Server) {
|
||||
wmLogger := watermill.NewStdLoggerWithOut(os.Stdout, true, false)
|
||||
amqpURI := os.Getenv("AMQP_URI")
|
||||
amqpConfig := amqp.NewDurableQueueConfig(amqpURI)
|
||||
|
||||
sub, err := amqp.NewSubscriber(amqpConfig, wmLogger)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
r, err := message.NewRouter(message.RouterConfig{}, wmLogger)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
r.AddMiddleware(
|
||||
wmMiddleware.CorrelationID,
|
||||
wmMiddleware.Recoverer,
|
||||
wmMiddleware.Retry{MaxRetries: 3}.Middleware,
|
||||
)
|
||||
configure(r, sub)
|
||||
|
||||
s.addComponent(name, component{
|
||||
name: name,
|
||||
start: func(ctx context.Context) error {
|
||||
return r.Run(ctx)
|
||||
},
|
||||
stop: func(ctx context.Context) error {
|
||||
return r.Close()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// main.go — registered as named component
|
||||
server.New(
|
||||
server.WithWatermillRouter("events", func(r *message.Router, sub message.Subscriber) {
|
||||
ports.RegisterEventHandlers(r, sub, application)
|
||||
}),
|
||||
server.WithHTTPHandler("api", createHandler),
|
||||
server.OnShutdown(
|
||||
server.Stop("events"), // 1. stop consuming
|
||||
server.Stop("api"), // 2. drain HTTP
|
||||
server.StopFunc(cleanup), // 3. close clients
|
||||
),
|
||||
).Run(ctx)
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// main.go — VIOLATION: infrastructure in main
|
||||
func main() {
|
||||
sub, _ := amqp.NewSubscriber(amqpConfig, logger) // VIOLATION
|
||||
r, _ := message.NewRouter(message.RouterConfig{}, logger) // VIOLATION
|
||||
r.AddMiddleware(wmMiddleware.Recoverer) // VIOLATION
|
||||
r.Run(context.Background())
|
||||
}
|
||||
|
||||
// main.go — VIOLATION: standalone RunWatermillRouter without unified server
|
||||
server.RunWatermillRouter(func(r *message.Router, sub message.Subscriber) { ... })
|
||||
// Cannot coordinate shutdown with other transports
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WM-02: Publisher Factory Returns (Publisher, Close, Error) Triple (CRITICAL)
|
||||
|
||||
Publisher creation MUST follow the same `(client, closeFunc, error)` triple-return pattern as `client.NewTrainerClient()` and `client.NewUsersClient()`. Config comes from environment variables.
|
||||
|
||||
**Check procedure:**
|
||||
1. Verify publisher factory in `internal/common/client/watermill.go`
|
||||
2. Must return `(message.Publisher, func() error, error)`
|
||||
3. Must read `AMQP_URI` from env
|
||||
4. Error case must return a no-op close function, never nil
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// internal/common/client/watermill.go
|
||||
func NewWatermillPublisher() (pub message.Publisher, close func() error, err error) {
|
||||
amqpURI := os.Getenv("AMQP_URI")
|
||||
if amqpURI == "" {
|
||||
return nil, func() error { return nil }, errors.New("empty env AMQP_URI")
|
||||
}
|
||||
|
||||
logger := watermill.NewStdLoggerWithOut(os.Stdout, true, false)
|
||||
config := amqp.NewDurableQueueConfig(amqpURI)
|
||||
|
||||
publisher, err := amqp.NewPublisher(config, logger)
|
||||
if err != nil {
|
||||
return nil, func() error { return nil }, errors.Wrap(err, "cannot create watermill publisher")
|
||||
}
|
||||
|
||||
return publisher, publisher.Close, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// VIOLATION: returns raw connection, no close function
|
||||
func NewPublisher() *amqp.Publisher {
|
||||
pub, _ := amqp.NewPublisher(config, logger)
|
||||
return pub
|
||||
}
|
||||
|
||||
// VIOLATION: nil close function on error path
|
||||
func NewPublisher() (message.Publisher, func() error, error) {
|
||||
// ...
|
||||
return nil, nil, err // nil close panics on defer
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WM-03: Event Handlers Live in Ports (CRITICAL)
|
||||
|
||||
Watermill event handlers are **inbound adapters** — they are ports, just like HTTP and gRPC handlers. They MUST:
|
||||
- Live in `ports/`
|
||||
- Hold `app.Application`
|
||||
- Delegate to command/query handlers
|
||||
- Contain NO business logic
|
||||
|
||||
**Check procedure:**
|
||||
1. Scan for `message.HandlerFunc` or `func(*message.Message) error` signatures
|
||||
2. These MUST be in `ports/` package
|
||||
3. Must import `app/`, `app/command/`, or `app/query/` — not `domain/` directly
|
||||
4. Must follow the same delegation pattern as HTTP/gRPC handlers
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// ports/event.go
|
||||
type EventHandlers struct {
|
||||
app app.Application
|
||||
}
|
||||
|
||||
func RegisterEventHandlers(r *message.Router, sub message.Subscriber, application app.Application) {
|
||||
handlers := EventHandlers{app: application}
|
||||
|
||||
r.AddNoPublisherHandler(
|
||||
"OnTrainingScheduled",
|
||||
"training.scheduled",
|
||||
sub,
|
||||
handlers.OnTrainingScheduled,
|
||||
)
|
||||
}
|
||||
|
||||
func (h EventHandlers) OnTrainingScheduled(msg *message.Message) error {
|
||||
var event TrainingScheduledEvent
|
||||
if err := json.Unmarshal(msg.Payload, &event); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.app.Commands.ScheduleTraining.Handle(
|
||||
msg.Context(),
|
||||
command.ScheduleTraining{Hour: event.Hour},
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// adapters/event_handler.go — VIOLATION: handler in adapters/
|
||||
func HandleTrainingScheduled(msg *message.Message) error {
|
||||
repo.Save(ctx, training) // VIOLATION: direct repo access
|
||||
}
|
||||
|
||||
// app/command/schedule_training.go — VIOLATION: message parsing in app layer
|
||||
func (h handler) Handle(ctx context.Context, msg *message.Message) error { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WM-04: Event Publisher Adapter Implements Domain Interface (WARNING)
|
||||
|
||||
Publishing events MUST go through an adapter that implements an interface defined in the app or domain layer. The app layer defines *what* events to publish; the adapter knows *how*.
|
||||
|
||||
This keeps Watermill as a swappable infrastructure detail.
|
||||
|
||||
**Check procedure:**
|
||||
1. Look for `message.Publisher` usage — it MUST NOT appear in `app/` or `domain/`
|
||||
2. An interface like `EventPublisher` should be in `app/command/services.go` or similar
|
||||
3. The concrete adapter in `adapters/` implements it using Watermill
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// app/command/services.go
|
||||
type TrainingEventPublisher interface {
|
||||
TrainingScheduled(ctx context.Context, t training.Training) error
|
||||
TrainingCancelled(ctx context.Context, trainingUUID string) error
|
||||
}
|
||||
|
||||
// adapters/training_event_publisher.go
|
||||
type WatermillTrainingEventPublisher struct {
|
||||
pub message.Publisher
|
||||
}
|
||||
|
||||
func NewWatermillTrainingEventPublisher(pub message.Publisher) WatermillTrainingEventPublisher {
|
||||
return WatermillTrainingEventPublisher{pub: pub}
|
||||
}
|
||||
|
||||
func (p WatermillTrainingEventPublisher) TrainingScheduled(ctx context.Context, t training.Training) error {
|
||||
payload, err := json.Marshal(TrainingScheduledEvent{UUID: t.UUID(), Hour: t.Time()})
|
||||
if err != nil { return err }
|
||||
msg := message.NewMessage(watermill.NewUUID(), payload)
|
||||
middleware.SetCorrelationID(middleware.MessageCorrelationID(msg), msg)
|
||||
return p.pub.Publish("training.scheduled", msg)
|
||||
}
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// app/command/schedule_training.go — VIOLATION: Watermill in app layer
|
||||
import "github.com/ThreeDotsLabs/watermill/message"
|
||||
|
||||
func (h handler) Handle(ctx context.Context, cmd ScheduleTraining) error {
|
||||
msg := message.NewMessage(watermill.NewUUID(), payload) // VIOLATION
|
||||
h.publisher.Publish("topic", msg) // VIOLATION: infra detail
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WM-05: Topic Naming Uses Domain Language (WARNING)
|
||||
|
||||
Topic/queue names MUST use domain language with dot notation: `{aggregate}.{past-tense-event}`. No CRUD names, no technical prefixes.
|
||||
|
||||
**Correct:**
|
||||
```
|
||||
training.scheduled
|
||||
training.cancelled
|
||||
training.reschedule_requested
|
||||
hour.made_available
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
```
|
||||
create-training // VIOLATION: CRUD name
|
||||
events.training.created // VIOLATION: redundant "events" prefix, CRUD
|
||||
TRAINING_QUEUE // VIOLATION: technical name, not domain event
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WM-06: Event Structs Live in the Publishing Port or Adapter (INFO)
|
||||
|
||||
Event DTOs (the JSON payloads) are protocol-specific — they belong in `ports/` or `adapters/`, NOT in `domain/`. Domain entities are the canonical model; events are a serialization concern.
|
||||
|
||||
**Check procedure:**
|
||||
1. Look for event structs (e.g., `TrainingScheduledEvent`)
|
||||
2. They MUST be in `ports/` (if consumed by event handlers) or `adapters/` (if produced by publisher adapters)
|
||||
3. They MUST NOT be in `domain/`
|
||||
|
||||
**Correct:**
|
||||
```go
|
||||
// ports/event.go or adapters/training_event_publisher.go
|
||||
type TrainingScheduledEvent struct {
|
||||
UUID string `json:"uuid"`
|
||||
Hour time.Time `json:"hour"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WM-07: Watermill Middleware in With* Option Only (WARNING)
|
||||
|
||||
Watermill middleware (retry, correlation ID, recoverer, throttle, etc.) MUST be configured exclusively inside the `WithWatermillRouter` option in `internal/common/server/watermill.go` — same principle as ARCH-06 for HTTP/gRPC middleware.
|
||||
|
||||
**Check procedure:**
|
||||
1. Scan for `r.AddMiddleware` or `router.AddMiddleware` calls
|
||||
2. All MUST be in `internal/common/server/watermill.go` (inside `WithWatermillRouter`)
|
||||
3. Flag any middleware setup in `main.go`, `ports/`, or `service/`
|
||||
|
||||
---
|
||||
|
||||
## WM-08: Publisher Cleanup via OnShutdown or Composition Root (WARNING)
|
||||
|
||||
When a service publishes events, the publisher's close function MUST be closed as part of the shutdown sequence. Two valid patterns:
|
||||
|
||||
**Pattern A — cleanup in OnShutdown (preferred when using unified server):**
|
||||
```go
|
||||
server.New(
|
||||
server.WithHTTPHandler("api", createHandler),
|
||||
server.OnShutdown(
|
||||
server.Stop("api"), // 1. drain HTTP (in-flight may publish)
|
||||
server.StopFunc(cleanup), // 2. close publisher + clients
|
||||
),
|
||||
).Run(ctx)
|
||||
```
|
||||
|
||||
**Pattern B — cleanup via defer (simpler services):**
|
||||
```go
|
||||
app, cleanup := service.NewApplication(ctx)
|
||||
defer cleanup() // runs after Run() returns
|
||||
|
||||
server.New(
|
||||
server.WithHTTPHandler("api", createHandler),
|
||||
server.OnShutdown(
|
||||
server.Stop("api"),
|
||||
),
|
||||
).Run(ctx)
|
||||
// cleanup() runs here via defer — publisher closes after server drained
|
||||
```
|
||||
|
||||
**Check procedure:**
|
||||
1. If `service/application.go` creates a publisher, verify close is either in `OnShutdown` or in the cleanup function
|
||||
2. Publisher close MUST happen *after* all transports that might publish are stopped
|
||||
3. Closing publisher before draining HTTP/gRPC = lost messages
|
||||
|
||||
**Wrong:**
|
||||
```go
|
||||
// main.go — VIOLATION: publisher lifecycle in main, not ordered
|
||||
func main() {
|
||||
pub, closePub, _ := client.NewWatermillPublisher()
|
||||
defer closePub() // VIOLATION: may close before HTTP drains
|
||||
app := service.NewApplication(ctx, pub) // VIOLATION: infra detail leaked
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WM-09: Named Components Replace SERVER_TO_RUN Switch (INFO)
|
||||
|
||||
With the unified server pattern (ARCH-08), the `SERVER_TO_RUN` environment variable switch is replaced by composing `With*` options. A service that needs HTTP + Watermill simply registers both.
|
||||
|
||||
**Correct — unified server:**
|
||||
```go
|
||||
// All transports in one process, explicit shutdown order
|
||||
server.New(
|
||||
server.WithWatermillRouter("events", func(r *message.Router, sub message.Subscriber) {
|
||||
ports.RegisterEventHandlers(r, sub, app)
|
||||
}),
|
||||
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
|
||||
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
|
||||
}),
|
||||
server.OnShutdown(
|
||||
server.Stop("events"),
|
||||
server.Stop("api"),
|
||||
server.StopFunc(cleanup),
|
||||
),
|
||||
).Run(ctx)
|
||||
```
|
||||
|
||||
**Also acceptable — SERVER_TO_RUN for single-transport deployments:**
|
||||
```go
|
||||
// When deploying each transport as a separate container
|
||||
switch serverType {
|
||||
case "http":
|
||||
server.New(
|
||||
server.WithHTTPHandler("api", createHandler),
|
||||
server.OnShutdown(server.Stop("api")),
|
||||
).Run(ctx)
|
||||
case "watermill":
|
||||
server.New(
|
||||
server.WithWatermillRouter("events", configureRouter),
|
||||
server.OnShutdown(server.Stop("events")),
|
||||
).Run(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WM-10: No Synchronous Side Effects Replaced by Fire-and-Forget (CRITICAL)
|
||||
|
||||
When replacing synchronous gRPC calls with async events, you MUST ensure the operation tolerates eventual consistency. If the caller needs confirmation that the action succeeded, keep it synchronous (gRPC) or use a saga/process manager — do NOT simply drop the response.
|
||||
|
||||
**Check procedure:**
|
||||
1. For each gRPC adapter being replaced by events, check if the calling command inspects the return value or error
|
||||
2. If the command makes decisions based on the result, it MUST remain synchronous or use a compensation pattern
|
||||
3. Fire-and-forget is only valid for notifications, projections, and truly independent side effects
|
||||
|
||||
**Correct use of async:**
|
||||
```go
|
||||
// Notification — caller doesn't need the result
|
||||
func (h handler) Handle(ctx context.Context, cmd ScheduleTraining) error {
|
||||
// ... create training ...
|
||||
// Fire event — consumer will send email, update dashboard, etc.
|
||||
return h.eventPublisher.TrainingScheduled(ctx, training)
|
||||
}
|
||||
```
|
||||
|
||||
**Wrong use of async:**
|
||||
```go
|
||||
// VIOLATION: caller needs confirmation that hours were reserved
|
||||
func (h handler) Handle(ctx context.Context, cmd ScheduleTraining) error {
|
||||
training, _ := training.NewTraining(...)
|
||||
h.eventPublisher.TrainingScheduled(ctx, training) // VIOLATION: no guarantee hours are available
|
||||
return h.repo.Save(ctx, training) // saved training without confirmed availability
|
||||
}
|
||||
// Previously this was a synchronous gRPC call that could fail and roll back
|
||||
```
|
||||
@@ -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,99 @@
|
||||
# Event Handler Scaffold Template
|
||||
|
||||
Generate a Watermill event handler port and its registration function. Event handlers are inbound adapters — they live in `ports/` and delegate to CQRS command/query handlers, identical to HTTP and gRPC handlers.
|
||||
|
||||
## Placeholders
|
||||
|
||||
- `{{Name}}` — PascalCase event name (e.g., `TrainingScheduled`)
|
||||
- `{{name}}` — camelCase (e.g., `trainingScheduled`)
|
||||
- `{{name_snake}}` — snake_case (e.g., `training_scheduled`)
|
||||
- `{{topic}}` — Dot-notation topic name (e.g., `training.scheduled`)
|
||||
- `{{module}}` — Go module path from go.mod
|
||||
- `{{command}}` — Command to invoke, PascalCase (e.g., `ScheduleTraining`)
|
||||
|
||||
## File: `ports/event.go`
|
||||
|
||||
If this file already exists, append the handler method and registration line. If not, create it:
|
||||
|
||||
```go
|
||||
package ports
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
|
||||
"{{module}}/app"
|
||||
"{{module}}/app/command"
|
||||
)
|
||||
|
||||
type EventHandlers struct {
|
||||
app app.Application
|
||||
}
|
||||
|
||||
func RegisterEventHandlers(r *message.Router, sub message.Subscriber, application app.Application) {
|
||||
handlers := EventHandlers{app: application}
|
||||
|
||||
r.AddNoPublisherHandler(
|
||||
"On{{Name}}",
|
||||
"{{topic}}",
|
||||
sub,
|
||||
handlers.On{{Name}},
|
||||
)
|
||||
// TODO: Register additional event handlers here
|
||||
}
|
||||
|
||||
// {{Name}}Event is the event payload DTO — protocol-specific, not a domain object.
|
||||
type {{Name}}Event struct {
|
||||
// TODO: Add event fields matching the publisher's payload
|
||||
// Example:
|
||||
// UUID string `json:"uuid"`
|
||||
// Hour time.Time `json:"hour"`
|
||||
}
|
||||
|
||||
func (h EventHandlers) On{{Name}}(msg *message.Message) error {
|
||||
var event {{Name}}Event
|
||||
if err := json.Unmarshal(msg.Payload, &event); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: Construct command and delegate to app layer
|
||||
// return h.app.Commands.{{command}}.Handle(msg.Context(), command.{{command}}{
|
||||
// // Map event fields to command fields
|
||||
// })
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Update `main.go`
|
||||
|
||||
Add `WithWatermillRouter` to the unified server and include it in `OnShutdown`:
|
||||
|
||||
```go
|
||||
server.New(
|
||||
server.WithWatermillRouter("events", func(r *message.Router, sub message.Subscriber) {
|
||||
ports.RegisterEventHandlers(r, sub, application)
|
||||
}),
|
||||
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
|
||||
return ports.HandlerFromMux(ports.NewHttpServer(application), router)
|
||||
}),
|
||||
server.OnShutdown(
|
||||
server.Stop("events"), // 1. stop consuming first
|
||||
server.Stop("api"), // 2. then drain HTTP
|
||||
server.StopFunc(cleanup), // 3. then close clients
|
||||
),
|
||||
).Run(ctx)
|
||||
```
|
||||
|
||||
## Update `docker-compose.yml`
|
||||
|
||||
Add `AMQP_URI` to the service environment (no separate container needed — all transports run in one process):
|
||||
|
||||
```yaml
|
||||
{{service}}:
|
||||
environment:
|
||||
AMQP_URI: amqp://guest:guest@rabbitmq:5672/
|
||||
depends_on:
|
||||
- rabbitmq
|
||||
```
|
||||
@@ -0,0 +1,128 @@
|
||||
# Event Publisher Adapter Scaffold Template
|
||||
|
||||
Generate a Watermill publisher adapter that implements a domain/app-layer interface. The adapter lives in `adapters/` and translates domain operations into published messages. The interface lives in `app/command/services.go`.
|
||||
|
||||
## Placeholders
|
||||
|
||||
- `{{Name}}` — PascalCase 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
|
||||
- `{{event}}` — PascalCase first event name (e.g., `TrainingScheduled`)
|
||||
- `{{topic}}` — Dot-notation topic (e.g., `training.scheduled`)
|
||||
|
||||
## File 1: `app/command/services.go`
|
||||
|
||||
If this file already exists, add the interface. Otherwise create it:
|
||||
|
||||
```go
|
||||
package command
|
||||
|
||||
import "context"
|
||||
|
||||
// {{Name}}EventPublisher defines events that can be emitted for {{name_lower}} operations.
|
||||
// Implemented by adapters (e.g., Watermill AMQP adapter).
|
||||
type {{Name}}EventPublisher interface {
|
||||
{{event}}(ctx context.Context) error
|
||||
// TODO: Add more event methods as needed
|
||||
// Example:
|
||||
// {{Name}}Cancelled(ctx context.Context, uuid string) error
|
||||
}
|
||||
```
|
||||
|
||||
## File 2: `adapters/{{name_snake}}_event_publisher.go`
|
||||
|
||||
```go
|
||||
package adapters
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/ThreeDotsLabs/watermill/message/router/middleware"
|
||||
)
|
||||
|
||||
type Watermill{{Name}}EventPublisher struct {
|
||||
pub message.Publisher
|
||||
}
|
||||
|
||||
func NewWatermill{{Name}}EventPublisher(pub message.Publisher) Watermill{{Name}}EventPublisher {
|
||||
return Watermill{{Name}}EventPublisher{pub: pub}
|
||||
}
|
||||
|
||||
// {{event}}Event is the wire format for the {{topic}} topic.
|
||||
type {{event}}Event struct {
|
||||
// TODO: Add event payload fields
|
||||
// Example:
|
||||
// UUID string `json:"uuid"`
|
||||
// Hour time.Time `json:"hour"`
|
||||
}
|
||||
|
||||
func (p Watermill{{Name}}EventPublisher) {{event}}(ctx context.Context) error {
|
||||
event := {{event}}Event{
|
||||
// TODO: Map domain data to event fields
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := message.NewMessage(watermill.NewUUID(), payload)
|
||||
middleware.SetCorrelationID(watermill.NewUUID(), msg)
|
||||
|
||||
return p.pub.Publish("{{topic}}", msg)
|
||||
}
|
||||
```
|
||||
|
||||
## Update `service/application.go`
|
||||
|
||||
Wire the publisher adapter in the composition root:
|
||||
|
||||
```go
|
||||
func NewApplication(ctx context.Context) (app.Application, func()) {
|
||||
// ... existing clients ...
|
||||
|
||||
publisher, closePub, err := client.NewWatermillPublisher()
|
||||
if err != nil { panic(err) }
|
||||
|
||||
eventPublisher := adapters.NewWatermill{{Name}}EventPublisher(publisher)
|
||||
|
||||
return newApplication(ctx, eventPublisher),
|
||||
func() {
|
||||
// ... existing cleanup ...
|
||||
_ = closePub()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update the private `newApplication` to accept the publisher interface:
|
||||
|
||||
```go
|
||||
func newApplication(
|
||||
ctx context.Context,
|
||||
eventPublisher command.{{Name}}EventPublisher,
|
||||
// ... existing deps ...
|
||||
) app.Application {
|
||||
// ... pass eventPublisher to command handlers that need it
|
||||
}
|
||||
```
|
||||
|
||||
## Update command handler
|
||||
|
||||
Inject the publisher into the command handler that triggers the event:
|
||||
|
||||
```go
|
||||
type {{name}}Handler struct {
|
||||
{{name_lower}}Repo {{name_lower}}.Repository
|
||||
eventPublisher command.{{Name}}EventPublisher
|
||||
}
|
||||
|
||||
func (h {{name}}Handler) Handle(ctx context.Context, cmd {{command}}) error {
|
||||
// ... domain logic ...
|
||||
return h.eventPublisher.{{event}}(ctx)
|
||||
}
|
||||
```
|
||||
@@ -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,211 @@
|
||||
# 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)
|
||||
|
||||
// Save{{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.
|
||||
Save{{Name}}(ctx context.Context, uuid string,
|
||||
updateFn func(t *{{Name}}) (*{{Name}}, error)) error
|
||||
|
||||
// TODO: Add other methods as needed. Examples:
|
||||
// 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,258 @@
|
||||
# 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. `main.go`
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"{{module_common}}/logs"
|
||||
"{{module_common}}/server"
|
||||
"{{module}}/ports"
|
||||
"{{module}}/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logs.Init()
|
||||
ctx := context.Background()
|
||||
|
||||
app := service.NewApplication(ctx)
|
||||
|
||||
server.New(
|
||||
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
|
||||
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
|
||||
}),
|
||||
server.OnShutdown(
|
||||
server.Stop("api"),
|
||||
),
|
||||
).Run(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
### 9. `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
|
||||
}
|
||||
```
|
||||
|
||||
### 10. `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. Ensure unified server exists: `/3dl scaffold unified_server`
|
||||
2. Add your first command with `/3dl scaffold command <ActionName>`
|
||||
3. Add your first query with `/3dl scaffold query <QueryName>`
|
||||
4. Wire them in `service/application.go`
|
||||
5. Add HTTP/gRPC handlers in `ports/`
|
||||
6. When adding Watermill: `/3dl scaffold watermill_router` then `/3dl scaffold event_handler <Name>`
|
||||
@@ -0,0 +1,297 @@
|
||||
# Unified Server Scaffold Template
|
||||
|
||||
Generate the core unified server infrastructure in `internal/common/server/`. This replaces the standalone `RunHTTPServer` / `RunGRPCServer` functions with a composable `server.New(...).Run(ctx)` pattern that supports multiple transports with explicit shutdown ordering.
|
||||
|
||||
Created once per project. Individual transports (`WithWatermillRouter`) can be added later.
|
||||
|
||||
## Placeholders
|
||||
|
||||
- `{{module_common}}` — Go module path to `internal/common` (e.g., `github.com/example/myproject/internal/common`)
|
||||
|
||||
## File 1: `internal/common/server/server.go`
|
||||
|
||||
```go
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
components map[string]component
|
||||
startOrder []string
|
||||
shutdownSteps []ShutdownStep
|
||||
}
|
||||
|
||||
type component struct {
|
||||
name string
|
||||
start func(ctx context.Context) error
|
||||
stop func(ctx context.Context) error
|
||||
}
|
||||
|
||||
type Option func(*Server)
|
||||
|
||||
func New(opts ...Option) *Server {
|
||||
s := &Server{
|
||||
components: make(map[string]component),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) addComponent(name string, c component) {
|
||||
if _, exists := s.components[name]; exists {
|
||||
panic("duplicate component name: " + name)
|
||||
}
|
||||
s.components[name] = c
|
||||
s.startOrder = append(s.startOrder, name)
|
||||
}
|
||||
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
errCh := make(chan error, len(s.components))
|
||||
for _, name := range s.startOrder {
|
||||
c := s.components[name]
|
||||
go func(c component) {
|
||||
logrus.WithField("component", c.name).Info("Starting")
|
||||
if err := c.start(ctx); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logrus.Info("Shutdown signal received")
|
||||
case err := <-errCh:
|
||||
logrus.WithError(err).Error("Component failed, initiating shutdown")
|
||||
}
|
||||
|
||||
s.executeShutdown()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) executeShutdown() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stopped := map[string]bool{}
|
||||
|
||||
for _, step := range s.shutdownSteps {
|
||||
if step.fn != nil {
|
||||
logrus.Info("Running shutdown func")
|
||||
if err := step.fn(shutdownCtx); err != nil {
|
||||
logrus.WithError(err).Error("Shutdown func failed")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, name := range step.componentNames {
|
||||
c, ok := s.components[name]
|
||||
if !ok {
|
||||
logrus.WithField("component", name).Warn("Unknown component in OnShutdown")
|
||||
continue
|
||||
}
|
||||
stopped[name] = true
|
||||
wg.Add(1)
|
||||
go func(c component) {
|
||||
defer wg.Done()
|
||||
logrus.WithField("component", c.name).Info("Stopping")
|
||||
if err := c.stop(shutdownCtx); err != nil {
|
||||
logrus.WithError(err).WithField("component", c.name).Error("Stop failed")
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Safety net: stop any components not mentioned in OnShutdown
|
||||
var wg sync.WaitGroup
|
||||
for name, c := range s.components {
|
||||
if stopped[name] {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(c component) {
|
||||
defer wg.Done()
|
||||
logrus.WithField("component", c.name).Warn("Stopping (not in OnShutdown — add it)")
|
||||
if err := c.stop(shutdownCtx); err != nil {
|
||||
logrus.WithError(err).WithField("component", c.name).Error("Stop failed")
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
```
|
||||
|
||||
## File 2: `internal/common/server/shutdown.go`
|
||||
|
||||
```go
|
||||
package server
|
||||
|
||||
import "context"
|
||||
|
||||
// ShutdownStep is one step in the shutdown sequence.
|
||||
type ShutdownStep struct {
|
||||
componentNames []string
|
||||
fn func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Stop creates a shutdown step that stops named components.
|
||||
// Multiple names in one call = parallel shutdown within the step.
|
||||
func Stop(names ...string) ShutdownStep {
|
||||
return ShutdownStep{componentNames: names}
|
||||
}
|
||||
|
||||
// StopFunc creates a shutdown step that runs an arbitrary cleanup function.
|
||||
func StopFunc(fn func()) ShutdownStep {
|
||||
return ShutdownStep{
|
||||
fn: func(ctx context.Context) error {
|
||||
fn()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// StopFuncWithErr creates a shutdown step with error return.
|
||||
func StopFuncWithErr(fn func(ctx context.Context) error) ShutdownStep {
|
||||
return ShutdownStep{fn: fn}
|
||||
}
|
||||
|
||||
// OnShutdown declares the shutdown sequence.
|
||||
// Steps execute top-to-bottom. Each step completes before the next starts.
|
||||
// Components not mentioned are stopped last with a warning.
|
||||
func OnShutdown(steps ...ShutdownStep) Option {
|
||||
return func(s *Server) {
|
||||
s.shutdownSteps = steps
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File 3: `internal/common/server/http.go` (replace existing)
|
||||
|
||||
```go
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"{{module_common}}/auth"
|
||||
"{{module_common}}/logs"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func WithHTTPHandler(name string, createHandler func(chi.Router) http.Handler) Option {
|
||||
return func(s *Server) {
|
||||
addr := ":" + os.Getenv("PORT")
|
||||
srv := &http.Server{Addr: addr}
|
||||
|
||||
s.addComponent(name, component{
|
||||
name: name,
|
||||
start: func(ctx context.Context) error {
|
||||
apiRouter := chi.NewRouter()
|
||||
setMiddlewares(apiRouter)
|
||||
rootRouter := chi.NewRouter()
|
||||
rootRouter.Mount("/api", createHandler(apiRouter))
|
||||
srv.Handler = rootRouter
|
||||
|
||||
logrus.WithField("addr", addr).Info("Starting HTTP server")
|
||||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
stop: func(ctx context.Context) error {
|
||||
return srv.Shutdown(ctx)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// setMiddlewares, addAuthMiddleware, addCorsMiddleware — same as existing
|
||||
```
|
||||
|
||||
## File 4: `internal/common/server/grpc.go` (replace existing)
|
||||
|
||||
```go
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"{{module_common}}/logs"
|
||||
grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
|
||||
grpc_logrus "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus"
|
||||
grpc_ctxtags "github.com/grpc-ecosystem/go-grpc-middleware/tags"
|
||||
"github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func WithGRPCServer(name string, registerServer func(*grpc.Server)) Option {
|
||||
return func(s *Server) {
|
||||
logrusEntry := logrus.NewEntry(logrus.StandardLogger())
|
||||
|
||||
grpcSrv := grpc.NewServer(
|
||||
grpc_middleware.WithUnaryServerChain(
|
||||
grpc_ctxtags.UnaryServerInterceptor(grpc_ctxtags.WithFieldExtractor(grpc_ctxtags.CodeGenRequestFieldExtractor)),
|
||||
grpc_logrus.UnaryServerInterceptor(logrusEntry),
|
||||
),
|
||||
grpc_middleware.WithStreamServerChain(
|
||||
grpc_ctxtags.StreamServerInterceptor(grpc_ctxtags.WithFieldExtractor(grpc_ctxtags.CodeGenRequestFieldExtractor)),
|
||||
grpc_logrus.StreamServerInterceptor(logrusEntry),
|
||||
),
|
||||
)
|
||||
registerServer(grpcSrv)
|
||||
|
||||
port := os.Getenv("GRPC_PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
addr := ":" + port
|
||||
|
||||
s.addComponent(name, component{
|
||||
name: name,
|
||||
start: func(ctx context.Context) error {
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.WithField("addr", addr).Info("Starting gRPC server")
|
||||
return grpcSrv.Serve(lis)
|
||||
},
|
||||
stop: func(ctx context.Context) error {
|
||||
grpcSrv.GracefulStop()
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Post-Creation Instructions
|
||||
|
||||
After creating the unified server:
|
||||
|
||||
1. Remove or replace the old `RunHTTPServer` / `RunGRPCServer` standalone functions
|
||||
2. Update all `main.go` files to use `server.New(...).Run(ctx)` with `OnShutdown`
|
||||
3. Add `/threedotslabs scaffold watermill_router` to add Watermill support
|
||||
4. Every component MUST appear in `OnShutdown` — the safety net logs warnings for forgotten ones
|
||||
@@ -0,0 +1,116 @@
|
||||
# Watermill Router Option + Publisher Client Scaffold Template
|
||||
|
||||
Generate the `WithWatermillRouter` server option in `internal/common/server/` and the publisher client factory in `internal/common/client/`. Requires the unified server scaffold (`/threedotslabs scaffold unified_server`) to be in place first.
|
||||
|
||||
## Placeholders
|
||||
|
||||
- `{{module_common}}` — Go module path to `internal/common` (e.g., `github.com/example/myproject/internal/common`)
|
||||
|
||||
## File 1: `internal/common/server/watermill.go`
|
||||
|
||||
```go
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill-amqp/v3/pkg/amqp"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
wmMiddleware "github.com/ThreeDotsLabs/watermill/message/router/middleware"
|
||||
)
|
||||
|
||||
func WithWatermillRouter(
|
||||
name string,
|
||||
configure func(*message.Router, message.Subscriber),
|
||||
) Option {
|
||||
return func(s *Server) {
|
||||
wmLogger := watermill.NewStdLoggerWithOut(os.Stdout, true, false)
|
||||
|
||||
amqpURI := os.Getenv("AMQP_URI")
|
||||
if amqpURI == "" {
|
||||
amqpURI = "amqp://guest:guest@rabbitmq:5672/"
|
||||
}
|
||||
amqpConfig := amqp.NewDurableQueueConfig(amqpURI)
|
||||
|
||||
sub, err := amqp.NewSubscriber(amqpConfig, wmLogger)
|
||||
if err != nil {
|
||||
panic("cannot create watermill subscriber: " + err.Error())
|
||||
}
|
||||
|
||||
r, err := message.NewRouter(message.RouterConfig{}, wmLogger)
|
||||
if err != nil {
|
||||
panic("cannot create watermill router: " + err.Error())
|
||||
}
|
||||
|
||||
r.AddMiddleware(
|
||||
wmMiddleware.CorrelationID,
|
||||
wmMiddleware.Recoverer,
|
||||
wmMiddleware.Retry{MaxRetries: 3}.Middleware,
|
||||
)
|
||||
|
||||
configure(r, sub)
|
||||
|
||||
s.addComponent(name, component{
|
||||
name: name,
|
||||
start: func(ctx context.Context) error {
|
||||
return r.Run(ctx)
|
||||
},
|
||||
stop: func(ctx context.Context) error {
|
||||
return r.Close()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File 2: `internal/common/client/watermill.go`
|
||||
|
||||
```go
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill-amqp/v3/pkg/amqp"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func NewWatermillPublisher() (pub message.Publisher, close func() error, err error) {
|
||||
amqpURI := os.Getenv("AMQP_URI")
|
||||
if amqpURI == "" {
|
||||
return nil, func() error { return nil }, errors.New("empty env AMQP_URI")
|
||||
}
|
||||
|
||||
logger := watermill.NewStdLoggerWithOut(os.Stdout, true, false)
|
||||
config := amqp.NewDurableQueueConfig(amqpURI)
|
||||
|
||||
publisher, err := amqp.NewPublisher(config, logger)
|
||||
if err != nil {
|
||||
return nil, func() error { return nil }, errors.Wrap(err, "cannot create watermill publisher")
|
||||
}
|
||||
|
||||
return publisher, publisher.Close, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Post-Creation Instructions
|
||||
|
||||
After creating the Watermill option and publisher:
|
||||
|
||||
1. Add `github.com/ThreeDotsLabs/watermill` and `github.com/ThreeDotsLabs/watermill-amqp/v3` to `go.mod`
|
||||
2. Add `AMQP_URI` to `.env`, `.test.env`, and `docker-compose.yml`
|
||||
3. Add a RabbitMQ service to `docker-compose.yml`:
|
||||
```yaml
|
||||
rabbitmq:
|
||||
image: rabbitmq:3-management
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
```
|
||||
4. Use `/3dl scaffold event_handler <Name>` to create event handlers in a service
|
||||
5. Use `/3dl scaffold event_publisher <Name>` to create a publisher adapter
|
||||
6. Add `server.WithWatermillRouter("events", ...)` and include `"events"` in `OnShutdown`
|
||||
@@ -0,0 +1,187 @@
|
||||
# tea — Claude Code plugin for the Gitea CLI
|
||||
|
||||
A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforces a hard rule: every `tea` command runs under the login **the operator chose**, never one Claude picked.
|
||||
|
||||
## What it ships
|
||||
|
||||
| Piece | What it does |
|
||||
|---|---|
|
||||
| `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project |
|
||||
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
|
||||
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment, close, evict |
|
||||
| `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand |
|
||||
| `tea-runner` agent | Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
|
||||
| `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation |
|
||||
|
||||
## The layering
|
||||
|
||||
An issue is a unit of work first and a Gitea row second. That is two layers,
|
||||
and knowledge flows one way:
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||
▲ offline — no tracker, no network, stdlib only
|
||||
│ imports
|
||||
skills/sync BRIDGE md <-> Gitea issue JSON, then over the wire
|
||||
▲
|
||||
│ calls
|
||||
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
||||
```
|
||||
|
||||
Delete `skills/sync` and the issue domain keeps working. Work that lives only
|
||||
on your machine is first-class, not a draft waiting to be uploaded. That is the
|
||||
point of the split: you can plan, write, and validate without a tracker, and
|
||||
publish only what you choose to.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Claude Code** — CLI, desktop app, or IDE extension
|
||||
- **Python 3** — required by the `tea-guard` hook (`python3` must be on `$PATH`)
|
||||
- **`tea`** — Gitea's official CLI. Install with `brew install tea` (macOS) or from [gitea.com/gitea/tea/releases](https://gitea.com/gitea/tea/releases)
|
||||
- At least one login configured: `tea logins add` (interactive — run it in a terminal, not via Claude)
|
||||
|
||||
## Installation
|
||||
|
||||
This is a Claude Code plugin — install it through the plugin marketplace, not by hand-editing `settings.json`.
|
||||
|
||||
1. Register the marketplace this plugin ships in:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.noodles.cam/claude-skills/marketplace.git
|
||||
```
|
||||
|
||||
Already have a local clone? Point at the directory instead:
|
||||
|
||||
```
|
||||
/plugin marketplace add /path/to/marketplace
|
||||
```
|
||||
|
||||
2. Install the plugin:
|
||||
|
||||
```
|
||||
/plugin install tea@claude-skills
|
||||
```
|
||||
|
||||
The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-guard` hook load immediately. Use `/plugin` to enable, disable, or update it later.
|
||||
|
||||
> The marketplace registration is written to `extraKnownMarketplaces` and the plugin to `enabledPlugins` in your settings automatically — you don't edit those by hand. There is **no** top-level `"plugins"` settings key; if you've added one from older instructions, remove it.
|
||||
|
||||
## First use
|
||||
|
||||
Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to the project root's `.claude/settings.local.json` and takes effect immediately — no restart needed.
|
||||
|
||||
Once per *project*, not once per checkout: a `git worktree` shares its main checkout's pin. Both the hook and the scripts find it from inside a worktree, so don't run `/tea:auth` there — it would leave a second pin in a directory that disappears with the branch.
|
||||
|
||||
```
|
||||
/tea:auth
|
||||
```
|
||||
|
||||
After that, just ask Claude to do something with issues or Gitea — it loads the
|
||||
right skill automatically. `/tea:auth` is only needed for the tracker side;
|
||||
`/tea:issue` works without any login at all.
|
||||
|
||||
## How the login guard works
|
||||
|
||||
Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it. The hook and the scripts look it up the same way — one search order, in `skills/auth/scripts/pin.py`.
|
||||
|
||||
Claude is **blocked** from:
|
||||
- running `tea` without `--login` at all
|
||||
- naming a login itself (e.g. `--login myaccount`)
|
||||
- using any variable other than `$GITEA_LOGIN`
|
||||
|
||||
This prevents silent fallback to the machine's default login (often a personal account) when working in a project that belongs to a different identity.
|
||||
|
||||
`tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data.
|
||||
|
||||
## The tea-runner agent
|
||||
|
||||
The skills carry meaning; the scripts carry work. `tea-runner` is a subagent on
|
||||
Haiku that does the second half in its own context and hands back a receipt —
|
||||
what ran, what it touched, what failed, verbatim.
|
||||
|
||||
Delegate a **batch**: pull a milestone and rebuild the index, push the three
|
||||
issues you just wrote, bootstrap the label set, post a comment from a file you
|
||||
prepared. Spawning it for a single `pull.py 42` costs more than running the
|
||||
command yourself; the saving is in the loop, the retry, and reading somebody
|
||||
else's stderr.
|
||||
|
||||
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no closing or
|
||||
retitling, no raw `tea`, no pushing beyond the set it was handed. A missing
|
||||
type, a failed validation, an unpushed dependency come back as a question, not
|
||||
as a guess. The `tea-guard` hook applies to it exactly as it does to the main
|
||||
session — the pinned login is enforced on every call it makes.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
.claude-plugin/
|
||||
plugin.json plugin manifest
|
||||
(the marketplace catalog lives one level up, in
|
||||
the repo root's .claude-plugin/marketplace.json)
|
||||
agents/
|
||||
tea-runner.md subagent (Haiku) that executes the scripts
|
||||
hooks/
|
||||
hooks.json registers the PreToolUse hooks
|
||||
tea-guard.sh the guard (Python 3, no deps)
|
||||
agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it
|
||||
skills/
|
||||
auth/ /tea:auth — the identity layer
|
||||
SKILL.md
|
||||
scripts/pin.py where the login pin is and how it is found —
|
||||
imported by _gitea.py AND by tea-guard.sh
|
||||
issue/ /tea:issue — the issue domain, offline
|
||||
SKILL.md
|
||||
references/format.md canonical issue format (identity, types, templates)
|
||||
scripts/ Python 3, stdlib only, no network:
|
||||
issue.py domain module: slug identity, parse/render,
|
||||
validation, taxonomy, dependency graph,
|
||||
body checkboxes
|
||||
issue_new.py create a local issue from its type template
|
||||
issue_check.py validate against the format
|
||||
issue_ac.py list the body's checkboxes; tick one
|
||||
issue_tree.py draw the dependency graph
|
||||
issue_evict.py drop closed issues the tracker also has
|
||||
issue_index.py rebuild tmp/issues/INDEX.md
|
||||
sync/ /tea:sync — the bridge to Gitea
|
||||
SKILL.md
|
||||
scripts/
|
||||
map.py md <-> Gitea JSON, pure functions, no I/O
|
||||
_gitea.py transport: login pin, tea api, pagination, filters
|
||||
pull.py Gitea -> tmp/issues/
|
||||
push.py tmp/issues/ -> Gitea, then drops the local file
|
||||
remote.py discovery listing to stdout
|
||||
comment.py post or edit a comment
|
||||
close.py the state field, both ways
|
||||
evict.py refresh state: from Gitea, then evict
|
||||
labels.py put the canonical label set into a repository
|
||||
use/ /tea:use — tea CLI reference (non-issue entities)
|
||||
SKILL.md
|
||||
references/tea/ command docs
|
||||
```
|
||||
|
||||
`AGENTS.md` carries the same layout with the reasoning behind it; if the two
|
||||
ever disagree, `AGENTS.md` is the one being worked from.
|
||||
|
||||
## Local issue store
|
||||
|
||||
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
|
||||
field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without
|
||||
a parser.
|
||||
|
||||
An `origin: local` file **is** the issue — the store, and the only copy.
|
||||
Anything with `origin: gitea` is a working copy of something the tracker
|
||||
already has, and it is deleted as soon as a push confirms the tracker is up to
|
||||
date:
|
||||
|
||||
- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers
|
||||
live in a `gitea:` field.
|
||||
- `origin: local` is a complete state. An issue that never leaves your machine
|
||||
is valid and finished — but it is not permanent: pushing ends it.
|
||||
- **A successful push deletes the local file** (`--update` too) and prints the
|
||||
number and URL it now lives at. Only after a confirmed response: a failed
|
||||
call leaves the file exactly where it was. Get it back with `pull.py <n>` —
|
||||
same slug, same `depends:`, even after a rename in Gitea.
|
||||
- Pulling overwrites the body: a fetch, not a merge. It is also how a pushed
|
||||
issue comes back.
|
||||
- Nothing tracks drift, and there is no second copy to drift. A file that is
|
||||
still here has not been pushed.
|
||||
@@ -136,8 +136,20 @@ class TestPayloadRoot(unittest.TestCase):
|
||||
self.assertFalse(os.path.basename(_gitea.PAYLOAD_ROOT).startswith("."))
|
||||
|
||||
def test_gitignore_covers_it(self):
|
||||
with open(os.path.join(REPO, ".gitignore")) as f:
|
||||
ignored = {line.strip() for line in f}
|
||||
"""The rule is `tmp/` is ignored, not which file says so: this plugin
|
||||
lives under `plugins/` in a marketplace repo, and git reads every
|
||||
.gitignore on the way up. So walk up the same way git does."""
|
||||
ignored = set()
|
||||
d = REPO
|
||||
while True:
|
||||
p = os.path.join(d, ".gitignore")
|
||||
if os.path.isfile(p):
|
||||
with open(p) as f:
|
||||
ignored |= {line.strip() for line in f}
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d or os.path.isdir(os.path.join(d, ".git")):
|
||||
break
|
||||
d = parent
|
||||
self.assertEqual(_gitea.PAYLOAD_PARTS[0], "tmp")
|
||||
self.assertIn("tmp/", ignored,
|
||||
"the payload directory is not covered by .gitignore")
|
||||
Reference in New Issue
Block a user