refactor: turn the repo into a two-plugin marketplace

tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.

The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.

tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.

test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-11 00:25:28 +05:00
parent 23f78beafb
commit 83f73c5cea
69 changed files with 4187 additions and 168 deletions
@@ -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)
}
```
+124
View File
@@ -0,0 +1,124 @@
# Query Handler Scaffold Template
Generate a query handler file with a read model interface.
## Placeholders
- `{{Name}}` — PascalCase query name (e.g., `AvailableHours`)
- `{{name}}` — camelCase (e.g., `availableHours`)
- `{{name_snake}}` — snake_case (e.g., `available_hours`)
- `{{module}}` — Go module path from go.mod
- `{{Result}}` — Result type (e.g., `[]Date`, `*HourDetails`)
## File: `app/query/{{name_snake}}.go`
```go
package query
import (
"context"
"github.com/sirupsen/logrus"
"{{module_common}}/decorator"
)
// Read model — defines what data the query needs
// Implemented by adapters (repository or dedicated read store)
type {{Name}}ReadModel interface {
{{Name}}(ctx context.Context /* TODO: add query params */) ({{Result}}, error)
}
// 1. Query struct — noun phrase, plain data
type {{Name}} struct {
// TODO: Add query parameters
// Example:
// From time.Time
// To time.Time
}
// Result types — optimized for reading, may differ from domain entities
// type Date struct {
// Date time.Time
// Hours []Hour
// }
// 2. Exported handler type alias
type {{Name}}Handler decorator.QueryHandler[{{Name}}, {{Result}}]
// 3. Unexported concrete handler struct
type {{name}}Handler struct {
readModel {{Name}}ReadModel
}
// 4. Constructor with nil-checks + decorator wrapping
func New{{Name}}Handler(
readModel {{Name}}ReadModel,
logger *logrus.Entry,
metricsClient decorator.MetricsClient,
) {{Name}}Handler {
if readModel == nil {
panic("nil readModel")
}
if logger == nil {
panic("nil logger")
}
if metricsClient == nil {
panic("nil metricsClient")
}
return decorator.ApplyQueryDecorators[{{Name}}, {{Result}}](
{{name}}Handler{readModel: readModel},
logger,
metricsClient,
)
}
// Handle — delegates to read model, may add input validation
func (h {{name}}Handler) Handle(ctx context.Context, q {{Name}}) ({{Result}}, error) {
// TODO: Add input validation if needed
// Example:
// if q.From.After(q.To) {
// return nil, errors.NewIncorrectInputError("date-from-after-date-to", "date from is after date to")
// }
return h.readModel.{{Name}}(ctx /* TODO: pass query params */)
}
```
## Update `app/app.go`
Add to the `Queries` struct:
```go
type Queries struct {
// ... existing handlers ...
{{Name}} query.{{Name}}Handler
}
```
## Update `service/application.go`
Wire the handler. The read model is typically implemented by the same repository adapter or a dedicated read adapter:
```go
Queries: app.Queries{
// ... existing handlers ...
{{Name}}: query.New{{Name}}Handler(
{{entity}}Repository, // implements {{Name}}ReadModel
logger,
metricsClient,
),
},
```
## Implement ReadModel on Adapter
Add the read model method to your repository adapter:
```go
// In adapters/
func (r *Memory{{Entity}}Repository) {{Name}}(ctx context.Context /* params */) ({{Result}}, error) {
// TODO: Implement query against storage
}
```
+211
View File
@@ -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(&current)
if err != nil {
return err
}
r.{{name}}s[uuid] = *updated
return nil
}
// Save{{Name}} stores a new {{Name}}. Used for initial creation.
func (r *Memory{{Name}}Repository) Save{{Name}}(ctx context.Context, t *{{name_lower}}.{{Name}}) error {
r.mu.Lock()
defer r.mu.Unlock()
r.{{name}}s[t.UUID()] = *t
return nil
}
```
## File: `adapters/memory_{{name_snake}}_repository_test.go`
```go
package adapters_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"{{module}}/adapters"
"{{module}}/domain/{{name_lower}}"
)
func TestMemory{{Name}}Repository_Get(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := adapters.NewMemory{{Name}}Repository()
// Setup: create and save a {{name_lower}}
entity, err := {{name_lower}}.New{{Name}}("test-uuid")
require.NoError(t, err)
err = repo.Save{{Name}}(ctx, entity)
require.NoError(t, err)
// Test: retrieve it
got, err := repo.Get{{Name}}(ctx, "test-uuid")
assert.NoError(t, err)
assert.Equal(t, "test-uuid", got.UUID())
}
func TestMemory{{Name}}Repository_GetNotFound(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := adapters.NewMemory{{Name}}Repository()
_, err := repo.Get{{Name}}(ctx, "nonexistent")
assert.ErrorIs(t, err, {{name_lower}}.ErrNotFound)
}
func TestMemory{{Name}}Repository_Update(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := adapters.NewMemory{{Name}}Repository()
// Setup
entity, err := {{name_lower}}.New{{Name}}("test-uuid")
require.NoError(t, err)
err = repo.Save{{Name}}(ctx, entity)
require.NoError(t, err)
// Test: update via callback
err = repo.Update{{Name}}(ctx, "test-uuid", func(t *{{name_lower}}.{{Name}}) (*{{name_lower}}.{{Name}}, error) {
// TODO: Apply domain action
return t, nil
})
assert.NoError(t, err)
}
```
## Extending to Production Adapters
When adding a real database adapter (e.g., PostgreSQL):
### 1. Create DB model struct
```go
// adapters/postgres_{{name_snake}}_repository.go
type postgres{{Name}} struct {
UUID string `db:"uuid"`
CreatedAt time.Time `db:"created_at"`
// ... map all persisted fields
}
```
### 2. Implement conversion methods
```go
func (r *Postgres{{Name}}Repository) to{{Name}}(m postgres{{Name}}) *{{name_lower}}.{{Name}} {
return {{name_lower}}.Unmarshal{{Name}}FromDatabase(m.UUID, m.CreatedAt)
}
```
### 3. Run shared tests against all implementations
```go
type TestRepository struct {
Name string
Repository {{name_lower}}.Repository
}
func createRepositories(t *testing.T) []TestRepository {
return []TestRepository{
{Name: "memory", Repository: adapters.NewMemory{{Name}}Repository()},
{Name: "postgres", Repository: newPostgresRepository(t)},
}
}
```
@@ -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(&current)
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`