@nakedev/go-scaffold 0.3.3 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +288 -50
- package/dist/commands/auth.js +53 -22
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +32 -2
- package/dist/commands/generate.js +25 -2
- package/dist/commands/method.js +22 -7
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +3 -3
- package/dist/commands/rbac.js +3 -3
- package/dist/commands/undo.js +5 -0
- package/dist/commands/worker.js +1 -1
- package/dist/index.js +186 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +43 -2
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +31 -1
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +37 -1
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/auth-patcher.js +115 -24
- package/dist/utils/config.js +147 -3
- package/dist/utils/main-patcher.js +29 -27
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/method-patcher.js +261 -81
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/observability-patcher.js +2 -2
- package/dist/utils/platform-patcher.js +29 -7
- package/dist/utils/rbac-patcher.js +97 -75
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
- package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
- package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
- package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
- package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
- package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
- package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
- package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
- package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
- package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
- package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
- package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
- package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
- package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
- package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
- package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
- package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
- package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/AGENTS.md.hbs +255 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +45 -17
- package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +38 -16
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +3 -3
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
|
@@ -14,7 +14,6 @@ type Config struct {
|
|
|
14
14
|
Port string
|
|
15
15
|
DBDSN string
|
|
16
16
|
LogLevel string
|
|
17
|
-
AutoMigrate bool
|
|
18
17
|
DBMaxOpenConns int
|
|
19
18
|
DBMaxIdleConns int
|
|
20
19
|
DBConnMaxLifetime time.Duration
|
|
@@ -56,13 +55,6 @@ func Load() Config {
|
|
|
56
55
|
panic(fmt.Sprintf("invalid APP_ENV %q — must be development or production", cfg.AppEnv))
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
// Defaults off in production, which is why it's set here rather than in
|
|
60
|
-
// the literal above — it needs a validated AppEnv first. AutoMigrate
|
|
61
|
-
// rewrites the schema at boot AND skips CheckMigrationVersion entirely
|
|
62
|
-
// (see main.go), so a deploy that merely forgets to set it used to get
|
|
63
|
-
// both. AUTO_MIGRATE=true still forces it on if you really mean it.
|
|
64
|
-
cfg.AutoMigrate = env("AUTO_MIGRATE", strconv.FormatBool(!cfg.IsProd())) == "true"
|
|
65
|
-
|
|
66
58
|
return cfg
|
|
67
59
|
}
|
|
68
60
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/http/httptest"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/gin-gonic/gin"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestCORS_AllowsOnlyConfiguredExactOrigins(t *testing.T) {
|
|
12
|
+
gin.SetMode(gin.TestMode)
|
|
13
|
+
tests := []struct {
|
|
14
|
+
name string
|
|
15
|
+
origin string
|
|
16
|
+
wantAllowOrigin string
|
|
17
|
+
wantCredentials bool
|
|
18
|
+
}{
|
|
19
|
+
{name: "allowed exact origin", origin: "http://localhost:3000", wantAllowOrigin: "http://localhost:3000", wantCredentials: true},
|
|
20
|
+
{name: "different origin denied", origin: "https://evil.example", wantAllowOrigin: "", wantCredentials: false},
|
|
21
|
+
{name: "wildcard is not a substitute", origin: "*", wantAllowOrigin: "", wantCredentials: false},
|
|
22
|
+
}
|
|
23
|
+
for _, tt := range tests {
|
|
24
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
25
|
+
router := gin.New()
|
|
26
|
+
router.Use(CORS([]string{"http://localhost:3000"}))
|
|
27
|
+
router.GET("/refresh", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
|
28
|
+
request := httptest.NewRequest(http.MethodGet, "/refresh", nil)
|
|
29
|
+
request.Header.Set("Origin", tt.origin)
|
|
30
|
+
response := httptest.NewRecorder()
|
|
31
|
+
router.ServeHTTP(response, request)
|
|
32
|
+
if got := response.Header().Get("Access-Control-Allow-Origin"); got != tt.wantAllowOrigin {
|
|
33
|
+
t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, tt.wantAllowOrigin)
|
|
34
|
+
}
|
|
35
|
+
if got := response.Header().Get("Access-Control-Allow-Credentials") == "true"; got != tt.wantCredentials {
|
|
36
|
+
t.Fatalf("Allow-Credentials = %v, want %v", got, tt.wantCredentials)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -29,14 +29,24 @@ func Error(exposeDetail bool) gin.HandlerFunc {
|
|
|
29
29
|
if !errors.As(err.Err, &appErr) {
|
|
30
30
|
// Unexpected error: log the real thing, answer the client generically.
|
|
31
31
|
slog.Error("unhandled error", "error", err.Err, "request_id", c.GetString(RequestIDKey))
|
|
32
|
-
appErr = apperror.NewInternal()
|
|
32
|
+
appErr = apperror.NewInternal(err.Err)
|
|
33
33
|
if exposeDetail {
|
|
34
34
|
appErr.Details = err.Err.Error()
|
|
35
35
|
}
|
|
36
|
-
} else
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
appErr.
|
|
36
|
+
} else {
|
|
37
|
+
// Internal AppErrors carry a technical cause for one structured log;
|
|
38
|
+
// known domain errors (validation, not-found, etc.) stay quiet.
|
|
39
|
+
if cause := appErr.Unwrap(); cause != nil {
|
|
40
|
+
slog.Error("request failed", "error", cause, "request_id", c.GetString(RequestIDKey))
|
|
41
|
+
if exposeDetail {
|
|
42
|
+
appErr.Details = cause.Error()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if !exposeDetail {
|
|
46
|
+
// Known AppError (e.g. VALIDATION_ERROR): still strip Details in
|
|
47
|
+
// prod, so a direct API call doesn't get field-level hints.
|
|
48
|
+
appErr.Details = nil
|
|
49
|
+
}
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
appErr.RequestID = c.GetString(RequestIDKey)
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
do lives in `cmd/api/wiring.go`, in a `run() error` — the one function that sees
|
|
7
7
|
every module and hands them what they need.
|
|
8
8
|
|
|
9
|
-
Modules never import one another.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
Modules never import one another. Each generated domain owns its local
|
|
10
|
+
repository → service → handler composition in `composition.go`; `wiring.go`
|
|
11
|
+
supplies the DB and any cross-feature security dependencies. A domain that
|
|
12
|
+
needs another's behaviour declares a narrow interface for exactly what it
|
|
13
|
+
needs and receives the concrete service from `run()`; `.golangci.yml` has a
|
|
14
|
+
depguard rule per domain that fails the build if anyone shortcuts that.
|
|
13
15
|
|
|
14
16
|
Two things the split buys. `run()` returns an error instead of calling
|
|
15
17
|
`os.Exit`, so the deferred cleanup below it actually runs — `os.Exit` skips
|
|
@@ -63,7 +65,27 @@ no per-domain versioning; a domain that needs a real breaking change gets a
|
|
|
63
65
|
new domain package (or a new field on the existing DTO), not a duplicated
|
|
64
66
|
model pointed at the same table under a different URL.
|
|
65
67
|
|
|
66
|
-
## 4.
|
|
68
|
+
## 4. Application boundaries
|
|
69
|
+
|
|
70
|
+
**Decision:** New modules use the project defaults recorded in
|
|
71
|
+
`go-scaffold.config.json`: surface `{{defaultModuleSurface}}` and application
|
|
72
|
+
style `{{defaultApplicationStyle}}`. The `generate module` wizard presents
|
|
73
|
+
those values through Lean/CRUD/CQRS profiles, with Advanced for an explicit
|
|
74
|
+
custom combination. `--profile` or the supported axis flags can override the
|
|
75
|
+
defaults for one module.
|
|
76
|
+
**Rationale:** A feature with meaningful write invariants or a different read
|
|
77
|
+
shape benefits from separate `CommandHandler` and `QueryHandler` ports while
|
|
78
|
+
remaining inside this modular monolith. A small CRUD feature does not benefit
|
|
79
|
+
from empty command/query wrappers, so it keeps one service until its business
|
|
80
|
+
needs justify the split. CQRS here does not require a second database, broker,
|
|
81
|
+
or event-sourcing runtime.
|
|
82
|
+
|
|
83
|
+
When enabled, `commands.go` owns state-changing application use cases,
|
|
84
|
+
`queries.go` owns read use cases, and `composition.go` constructs both. The
|
|
85
|
+
HTTP handler is still an inbound adapter and `cmd/api/wiring.go` still only
|
|
86
|
+
selects infrastructure and registers the feature.
|
|
87
|
+
|
|
88
|
+
## 5. Response and Error Model
|
|
67
89
|
|
|
68
90
|
**Decision:** Central `apperror.AppError` (HTTP status + machine-readable
|
|
69
91
|
`code` + message), rendered once by `middleware.Error()`.
|
|
@@ -75,24 +97,24 @@ call site, no error-shape drift between domains.
|
|
|
75
97
|
- DB errors are classified once in `shared/dberr` (`IsDuplicate`,
|
|
76
98
|
`IsForeignKey`) and mapped to the right HTTP status per domain
|
|
77
99
|
|
|
78
|
-
##
|
|
100
|
+
## 6. Pagination
|
|
79
101
|
|
|
80
102
|
**Decision:** Shared `limit`/`offset` parsing and response envelope
|
|
81
103
|
(`shared/pagination`), used by every list endpoint.
|
|
82
104
|
**Rationale:** One implementation, one response shape (`{data, limit,
|
|
83
105
|
offset}`) — no per-domain reinvention.
|
|
84
106
|
|
|
85
|
-
##
|
|
107
|
+
## 7. Persistence
|
|
86
108
|
|
|
87
109
|
**Decision:** PostgreSQL + GORM, schema managed by
|
|
88
110
|
[golang-migrate](https://github.com/golang-migrate/migrate).
|
|
89
|
-
**Rationale:** `
|
|
90
|
-
|
|
91
|
-
|
|
111
|
+
**Rationale:** `APP_ENV=development` allows a convenience table bootstrap;
|
|
112
|
+
`APP_ENV=production` runs only after `migrate up` has applied the separate,
|
|
113
|
+
versioned, rollback-capable SQL migrations.
|
|
92
114
|
|
|
93
115
|
{{#if docker}}- `docker-compose.yml` provides the local Postgres instance
|
|
94
116
|
{{/if}}
|
|
95
|
-
##
|
|
117
|
+
## 8. IDs
|
|
96
118
|
|
|
97
119
|
**Decision:** UUID v7 for every entity, generated app-side
|
|
98
120
|
(`shared/id.New()`), not by a DB default.
|
|
@@ -101,18 +123,18 @@ splits versus random v4 under heavy writes — and the app has the ID before
|
|
|
101
123
|
insert, so it doesn't need `gen_random_uuid()`.
|
|
102
124
|
|
|
103
125
|
{{#if openapiDocs}}
|
|
104
|
-
##
|
|
126
|
+
## 9. API Documentation
|
|
105
127
|
|
|
106
128
|
**Decision:** Hand-written OpenAPI spec (`docs/openapi.yaml`), split by
|
|
107
|
-
domain module,
|
|
108
|
-
|
|
109
|
-
|
|
129
|
+
domain module, read from the working copy only — the server serves no `/docs`
|
|
130
|
+
route in any environment. Renderers that follow relative `$ref`s get a
|
|
131
|
+
fully-resolved single file from `make openapi-bundle`.
|
|
110
132
|
**Rationale:** Cheap while the endpoint count is low; switch to
|
|
111
133
|
comment-generated (swaggo) if hand-updates start drifting.
|
|
112
134
|
{{/if}}
|
|
113
135
|
|
|
114
136
|
{{#if observability}}
|
|
115
|
-
##
|
|
137
|
+
## 10. Observability
|
|
116
138
|
|
|
117
139
|
**Decision:** Prometheus metrics (`GET /metrics`, request count + latency
|
|
118
140
|
per route) always-on when this feature is enabled; OpenTelemetry tracing
|
|
@@ -16,6 +16,9 @@ internal/app/<domain>/
|
|
|
16
16
|
├── errors.go # domain error catalog (<DOMAIN>_NOT_FOUND, ...)
|
|
17
17
|
├── repository.go # the only place that touches the DB for this domain, every method takes ctx
|
|
18
18
|
├── service.go # business logic; declares the repository interface it needs (mockable in tests)
|
|
19
|
+
├── commands.go # optional: command port + state-changing application handlers (`--cqrs`)
|
|
20
|
+
├── queries.go # optional: query port + read-only application handlers (`--cqrs`)
|
|
21
|
+
├── composition.go # feature-local repository → service → handler wiring
|
|
19
22
|
├── handler.go # HTTP: routing, bind, delegate, respond
|
|
20
23
|
├── service_test.go # unit test, function-backed repository stub, no DB
|
|
21
24
|
├── handler_test.go # HTTP unit test, service stub, no DB
|
|
@@ -35,8 +38,8 @@ internal/app/<domain>/
|
|
|
35
38
|
- `TableName()` returns a schema-qualified name (`order_svc.orders`, not
|
|
36
39
|
`orders`) — every domain gets its own Postgres schema, created by its own
|
|
37
40
|
migration (`CREATE SCHEMA IF NOT EXISTS`) and by `cmd/api/wiring.go` before
|
|
38
|
-
|
|
39
|
-
live in). A cross-domain FK is still fine — see the FK rules below — this
|
|
41
|
+
the development table bootstrap runs (the bootstrap creates tables, never
|
|
42
|
+
the schema they live in). A cross-domain FK is still fine — see the FK rules below — this
|
|
40
43
|
only stops one domain's table from silently colliding with another's, or a
|
|
41
44
|
raw SQL `JOIN` from reaching into a domain it doesn't own without at least
|
|
42
45
|
naming the schema it's crossing into.
|
|
@@ -50,6 +53,23 @@ internal/app/<domain>/
|
|
|
50
53
|
`p.Response(out)`
|
|
51
54
|
- Reads the `:id` param via `httpx.ParseID(c)`
|
|
52
55
|
|
|
56
|
+
### Commands and queries (opt-in CQRS)
|
|
57
|
+
|
|
58
|
+
Use the project default recorded in `go-scaffold.config.json`, or override it
|
|
59
|
+
per module with `--cqrs`, when the feature has distinct write invariants, read
|
|
60
|
+
projections, consistency requirements, or scaling needs. `commands.go` owns
|
|
61
|
+
state-changing application ports and handlers; `queries.go` owns read-only
|
|
62
|
+
ports and handlers. `composition.go` constructs both, and the HTTP adapter
|
|
63
|
+
selects the matching port for each route. They may share the same
|
|
64
|
+
repository/database in this modular monolith — separate storage or event
|
|
65
|
+
sourcing is not implied.
|
|
66
|
+
|
|
67
|
+
The generated `Service` remains a compatibility facade for tests and existing
|
|
68
|
+
callers. New feature code should depend on the narrower command/query port it
|
|
69
|
+
actually needs. `generate module --defaults` uses the project default; choose
|
|
70
|
+
the single service path for simple CRUD and CQRS only when the feature earns
|
|
71
|
+
the extra boundary.
|
|
72
|
+
|
|
53
73
|
### Service
|
|
54
74
|
- Contains the business logic, knows nothing about HTTP
|
|
55
75
|
- Declares a `repository` interface for what it needs from the data layer —
|
|
@@ -121,8 +141,8 @@ if this project has one):
|
|
|
121
141
|
associations / belongs-to — that's what keeps one domain package from
|
|
122
142
|
importing another.
|
|
123
143
|
2. **Declare the FK constraint in migration SQL**
|
|
124
|
-
(`REFERENCES ... ON DELETE ...`), not a GORM tag —
|
|
125
|
-
create the constraint, which would make dev and prod schemas diverge.
|
|
144
|
+
(`REFERENCES ... ON DELETE ...`), not a GORM tag — the development bootstrap
|
|
145
|
+
doesn't create the constraint, which would make dev and prod schemas diverge.
|
|
126
146
|
3. **Map the FK error to the right status** via `dberr.IsForeignKey` —
|
|
127
147
|
inserting a reference to a missing parent, or deleting a parent that
|
|
128
148
|
still has children, is a client error (409/422), not a 500. Don't
|
|
@@ -148,24 +168,22 @@ type userLookup interface {
|
|
|
148
168
|
}
|
|
149
169
|
```
|
|
150
170
|
|
|
151
|
-
|
|
152
|
-
|
|
171
|
+
The feature keeps its own repository/service/handler composition in
|
|
172
|
+
`composition.go`; `cmd/api/wiring.go` supplies the DB and any cross-feature
|
|
173
|
+
security dependencies. For example, auth and RBAC are composed locally and
|
|
174
|
+
the root passes only the role feature's public capabilities:
|
|
153
175
|
|
|
154
176
|
```go
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if err != nil {
|
|
160
|
-
return "", err
|
|
161
|
-
}
|
|
162
|
-
return u.Email, nil
|
|
163
|
-
},
|
|
164
|
-
))
|
|
177
|
+
roleComposition := role.NewCompositionFromDB(db, cfg.JWTSecret, cfg.AuthzCacheTTL)
|
|
178
|
+
user.NewHandlerFromDB(
|
|
179
|
+
db, cfg, roleComposition.Service, roleComposition.Authz,
|
|
180
|
+
).Register(api)
|
|
165
181
|
```
|
|
166
182
|
|
|
167
|
-
|
|
168
|
-
interface
|
|
183
|
+
For ordinary cross-domain behaviour, the caller still declares a narrow
|
|
184
|
+
interface next to its application service. Its local composition accepts that
|
|
185
|
+
port, while the root supplies an adapter; the root never reaches into the
|
|
186
|
+
callee's repository or handler. A func-to-interface shim is one small option:
|
|
169
187
|
|
|
170
188
|
```go
|
|
171
189
|
// order/service.go
|
|
@@ -198,12 +216,13 @@ own data.
|
|
|
198
216
|
go-scaffold generate method <domain> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]
|
|
199
217
|
```
|
|
200
218
|
|
|
201
|
-
Patches `handler.go`/`service.go` (and `
|
|
202
|
-
|
|
219
|
+
Patches `handler.go`/`service.go` (and `commands.go` or `queries.go` for a
|
|
220
|
+
module generated with `--cqrs`; plus `repository.go` + the relevant repository
|
|
221
|
+
port + its `repositoryStub` test stub, for a `get --get-mode one --field`
|
|
203
222
|
lookup) in place, at the `// go-scaffold:*` marker comments near the end of
|
|
204
223
|
each file. **Don't delete those markers** — they're where the next
|
|
205
224
|
`generate method` call inserts. The method body is always left as a `TODO`
|
|
206
|
-
that compiles and returns a clean `500` (`apperror.NewInternal()`) rather
|
|
225
|
+
that compiles and returns a clean `500` (`apperror.NewInternal(cause)`) rather
|
|
207
226
|
than guessing at business logic — same spirit as `generate module`'s
|
|
208
227
|
placeholder fields.
|
|
209
228
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
- PostgreSQL + GORM baseline: always enabled
|
|
23
23
|
- Graceful shutdown, structured logging, `/livez` + `/readyz`: always enabled
|
|
24
24
|
- Docker Compose (local Postgres): `{{#if docker}}enabled{{else}}disabled{{/if}}`
|
|
25
|
-
- OpenAPI docs (`docs/openapi.yaml`,
|
|
25
|
+
- OpenAPI docs (`docs/openapi.yaml`, working-copy only — never served over HTTP): `{{#if openapiDocs}}enabled{{else}}disabled{{/if}}`
|
|
26
26
|
- Metrics + tracing (Prometheus `/metrics`, OpenTelemetry for Gin + GORM, `cmd/api` only): `{{#if observability}}enabled{{else}}disabled{{/if}}`
|
|
27
27
|
- API route prefix: `{{#if apiPrefix}}/{{apiPrefix}}{{else}}(none){{/if}}`
|
|
28
28
|
- CI (`.github/workflows/ci.yml` — build, vet, gofmt check, golangci-lint, `go test` with a real Postgres service): always enabled
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
{{#if docker}}- Local Postgres comes from `docker-compose.yml`
|
|
33
33
|
{{else}}- No Docker scaffolding included — bring your own Postgres and point `DB_DSN` at it
|
|
34
|
-
{{/if}}- `
|
|
35
|
-
`
|
|
34
|
+
{{/if}}- `APP_ENV=development` enables the convenience table bootstrap;
|
|
35
|
+
`APP_ENV=production` requires `migrate up` as a deploy step instead
|
|
36
36
|
{{#if openapiDocs}}- `docs/openapi.yaml` is hand-written, not generated — update it whenever an
|
|
37
37
|
endpoint or DTO changes
|
|
38
38
|
{{/if}}{{#if observability}}- `OTEL_EXPORTER_OTLP_ENDPOINT` unset (dev default) means tracing exports
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
package {{pkg}}
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
|
|
7
|
+
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
8
|
+
"{{goModule}}/internal/shared/apperror"
|
|
9
|
+
"{{goModule}}/internal/shared/dberr"
|
|
10
|
+
"{{goModule}}/internal/shared/id"
|
|
11
|
+
|
|
12
|
+
"github.com/google/uuid"
|
|
13
|
+
"gorm.io/gorm"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// commandRepository is the outbound port for state-changing use cases. It is
|
|
17
|
+
// intentionally smaller than queryRepository so command handlers do not grow
|
|
18
|
+
// a dependency on read-only operations by accident.
|
|
19
|
+
type commandRepository interface {
|
|
20
|
+
Create(ctx context.Context, m *model.{{pascalName}}) error
|
|
21
|
+
FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error)
|
|
22
|
+
Update(ctx context.Context, m *model.{{pascalName}}) error
|
|
23
|
+
Delete(ctx context.Context, id uuid.UUID) error
|
|
24
|
+
// go-scaffold:command-repository-interface
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// commandService is the inbound application port consumed by the HTTP
|
|
28
|
+
// adapter. Its implementation is free of Gin and is independently testable.
|
|
29
|
+
type commandService interface {
|
|
30
|
+
Create(context.Context, createInput) (*model.{{pascalName}}, error)
|
|
31
|
+
Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
|
|
32
|
+
Delete(context.Context, uuid.UUID) error
|
|
33
|
+
// go-scaffold:command-interface
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// CommandHandler owns state-changing use cases for {{pkg}}. It is deliberately
|
|
37
|
+
// separate from QueryHandler even though both adapters may share one database.
|
|
38
|
+
type CommandHandler struct {
|
|
39
|
+
repo commandRepository
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func NewCommandHandler(repo commandRepository) *CommandHandler {
|
|
43
|
+
return &CommandHandler{repo: repo}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func (h *CommandHandler) Create(ctx context.Context, in createInput) (*model.{{pascalName}}, error) {
|
|
47
|
+
// TODO: set real fields from in
|
|
48
|
+
_ = in
|
|
49
|
+
m := &model.{{pascalName}}{ID: id.New()}
|
|
50
|
+
if err := h.repo.Create(ctx, m); err != nil {
|
|
51
|
+
if dberr.IsDuplicate(err) {
|
|
52
|
+
return nil, errConflict()
|
|
53
|
+
}
|
|
54
|
+
return nil, apperror.NewInternal(err)
|
|
55
|
+
}
|
|
56
|
+
return m, nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func (h *CommandHandler) Update(ctx context.Context, id uuid.UUID, in updateInput) (*model.{{pascalName}}, error) {
|
|
60
|
+
m, err := h.repo.FindByID(ctx, id)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return nil, wrapFindErr(err)
|
|
63
|
+
}
|
|
64
|
+
// TODO: apply real fields from in before saving
|
|
65
|
+
// The version the client read comes from the request, not from the row we
|
|
66
|
+
// just loaded — comparing the row against itself would always succeed and
|
|
67
|
+
// defeat the check.
|
|
68
|
+
m.Version = in.Version
|
|
69
|
+
if err := h.repo.Update(ctx, m); err != nil {
|
|
70
|
+
if errors.Is(err, ErrStaleVersion) {
|
|
71
|
+
return nil, errStale()
|
|
72
|
+
}
|
|
73
|
+
if dberr.IsDuplicate(err) {
|
|
74
|
+
return nil, errConflict()
|
|
75
|
+
}
|
|
76
|
+
return nil, apperror.NewInternal(err)
|
|
77
|
+
}
|
|
78
|
+
return m, nil
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func (h *CommandHandler) Delete(ctx context.Context, id uuid.UUID) error {
|
|
82
|
+
if err := h.repo.Delete(ctx, id); err != nil {
|
|
83
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
84
|
+
return errNotFound()
|
|
85
|
+
}
|
|
86
|
+
// deleting a {{pkg}} that's still referenced elsewhere → FK RESTRICT trips = 409, not 500
|
|
87
|
+
if dberr.IsForeignKey(err) {
|
|
88
|
+
return errHasReferences()
|
|
89
|
+
}
|
|
90
|
+
return apperror.NewInternal(err)
|
|
91
|
+
}
|
|
92
|
+
return nil
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// go-scaffold:command-methods
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package {{pkg}}
|
|
2
|
+
|
|
3
|
+
import "gorm.io/gorm"
|
|
4
|
+
{{#if permission}}
|
|
5
|
+
import "{{goModule}}/internal/shared/middleware"
|
|
6
|
+
{{/if}}
|
|
7
|
+
|
|
8
|
+
// NewHandlerFromDB is this feature's local composition root. The API binary
|
|
9
|
+
// only chooses infrastructure and registers the route; repository, application
|
|
10
|
+
// handlers, and delivery construction stay next to the feature.
|
|
11
|
+
func NewHandlerFromDB(db *gorm.DB{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
|
|
12
|
+
{{#if cqrs}}
|
|
13
|
+
repo := NewRepository(db)
|
|
14
|
+
return NewHandlerFromCQRS(
|
|
15
|
+
NewCommandHandler(repo),
|
|
16
|
+
NewQueryHandler(repo){{#if auth}}, jwtSecret{{/if}}{{#if permission}}, authz{{/if}},
|
|
17
|
+
)
|
|
18
|
+
{{else}}
|
|
19
|
+
return NewHandler(
|
|
20
|
+
NewService(NewRepository(db)){{#if auth}}, jwtSecret{{/if}}{{#if permission}}, authz{{/if}},
|
|
21
|
+
)
|
|
22
|
+
{{/if}}
|
|
23
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
package {{pkg}}
|
|
2
|
+
|
|
3
|
+
// These compile-time assertions make the generated boundary visible to both
|
|
4
|
+
// users and tooling: commands and queries are separate application ports even
|
|
5
|
+
// when they share one Postgres adapter in this modular monolith.
|
|
6
|
+
var _ commandService = (*CommandHandler)(nil)
|
|
7
|
+
var _ queryService = (*QueryHandler)(nil)
|
|
@@ -15,6 +15,21 @@ import (
|
|
|
15
15
|
"github.com/google/uuid"
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
+
{{#if cqrs}}
|
|
19
|
+
// service is retained as a compatibility seam for handler tests and callers
|
|
20
|
+
// that still want one feature facade. Production wiring uses the narrower
|
|
21
|
+
// commandService/queryService ports through NewHandlerFromCQRS.
|
|
22
|
+
type service interface {
|
|
23
|
+
commandService
|
|
24
|
+
queryService
|
|
25
|
+
Create(context.Context, createInput) (*model.{{pascalName}}, error)
|
|
26
|
+
List(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
27
|
+
Get(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
28
|
+
Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
|
|
29
|
+
Delete(context.Context, uuid.UUID) error
|
|
30
|
+
// go-scaffold:service-interface
|
|
31
|
+
}
|
|
32
|
+
{{else}}
|
|
18
33
|
// service is the narrow application API required by this HTTP adapter. Keeping
|
|
19
34
|
// the dependency as an interface makes handler tests fast and database-free.
|
|
20
35
|
type service interface {
|
|
@@ -25,10 +40,16 @@ type service interface {
|
|
|
25
40
|
Delete(context.Context, uuid.UUID) error
|
|
26
41
|
// go-scaffold:service-interface
|
|
27
42
|
}
|
|
43
|
+
{{/if}}
|
|
28
44
|
|
|
29
45
|
// Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
|
|
30
46
|
type Handler struct {
|
|
47
|
+
{{#if cqrs}}
|
|
48
|
+
commands commandService
|
|
49
|
+
queries queryService
|
|
50
|
+
{{else}}
|
|
31
51
|
svc service
|
|
52
|
+
{{/if}}
|
|
32
53
|
{{#if auth}}
|
|
33
54
|
jwtSecret string
|
|
34
55
|
{{/if}}
|
|
@@ -37,6 +58,27 @@ type Handler struct {
|
|
|
37
58
|
{{/if}}
|
|
38
59
|
}
|
|
39
60
|
|
|
61
|
+
{{#if cqrs}}
|
|
62
|
+
// NewHandler keeps the pre-CQRS constructor usable for focused handler tests
|
|
63
|
+
// and hand-written callers. New production composition should use the explicit
|
|
64
|
+
// command/query constructor below.
|
|
65
|
+
func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
|
|
66
|
+
return NewHandlerFromCQRS(svc, svc{{#if auth}}, jwtSecret{{/if}}{{#if permission}}, authz{{/if}})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func NewHandlerFromCQRS(commands commandService, queries queryService{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
|
|
70
|
+
return &Handler{
|
|
71
|
+
commands: commands,
|
|
72
|
+
queries: queries,
|
|
73
|
+
{{#if auth}}
|
|
74
|
+
jwtSecret: jwtSecret,
|
|
75
|
+
{{/if}}
|
|
76
|
+
{{#if permission}}
|
|
77
|
+
authz: authz,
|
|
78
|
+
{{/if}}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
{{else}}
|
|
40
82
|
func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
|
|
41
83
|
return &Handler{
|
|
42
84
|
svc: svc,
|
|
@@ -48,6 +90,7 @@ func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission
|
|
|
48
90
|
{{/if}}
|
|
49
91
|
}
|
|
50
92
|
}
|
|
93
|
+
{{/if}}
|
|
51
94
|
|
|
52
95
|
// Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
|
|
53
96
|
func (h *Handler) Register(rg gin.IRouter) {
|
|
@@ -66,7 +109,7 @@ func (h *Handler) create(c *gin.Context) {
|
|
|
66
109
|
c.Error(httpx.BindErr(err))
|
|
67
110
|
return
|
|
68
111
|
}
|
|
69
|
-
m, err := h.svc.Create(c.Request.Context(), in)
|
|
112
|
+
{{#if cqrs}}m, err := h.commands.Create(c.Request.Context(), in){{else}}m, err := h.svc.Create(c.Request.Context(), in){{/if}}
|
|
70
113
|
if err != nil {
|
|
71
114
|
c.Error(err)
|
|
72
115
|
return
|
|
@@ -76,7 +119,7 @@ func (h *Handler) create(c *gin.Context) {
|
|
|
76
119
|
|
|
77
120
|
func (h *Handler) list(c *gin.Context) {
|
|
78
121
|
p := pagination.Parse(c)
|
|
79
|
-
items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
|
|
122
|
+
{{#if cqrs}}items, err := h.queries.List(c.Request.Context(), p.Limit, p.Offset){{else}}items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset){{/if}}
|
|
80
123
|
if err != nil {
|
|
81
124
|
c.Error(err)
|
|
82
125
|
return
|
|
@@ -93,7 +136,7 @@ func (h *Handler) get(c *gin.Context) {
|
|
|
93
136
|
if !ok {
|
|
94
137
|
return
|
|
95
138
|
}
|
|
96
|
-
m, err := h.svc.Get(c.Request.Context(), id)
|
|
139
|
+
{{#if cqrs}}m, err := h.queries.Get(c.Request.Context(), id){{else}}m, err := h.svc.Get(c.Request.Context(), id){{/if}}
|
|
97
140
|
if err != nil {
|
|
98
141
|
c.Error(err)
|
|
99
142
|
return
|
|
@@ -111,7 +154,7 @@ func (h *Handler) update(c *gin.Context) {
|
|
|
111
154
|
c.Error(httpx.BindErr(err))
|
|
112
155
|
return
|
|
113
156
|
}
|
|
114
|
-
m, err := h.svc.Update(c.Request.Context(), id, in)
|
|
157
|
+
{{#if cqrs}}m, err := h.commands.Update(c.Request.Context(), id, in){{else}}m, err := h.svc.Update(c.Request.Context(), id, in){{/if}}
|
|
115
158
|
if err != nil {
|
|
116
159
|
c.Error(err)
|
|
117
160
|
return
|
|
@@ -124,7 +167,9 @@ func (h *Handler) delete(c *gin.Context) {
|
|
|
124
167
|
if !ok {
|
|
125
168
|
return
|
|
126
169
|
}
|
|
127
|
-
if err := h.
|
|
170
|
+
{{#if cqrs}} if err := h.commands.Delete(c.Request.Context(), id); err != nil {
|
|
171
|
+
{{else}} if err := h.svc.Delete(c.Request.Context(), id); err != nil {
|
|
172
|
+
{{/if}}
|
|
128
173
|
c.Error(err)
|
|
129
174
|
return
|
|
130
175
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
package {{pkg}}
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
7
|
+
|
|
8
|
+
"github.com/google/uuid"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// commandRepository is the outbound port for state-changing use cases.
|
|
12
|
+
type commandRepository interface {
|
|
13
|
+
Create(ctx context.Context, m *model.{{pascalName}}) error
|
|
14
|
+
FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error)
|
|
15
|
+
Update(ctx context.Context, m *model.{{pascalName}}) error
|
|
16
|
+
Delete(ctx context.Context, id uuid.UUID) error
|
|
17
|
+
// go-scaffold:command-repository-interface
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// commandService is the inbound application port consumed by command routes.
|
|
21
|
+
type commandService interface {
|
|
22
|
+
// go-scaffold:command-interface
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// CommandHandler owns state-changing use cases for {{pkg}}.
|
|
26
|
+
type CommandHandler struct {
|
|
27
|
+
repo commandRepository
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func NewCommandHandler(repo commandRepository) *CommandHandler {
|
|
31
|
+
return &CommandHandler{repo: repo}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// go-scaffold:command-methods
|