@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.
Files changed (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. 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 if !exposeDetail {
37
- // Known AppError (e.g. VALIDATION_ERROR): still strip Details in
38
- // prod, so a direct API call doesn't get field-level hints.
39
- appErr.Details = nil
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. A domain that needs another's behaviour
10
- declares a narrow interface for exactly what it needs and receives the concrete
11
- service from `run()`; `.golangci.yml` has a depguard rule per domain that fails
12
- the build if anyone shortcuts that.
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. Response and Error Model
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
- ## 5. Pagination
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
- ## 6. Persistence
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:** `AUTO_MIGRATE=true` runs GORM's AutoMigrate in dev for speed;
90
- prod runs `migrate up` as a separate, versioned, rollback-capable step —
91
- AutoMigrate is add-only and locks tables once there's real data.
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
- ## 7. IDs
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
- ## 8. API Documentation
126
+ ## 9. API Documentation
105
127
 
106
128
  **Decision:** Hand-written OpenAPI spec (`docs/openapi.yaml`), split by
107
- domain module, served under `/docs` (so relative `$ref`s to sibling files
108
- resolve over HTTP too the index alone isn't enough for a renderer like
109
- Scalar/Swagger UI/Redoc to follow them).
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
- ## 9. Observability
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
- `AutoMigrate` runs in dev (AutoMigrate creates tables, never the schema they
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 — AutoMigrate doesn't
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
- `cmd/api/wiring.go` is the one place that knows both domains, so the adapter
152
- lives there a func literal, not a type:
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
- userSvc := user.NewService(userRepo, ...)
156
- orderSvc := order.NewService(orderRepo, order.UserLookupFunc(
157
- func(ctx context.Context, id uuid.UUID) (string, error) {
158
- u, err := userSvc.Get(ctx, id)
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
- where `order` provides the usual func-to-interface shim next to its
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 `repository.go` + the `repository`
202
- interface + its `repositoryStub` test stub, for a `get --get-mode one --field`
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`, whole `docs/` tree served at `/docs`): `{{#if openapiDocs}}enabled{{else}}disabled{{/if}}`
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}}- `AUTO_MIGRATE=true` (dev default) runs GORM AutoMigrate on boot; set it to
35
- `false` in prod and run `migrate up` as a deploy step instead
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.svc.Delete(c.Request.Context(), id); err != nil {
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