This commit is contained in:
naudachu
2026-03-06 21:38:51 +05:00
commit de4efe97bf
14 changed files with 2420 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
# Command Handler Scaffold Template
Generate a single command handler file following the 4-component pattern.
## Placeholders
- `{{Name}}` — PascalCase command name (e.g., `ScheduleTraining`)
- `{{name}}` — camelCase (e.g., `scheduleTraining`)
- `{{module}}` — Go module path from go.mod
- `{{entity}}` — Domain entity name, lowercase (e.g., `hour`)
- `{{Entity}}` — Domain entity name, PascalCase (e.g., `Hour`)
## File: `app/command/{{name_snake}}.go`
```go
package command
import (
"context"
"github.com/sirupsen/logrus"
"{{module}}/domain/{{entity}}"
"{{module_common}}/decorator"
)
// 1. Command struct — imperative verb + noun, plain data
type {{Name}} struct {
// TODO: Add command fields
// Example:
// UUID string
// Hour time.Time
}
// 2. Exported handler type alias
type {{Name}}Handler decorator.CommandHandler[{{Name}}]
// 3. Unexported concrete handler struct
type {{name}}Handler struct {
{{entity}}Repo {{entity}}.Repository
}
// 4. Constructor with nil-checks + decorator wrapping
func New{{Name}}Handler(
{{entity}}Repo {{entity}}.Repository,
logger *logrus.Entry,
metricsClient decorator.MetricsClient,
) {{Name}}Handler {
if {{entity}}Repo == nil {
panic("nil {{entity}}Repo")
}
if logger == nil {
panic("nil logger")
}
if metricsClient == nil {
panic("nil metricsClient")
}
return decorator.ApplyCommandDecorators[{{Name}}](
{{name}}Handler{{"{"}}{{entity}}Repo: {{entity}}Repo},
logger,
metricsClient,
)
}
// Handle — orchestrates domain logic, does NOT contain business rules
func (h {{name}}Handler) Handle(ctx context.Context, cmd {{Name}}) error {
// TODO: Implement command handling
//
// Typical patterns:
//
// Pattern A — Update via callback:
// return h.{{entity}}Repo.Update{{Entity}}(ctx, cmd.UUID, func(e *{{entity}}.{{Entity}}) (*{{entity}}.{{Entity}}, error) {
// if err := e.SomeDomainAction(); err != nil {
// return nil, err
// }
// return e, nil
// })
//
// Pattern B — Create new entity:
// entity, err := {{entity}}.New{{Entity}}(cmd.UUID, ...)
// if err != nil {
// return err
// }
// return h.{{entity}}Repo.Save(ctx, entity)
return nil
}
```
## Update `app/app.go`
After creating the handler, add it to the `Commands` struct:
```go
type Commands struct {
// ... existing handlers ...
{{Name}} command.{{Name}}Handler
}
```
## Update `service/application.go`
Wire the handler in the composition root:
```go
Commands: app.Commands{
// ... existing handlers ...
{{Name}}: command.New{{Name}}Handler(
{{entity}}Repository,
logger,
metricsClient,
),
},
```
+156
View File
@@ -0,0 +1,156 @@
# Domain Entity Scaffold Template
Generate a domain entity with factory constructor, value objects, and errors.
## Placeholders
- `{{Name}}` — PascalCase entity name (e.g., `Training`, `Hour`, `Order`)
- `{{name}}` — camelCase (e.g., `training`)
- `{{name_lower}}` — all lowercase package name (e.g., `training`)
- `{{name_snake}}` — snake_case (e.g., `training`)
## File: `domain/{{name_lower}}/{{name_snake}}.go`
```go
package {{name_lower}}
import (
"errors"
"time"
)
// {{Name}} is the aggregate root for the {{name_lower}} domain.
type {{Name}} struct {
uuid string
createdAt time.Time
// TODO: Add domain fields (all private)
// status Status // value object, not raw string
}
// New{{Name}} creates a new {{Name}} with validated invariants.
func New{{Name}}(uuid string) (*{{Name}}, error) {
if uuid == "" {
return nil, errors.New("empty {{name_lower}} uuid")
}
return &{{Name}}{
uuid: uuid,
createdAt: time.Now(),
}, nil
}
// Unmarshal{{Name}}FromDatabase reconstructs a {{Name}} from persistence.
// Bypasses validation — data was valid when stored.
func Unmarshal{{Name}}FromDatabase(
uuid string,
createdAt time.Time,
// TODO: Add all persisted fields
) *{{Name}} {
return &{{Name}}{
uuid: uuid,
createdAt: createdAt,
}
}
// Accessor methods — expose state without allowing mutation.
func (t {{Name}}) UUID() string {
return t.uuid
}
func (t {{Name}}) CreatedAt() time.Time {
return t.createdAt
}
// TODO: Add behavior methods using domain language.
// Examples:
//
// func (t *{{Name}}) Approve() error {
// if t.status != Pending {
// return ErrNotPending
// }
// t.status = Approved
// return nil
// }
//
// func (t *{{Name}}) Cancel() error { ... }
// func (t *{{Name}}) Submit(details string) error { ... }
```
## File: `domain/{{name_lower}}/errors.go`
```go
package {{name_lower}}
import "errors"
// Sentinel errors — simple, no context needed.
var (
ErrNotFound = errors.New("{{name_lower}} not found")
// TODO: Add domain-specific errors
// ErrAlreadyCanceled = errors.New("{{name_lower}} already canceled")
// ErrNotPending = errors.New("{{name_lower}} is not in pending state")
)
// Typed errors — carry context for logging/display.
// Example:
//
// type ForbiddenError struct {
// RequestingUserUUID string
// OwnerUUID string
// }
//
// func (e ForbiddenError) Error() string {
// return fmt.Sprintf("user %s cannot access {{name_lower}} owned by %s",
// e.RequestingUserUUID, e.OwnerUUID)
// }
```
## File: `domain/{{name_lower}}/status.go` (Optional Value Object)
```go
package {{name_lower}}
import "fmt"
// Status is a value object — cannot be constructed with arbitrary values.
type Status struct {
s string
}
var (
Pending = Status{"pending"}
Approved = Status{"approved"}
Canceled = Status{"canceled"}
)
func NewStatusFromString(s string) (Status, error) {
switch s {
case "pending":
return Pending, nil
case "approved":
return Approved, nil
case "canceled":
return Canceled, nil
default:
return Status{}, fmt.Errorf("unknown {{name_lower}} status: %s", s)
}
}
func (s Status) String() string {
return s.s
}
func (s Status) IsZero() bool {
return s == Status{}
}
```
## Post-Creation Checklist
- [ ] All struct fields are private (unexported)
- [ ] Factory constructor validates all invariants
- [ ] UnmarshalFromDatabase accepts all persisted fields
- [ ] Value objects are struct wrappers, not type aliases
- [ ] Behavior methods use domain language, not CRUD
- [ ] Errors are sentinel vars or typed structs
+124
View File
@@ -0,0 +1,124 @@
# Query Handler Scaffold Template
Generate a query handler file with a read model interface.
## Placeholders
- `{{Name}}` — PascalCase query name (e.g., `AvailableHours`)
- `{{name}}` — camelCase (e.g., `availableHours`)
- `{{name_snake}}` — snake_case (e.g., `available_hours`)
- `{{module}}` — Go module path from go.mod
- `{{Result}}` — Result type (e.g., `[]Date`, `*HourDetails`)
## File: `app/query/{{name_snake}}.go`
```go
package query
import (
"context"
"github.com/sirupsen/logrus"
"{{module_common}}/decorator"
)
// Read model — defines what data the query needs
// Implemented by adapters (repository or dedicated read store)
type {{Name}}ReadModel interface {
{{Name}}(ctx context.Context /* TODO: add query params */) ({{Result}}, error)
}
// 1. Query struct — noun phrase, plain data
type {{Name}} struct {
// TODO: Add query parameters
// Example:
// From time.Time
// To time.Time
}
// Result types — optimized for reading, may differ from domain entities
// type Date struct {
// Date time.Time
// Hours []Hour
// }
// 2. Exported handler type alias
type {{Name}}Handler decorator.QueryHandler[{{Name}}, {{Result}}]
// 3. Unexported concrete handler struct
type {{name}}Handler struct {
readModel {{Name}}ReadModel
}
// 4. Constructor with nil-checks + decorator wrapping
func New{{Name}}Handler(
readModel {{Name}}ReadModel,
logger *logrus.Entry,
metricsClient decorator.MetricsClient,
) {{Name}}Handler {
if readModel == nil {
panic("nil readModel")
}
if logger == nil {
panic("nil logger")
}
if metricsClient == nil {
panic("nil metricsClient")
}
return decorator.ApplyQueryDecorators[{{Name}}, {{Result}}](
{{name}}Handler{readModel: readModel},
logger,
metricsClient,
)
}
// Handle — delegates to read model, may add input validation
func (h {{name}}Handler) Handle(ctx context.Context, q {{Name}}) ({{Result}}, error) {
// TODO: Add input validation if needed
// Example:
// if q.From.After(q.To) {
// return nil, errors.NewIncorrectInputError("date-from-after-date-to", "date from is after date to")
// }
return h.readModel.{{Name}}(ctx /* TODO: pass query params */)
}
```
## Update `app/app.go`
Add to the `Queries` struct:
```go
type Queries struct {
// ... existing handlers ...
{{Name}} query.{{Name}}Handler
}
```
## Update `service/application.go`
Wire the handler. The read model is typically implemented by the same repository adapter or a dedicated read adapter:
```go
Queries: app.Queries{
// ... existing handlers ...
{{Name}}: query.New{{Name}}Handler(
{{entity}}Repository, // implements {{Name}}ReadModel
logger,
metricsClient,
),
},
```
## Implement ReadModel on Adapter
Add the read model method to your repository adapter:
```go
// In adapters/
func (r *Memory{{Entity}}Repository) {{Name}}(ctx context.Context /* params */) ({{Result}}, error) {
// TODO: Implement query against storage
}
```
+212
View File
@@ -0,0 +1,212 @@
# Repository Scaffold Template
Generate a repository interface in the domain package and a memory implementation in adapters.
## Placeholders
- `{{Name}}` — PascalCase entity name (e.g., `Training`)
- `{{name}}` — camelCase (e.g., `training`)
- `{{name_lower}}` — all lowercase package name (e.g., `training`)
- `{{name_snake}}` — snake_case (e.g., `training`)
- `{{module}}` — Go module path from go.mod
## File: `domain/{{name_lower}}/repository.go`
```go
package {{name_lower}}
import "context"
// Repository defines persistence operations for {{Name}}.
// Defined in domain — adapters implement it implicitly.
type Repository interface {
// Get{{Name}} retrieves a {{Name}} by its UUID.
Get{{Name}}(ctx context.Context, uuid string) (*{{Name}}, error)
// Update{{Name}} loads a {{Name}}, applies the update function within a
// transaction, and persists the result. The callback pattern ensures
// domain logic is separated from transaction management.
Update{{Name}}(ctx context.Context, uuid string,
updateFn func(t *{{Name}}) (*{{Name}}, error)) error
// TODO: Add other methods as needed. Examples:
// Save{{Name}}(ctx context.Context, t *{{Name}}) error
// Delete{{Name}}(ctx context.Context, uuid string) error
}
```
## File: `adapters/memory_{{name_snake}}_repository.go`
```go
package adapters
import (
"context"
"sync"
"{{module}}/domain/{{name_lower}}"
)
// Memory{{Name}}Repository is an in-memory implementation of {{name_lower}}.Repository.
// Useful for tests and local development.
type Memory{{Name}}Repository struct {
{{name}}s map[string]{{name_lower}}.{{Name}}
mu sync.RWMutex
}
func NewMemory{{Name}}Repository() *Memory{{Name}}Repository {
return &Memory{{Name}}Repository{
{{name}}s: make(map[string]{{name_lower}}.{{Name}}),
}
}
func (r *Memory{{Name}}Repository) Get{{Name}}(ctx context.Context, uuid string) (*{{name_lower}}.{{Name}}, error) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.{{name}}s[uuid]
if !ok {
return nil, {{name_lower}}.ErrNotFound
}
// Return a copy to prevent mutation of stored value
return &t, nil
}
func (r *Memory{{Name}}Repository) Update{{Name}}(
ctx context.Context,
uuid string,
updateFn func(t *{{name_lower}}.{{Name}}) (*{{name_lower}}.{{Name}}, error),
) error {
r.mu.Lock()
defer r.mu.Unlock()
current, ok := r.{{name}}s[uuid]
if !ok {
return {{name_lower}}.ErrNotFound
}
updated, err := updateFn(&current)
if err != nil {
return err
}
r.{{name}}s[uuid] = *updated
return nil
}
// Save{{Name}} stores a new {{Name}}. Used for initial creation.
func (r *Memory{{Name}}Repository) Save{{Name}}(ctx context.Context, t *{{name_lower}}.{{Name}}) error {
r.mu.Lock()
defer r.mu.Unlock()
r.{{name}}s[t.UUID()] = *t
return nil
}
```
## File: `adapters/memory_{{name_snake}}_repository_test.go`
```go
package adapters_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"{{module}}/adapters"
"{{module}}/domain/{{name_lower}}"
)
func TestMemory{{Name}}Repository_Get(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := adapters.NewMemory{{Name}}Repository()
// Setup: create and save a {{name_lower}}
entity, err := {{name_lower}}.New{{Name}}("test-uuid")
require.NoError(t, err)
err = repo.Save{{Name}}(ctx, entity)
require.NoError(t, err)
// Test: retrieve it
got, err := repo.Get{{Name}}(ctx, "test-uuid")
assert.NoError(t, err)
assert.Equal(t, "test-uuid", got.UUID())
}
func TestMemory{{Name}}Repository_GetNotFound(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := adapters.NewMemory{{Name}}Repository()
_, err := repo.Get{{Name}}(ctx, "nonexistent")
assert.ErrorIs(t, err, {{name_lower}}.ErrNotFound)
}
func TestMemory{{Name}}Repository_Update(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := adapters.NewMemory{{Name}}Repository()
// Setup
entity, err := {{name_lower}}.New{{Name}}("test-uuid")
require.NoError(t, err)
err = repo.Save{{Name}}(ctx, entity)
require.NoError(t, err)
// Test: update via callback
err = repo.Update{{Name}}(ctx, "test-uuid", func(t *{{name_lower}}.{{Name}}) (*{{name_lower}}.{{Name}}, error) {
// TODO: Apply domain action
return t, nil
})
assert.NoError(t, err)
}
```
## Extending to Production Adapters
When adding a real database adapter (e.g., PostgreSQL):
### 1. Create DB model struct
```go
// adapters/postgres_{{name_snake}}_repository.go
type postgres{{Name}} struct {
UUID string `db:"uuid"`
CreatedAt time.Time `db:"created_at"`
// ... map all persisted fields
}
```
### 2. Implement conversion methods
```go
func (r *Postgres{{Name}}Repository) to{{Name}}(m postgres{{Name}}) *{{name_lower}}.{{Name}} {
return {{name_lower}}.Unmarshal{{Name}}FromDatabase(m.UUID, m.CreatedAt)
}
```
### 3. Run shared tests against all implementations
```go
type TestRepository struct {
Name string
Repository {{name_lower}}.Repository
}
func createRepositories(t *testing.T) []TestRepository {
return []TestRepository{
{Name: "memory", Repository: adapters.NewMemory{{Name}}Repository()},
{Name: "postgres", Repository: newPostgresRepository(t)},
}
}
```
+223
View File
@@ -0,0 +1,223 @@
# Service Scaffold Template
Generate a complete service skeleton with all standard directories and stub files.
## Placeholders
- `{{Name}}` — PascalCase service/aggregate name (e.g., `Training`)
- `{{name}}` — camelCase (e.g., `training`)
- `{{name_snake}}` — snake_case (e.g., `training`)
- `{{name_lower}}` — all lowercase (e.g., `training`)
- `{{module}}` — Go module path from go.mod
## Files to Create
### 1. `domain/{{name_lower}}/{{name_snake}}.go`
```go
package {{name_lower}}
import (
"errors"
"time"
)
type {{Name}} struct {
uuid string
createdAt time.Time
}
func New{{Name}}(uuid string) (*{{Name}}, error) {
if uuid == "" {
return nil, errors.New("empty {{name_lower}} uuid")
}
return &{{Name}}{
uuid: uuid,
createdAt: time.Now(),
}, nil
}
func Unmarshal{{Name}}FromDatabase(uuid string, createdAt time.Time) *{{Name}} {
return &{{Name}}{
uuid: uuid,
createdAt: createdAt,
}
}
func (t {{Name}}) UUID() string {
return t.uuid
}
func (t {{Name}}) CreatedAt() time.Time {
return t.createdAt
}
```
### 2. `domain/{{name_lower}}/repository.go`
```go
package {{name_lower}}
import "context"
type Repository interface {
Get{{Name}}(ctx context.Context, uuid string) (*{{Name}}, error)
Update{{Name}}(ctx context.Context, uuid string,
updateFn func(t *{{Name}}) (*{{Name}}, error)) error
}
```
### 3. `domain/{{name_lower}}/errors.go`
```go
package {{name_lower}}
import "errors"
var (
ErrNotFound = errors.New("{{name_lower}} not found")
)
```
### 4. `app/app.go`
```go
package app
import (
"{{module}}/app/command"
"{{module}}/app/query"
)
type Application struct {
Commands Commands
Queries Queries
}
type Commands struct {
// Add command handlers here, e.g.:
// Create{{Name}} command.Create{{Name}}Handler
}
type Queries struct {
// Add query handlers here, e.g.:
// {{Name}}ByUUID query.{{Name}}ByUUIDHandler
}
```
### 5. `app/command/.gitkeep`
Create empty directory placeholder.
### 6. `app/query/.gitkeep`
Create empty directory placeholder.
### 7. `ports/http.go`
```go
package ports
import (
"{{module}}/app"
)
type HttpServer struct {
app app.Application
}
func NewHttpServer(application app.Application) HttpServer {
return HttpServer{app: application}
}
```
### 8. `adapters/memory_{{name_snake}}_repository.go`
```go
package adapters
import (
"context"
"sync"
"{{module}}/domain/{{name_lower}}"
)
type Memory{{Name}}Repository struct {
{{name_lower}}s map[string]{{name_lower}}.{{Name}}
mu sync.RWMutex
}
func NewMemory{{Name}}Repository() *Memory{{Name}}Repository {
return &Memory{{Name}}Repository{
{{name_lower}}s: make(map[string]{{name_lower}}.{{Name}}),
}
}
func (r *Memory{{Name}}Repository) Get{{Name}}(ctx context.Context, uuid string) (*{{name_lower}}.{{Name}}, error) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.{{name_lower}}s[uuid]
if !ok {
return nil, {{name_lower}}.ErrNotFound
}
return &t, nil
}
func (r *Memory{{Name}}Repository) Update{{Name}}(
ctx context.Context,
uuid string,
updateFn func(t *{{name_lower}}.{{Name}}) (*{{name_lower}}.{{Name}}, error),
) error {
r.mu.Lock()
defer r.mu.Unlock()
current, ok := r.{{name_lower}}s[uuid]
if !ok {
return {{name_lower}}.ErrNotFound
}
updated, err := updateFn(&current)
if err != nil {
return err
}
r.{{name_lower}}s[uuid] = *updated
return nil
}
```
### 9. `service/application.go`
```go
package service
import (
"context"
"{{module}}/adapters"
"{{module}}/app"
)
func NewApplication(ctx context.Context) app.Application {
{{name_lower}}Repository := adapters.NewMemory{{Name}}Repository()
_ = {{name_lower}}Repository // wire into handlers
return app.Application{
Commands: app.Commands{},
Queries: app.Queries{},
}
}
```
## Post-Creation Instructions
After creating the service skeleton:
1. Add your first command with `/threedots scaffold command <ActionName>`
2. Add your first query with `/threedots scaffold query <QueryName>`
3. Wire them in `service/application.go`
4. Add HTTP/gRPC handlers in `ports/`