@nakedev/go-scaffold 0.4.3 → 0.5.0

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 (164) hide show
  1. package/README.md +567 -513
  2. package/dist/commands/auth.js +12 -1
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/create.js +2 -1
  5. package/dist/commands/generate.js +5 -2
  6. package/dist/commands/method.js +66 -70
  7. package/dist/commands/observability.js +4 -53
  8. package/dist/commands/rbac.js +19 -8
  9. package/dist/commands/undo.js +6 -3
  10. package/dist/commands/worker.js +14 -4
  11. package/dist/index.js +13 -1
  12. package/dist/templates/auth-manifest.js +46 -45
  13. package/dist/templates/create-manifest.js +4 -0
  14. package/dist/templates/module-manifest.js +82 -60
  15. package/dist/templates/rbac-manifest.js +15 -11
  16. package/dist/templates/worker-manifest.js +4 -1
  17. package/dist/types.js +2 -0
  18. package/dist/utils/auth-patcher.js +22 -22
  19. package/dist/utils/config.js +24 -5
  20. package/dist/utils/docs-patcher.js +68 -0
  21. package/dist/utils/hexagonal-method-patcher.js +334 -0
  22. package/dist/utils/main-patcher.js +3 -3
  23. package/dist/utils/module-location.js +17 -11
  24. package/dist/utils/platform-patcher.js +27 -0
  25. package/dist/utils/rbac-patcher.js +73 -216
  26. package/package.json +1 -1
  27. package/templates/add/auth/cmd/seed/main.go.hbs +2 -0
  28. package/templates/add/auth/internal/app/user/{browser_policy.go.hbs → adapters/inbound/http/browser_policy.go.hbs} +3 -3
  29. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  30. package/templates/add/auth/internal/app/user/{handler.go.hbs → adapters/inbound/http/handler.go.hbs} +103 -8
  31. package/templates/add/auth/internal/app/user/{handler_local.go.hbs → adapters/inbound/http/handler_local.go.hbs} +8 -7
  32. package/templates/add/auth/internal/app/user/{handler_mfa.go.hbs → adapters/inbound/http/handler_mfa.go.hbs} +6 -6
  33. package/templates/add/auth/internal/app/user/{handler_oauth.go.hbs → adapters/inbound/http/handler_oauth.go.hbs} +17 -17
  34. package/templates/add/auth/internal/app/user/{handler_recovery.go.hbs → adapters/inbound/http/handler_recovery.go.hbs} +4 -4
  35. package/templates/add/auth/internal/app/user/{handler_test.go.hbs → adapters/inbound/http/handler_test.go.hbs} +52 -31
  36. package/templates/add/auth/internal/app/user/{handler_user.go.hbs → adapters/inbound/http/handler_user.go.hbs} +4 -4
  37. package/templates/add/auth/internal/app/user/{session_cookie.go.hbs → adapters/inbound/http/session_cookie.go.hbs} +5 -3
  38. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  39. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  40. package/templates/add/auth/internal/app/user/{mfa_store.go.hbs → adapters/outbound/postgres/mfa_store.go.hbs} +22 -20
  41. package/templates/add/auth/internal/app/user/{mfa_store_test.go.hbs → adapters/outbound/postgres/mfa_store_test.go.hbs} +7 -7
  42. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  43. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  44. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  45. package/templates/add/auth/internal/app/user/{tokenstore_pg.go.hbs → adapters/outbound/postgres/tokenstore_pg.go.hbs} +36 -32
  46. package/templates/add/auth/internal/app/user/{tokenstore_pg_test.go.hbs → adapters/outbound/postgres/tokenstore_pg_test.go.hbs} +10 -3
  47. package/templates/add/auth/internal/app/user/{tokenstore_recovery.go.hbs → adapters/outbound/postgres/tokenstore_recovery.go.hbs} +29 -3
  48. package/templates/add/auth/internal/app/user/{tokenstore_redis.go.hbs → adapters/outbound/redis/tokenstore.go.hbs} +43 -36
  49. package/templates/add/auth/internal/app/user/{tokenstore_redis_test.go.hbs → adapters/outbound/redis/tokenstore_test.go.hbs} +14 -3
  50. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  51. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  52. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  53. package/templates/add/auth/internal/app/user/{external_login.go.hbs → application/external_login.go.hbs} +30 -40
  54. package/templates/add/auth/internal/app/user/{jwt.go.hbs → application/jwt.go.hbs} +6 -3
  55. package/templates/add/auth/internal/app/user/{local_auth.go.hbs → application/local_auth.go.hbs} +17 -19
  56. package/templates/add/auth/internal/app/user/{mfa_service.go.hbs → application/mfa_service.go.hbs} +27 -28
  57. package/templates/add/auth/internal/app/user/{mfa_service_test.go.hbs → application/mfa_service_test.go.hbs} +12 -11
  58. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +3 -3
  59. package/templates/add/auth/internal/app/user/{provider_test.go.hbs → application/provider_test.go.hbs} +55 -56
  60. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +16 -47
  61. package/templates/add/auth/internal/app/user/{recovery_service.go.hbs → application/recovery_service.go.hbs} +13 -15
  62. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  63. package/templates/add/auth/internal/app/user/{service_test.go.hbs → application/service_test.go.hbs} +94 -75
  64. package/templates/add/auth/internal/app/user/{sessions.go.hbs → application/sessions.go.hbs} +14 -14
  65. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  66. package/templates/add/auth/internal/app/user/{user_query.go.hbs → application/user_query.go.hbs} +16 -16
  67. package/templates/add/auth/internal/app/user/composition.go.hbs +89 -86
  68. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  69. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  70. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  71. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  72. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +1 -1
  73. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  74. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -1
  75. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  76. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  77. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  78. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  79. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  80. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  81. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  82. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  83. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  84. package/templates/add/rbac/internal/app/role/composition.go.hbs +23 -10
  85. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  86. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  87. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  88. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  89. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  90. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  91. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +54 -36
  92. package/templates/create/base/.golangci.yml.hbs +2 -2
  93. package/templates/create/base/AGENTS.md.hbs +55 -31
  94. package/templates/create/base/README.md.hbs +77 -22
  95. package/templates/create/base/cmd/api/wiring.go.hbs +3 -2
  96. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  97. package/templates/create/features/docs/architecture.md.hbs +64 -26
  98. package/templates/create/features/docs/patterns.md.hbs +117 -90
  99. package/templates/create/features/docs/techstack.md.hbs +17 -2
  100. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  101. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  102. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  103. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  104. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  105. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  106. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  107. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  108. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  109. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  110. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  111. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  112. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  113. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  114. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  115. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  116. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  117. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  118. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  119. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  120. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  121. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  122. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  123. package/templates/generate/module/migration.up.sql.hbs +1 -1
  124. package/dist/utils/method-patcher.js +0 -537
  125. package/templates/add/auth/internal/app/user/contracts.go.hbs +0 -88
  126. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -134
  127. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -68
  128. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -45
  129. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -32
  130. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  131. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +0 -17
  132. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +0 -20
  133. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +0 -17
  134. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -31
  135. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -139
  136. package/templates/add/auth/internal/app/user/service.go.hbs +0 -135
  137. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -58
  138. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  139. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  140. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  141. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  142. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  143. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  144. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  145. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  146. package/templates/generate/module/commands.go.hbs +0 -95
  147. package/templates/generate/module/composition.go.hbs +0 -23
  148. package/templates/generate/module/cqrs_test.go.hbs +0 -7
  149. package/templates/generate/module/dto.go.hbs +0 -36
  150. package/templates/generate/module/errors.go.hbs +0 -33
  151. package/templates/generate/module/handler.go.hbs +0 -179
  152. package/templates/generate/module/handler_test.go.hbs +0 -174
  153. package/templates/generate/module/minimal/commands.go.hbs +0 -34
  154. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  155. package/templates/generate/module/minimal/handler.go.hbs +0 -82
  156. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  157. package/templates/generate/module/minimal/queries.go.hbs +0 -45
  158. package/templates/generate/module/minimal/service.go.hbs +0 -71
  159. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  160. package/templates/generate/module/model/model.go.hbs +0 -36
  161. package/templates/generate/module/queries.go.hbs +0 -62
  162. package/templates/generate/module/repository.go.hbs +0 -103
  163. package/templates/generate/module/service.go.hbs +0 -164
  164. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -0,0 +1,182 @@
1
+ // Package httpadapter is the Gin inbound adapter for {{pkg}}.
2
+ package httpadapter
3
+
4
+ import (
5
+ "errors"
6
+ "net/http"
7
+
8
+ "{{goModule}}/internal/app/{{modulePath}}/application"
9
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
10
+ "{{goModule}}/internal/shared/apperror"
11
+ "{{goModule}}/internal/shared/httpx"
12
+ {{#if auth}} "{{goModule}}/internal/shared/middleware"
13
+ {{/if}}
14
+ "{{goModule}}/internal/shared/pagination"
15
+
16
+ "github.com/gin-gonic/gin"
17
+ )
18
+
19
+ type Handler struct {
20
+ {{#if cqrs}}
21
+ commands application.CommandPort
22
+ queries application.QueryPort
23
+ {{else}}
24
+ svc application.ServicePort
25
+ {{/if}}
26
+ {{#if auth}}
27
+ jwtSecret string
28
+ {{/if}}
29
+ {{#if permission}}
30
+ authz *middleware.Authz
31
+ {{/if}}
32
+ }
33
+
34
+ {{#if cqrs}}
35
+ func NewHandler(commands application.CommandPort, queries application.QueryPort{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
36
+ h := &Handler{commands: commands, queries: queries}
37
+ {{#if auth}}
38
+ h.jwtSecret = jwtSecret
39
+ {{/if}}
40
+ {{#if permission}}
41
+ h.authz = authz
42
+ {{/if}}
43
+ return h
44
+ }
45
+ {{else}}
46
+ func NewHandler(svc application.ServicePort{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
47
+ h := &Handler{svc: svc}
48
+ {{#if auth}}
49
+ h.jwtSecret = jwtSecret
50
+ {{/if}}
51
+ {{#if permission}}
52
+ h.authz = authz
53
+ {{/if}}
54
+ return h
55
+ }
56
+ {{/if}}
57
+
58
+ func (h *Handler) Register(rg gin.IRouter) {
59
+ g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
60
+ g.POST("", h.create)
61
+ g.GET("", h.list)
62
+ g.GET("/:id", h.get)
63
+ g.PUT("/:id", h.update)
64
+ g.DELETE("/:id", h.delete)
65
+ // go-scaffold:handler-routes
66
+ }
67
+
68
+ func appError(err error) error {
69
+ switch {
70
+ case errors.Is(err, domain.ErrNotFound):
71
+ return applicationError(http.StatusNotFound, "{{errorPrefix}}_NOT_FOUND", "{{pkg}} not found")
72
+ case errors.Is(err, domain.ErrConflict):
73
+ return applicationError(http.StatusConflict, "{{errorPrefix}}_CONFLICT", "{{pkg}} already exists")
74
+ case errors.Is(err, domain.ErrStaleVersion):
75
+ return applicationError(http.StatusConflict, "{{errorPrefix}}_STALE", "{{pkg}} was modified by someone else — reload and try again")
76
+ case errors.Is(err, domain.ErrHasReferences):
77
+ return applicationError(http.StatusConflict, "{{errorPrefix}}_HAS_REFERENCES", "{{pkg}} still has related records")
78
+ case errors.Is(err, domain.ErrNotImplemented):
79
+ return applicationError(http.StatusNotImplemented, "NOT_IMPLEMENTED", "not implemented")
80
+ default:
81
+ return apperror.NewInternal(err)
82
+ }
83
+ }
84
+
85
+ func applicationError(status int, code, message string) *apperror.AppError {
86
+ // The HTTP adapter is the only layer allowed to create an HTTP error.
87
+ return apperror.New(status, code, message)
88
+ }
89
+
90
+ func (h *Handler) create(c *gin.Context) {
91
+ var in createInput
92
+ if err := c.ShouldBindJSON(&in); err != nil {
93
+ c.Error(httpx.BindErr(err))
94
+ return
95
+ }
96
+ {{#if cqrs}}
97
+ m, err := h.commands.Create(c.Request.Context(), toCreateInput(in))
98
+ {{else}}
99
+ m, err := h.svc.Create(c.Request.Context(), toCreateInput(in))
100
+ {{/if}}
101
+ if err != nil {
102
+ c.Error(appError(err))
103
+ return
104
+ }
105
+ c.JSON(http.StatusCreated, toResponse(application.ToResponse(m)))
106
+ }
107
+
108
+ func (h *Handler) list(c *gin.Context) {
109
+ p := pagination.Parse(c)
110
+ {{#if cqrs}}
111
+ items, err := h.queries.List(c.Request.Context(), p.Limit, p.Offset)
112
+ {{else}}
113
+ items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
114
+ {{/if}}
115
+ if err != nil {
116
+ c.Error(appError(err))
117
+ return
118
+ }
119
+ out := make([]response, len(items))
120
+ for i := range items {
121
+ out[i] = toResponse(application.ToResponse(&items[i]))
122
+ }
123
+ c.JSON(http.StatusOK, p.Response(out))
124
+ }
125
+
126
+ func (h *Handler) get(c *gin.Context) {
127
+ id, ok := httpx.ParseID(c)
128
+ if !ok {
129
+ return
130
+ }
131
+ {{#if cqrs}}
132
+ m, err := h.queries.Get(c.Request.Context(), id)
133
+ {{else}}
134
+ m, err := h.svc.Get(c.Request.Context(), id)
135
+ {{/if}}
136
+ if err != nil {
137
+ c.Error(appError(err))
138
+ return
139
+ }
140
+ c.JSON(http.StatusOK, toResponse(application.ToResponse(m)))
141
+ }
142
+
143
+ func (h *Handler) update(c *gin.Context) {
144
+ id, ok := httpx.ParseID(c)
145
+ if !ok {
146
+ return
147
+ }
148
+ var in updateInput
149
+ if err := c.ShouldBindJSON(&in); err != nil {
150
+ c.Error(httpx.BindErr(err))
151
+ return
152
+ }
153
+ {{#if cqrs}}
154
+ m, err := h.commands.Update(c.Request.Context(), id, toUpdateInput(in))
155
+ {{else}}
156
+ m, err := h.svc.Update(c.Request.Context(), id, toUpdateInput(in))
157
+ {{/if}}
158
+ if err != nil {
159
+ c.Error(appError(err))
160
+ return
161
+ }
162
+ c.JSON(http.StatusOK, toResponse(application.ToResponse(m)))
163
+ }
164
+
165
+ func (h *Handler) delete(c *gin.Context) {
166
+ id, ok := httpx.ParseID(c)
167
+ if !ok {
168
+ return
169
+ }
170
+ {{#if cqrs}}
171
+ err := h.commands.Delete(c.Request.Context(), id)
172
+ {{else}}
173
+ err := h.svc.Delete(c.Request.Context(), id)
174
+ {{/if}}
175
+ if err != nil {
176
+ c.Error(appError(err))
177
+ return
178
+ }
179
+ c.Status(http.StatusNoContent)
180
+ }
181
+
182
+ // go-scaffold:handler-funcs
@@ -0,0 +1,83 @@
1
+ // Package httpadapter is the Gin inbound adapter for {{pkg}}.
2
+ package httpadapter
3
+
4
+ import (
5
+ "errors"
6
+ "net/http"
7
+
8
+ "{{goModule}}/internal/app/{{modulePath}}/application"
9
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
10
+ "{{goModule}}/internal/shared/apperror"
11
+ {{#if auth}}
12
+ "{{goModule}}/internal/shared/middleware"
13
+ {{/if}}
14
+
15
+ "github.com/gin-gonic/gin"
16
+ )
17
+
18
+ type Handler struct {
19
+ {{#if cqrs}}
20
+ commands application.CommandPort
21
+ queries application.QueryPort
22
+ {{else}}
23
+ svc application.ServicePort
24
+ {{/if}}
25
+ {{#if auth}}
26
+ jwtSecret string
27
+ {{/if}}
28
+ {{#if permission}}
29
+ authz *middleware.Authz
30
+ {{/if}}
31
+ }
32
+
33
+ {{#if cqrs}}
34
+ func NewHandler(commands application.CommandPort, queries application.QueryPort{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
35
+ h := &Handler{commands: commands, queries: queries}
36
+ {{#if auth}}
37
+ h.jwtSecret = jwtSecret
38
+ {{/if}}
39
+ {{#if permission}}
40
+ h.authz = authz
41
+ {{/if}}
42
+ return h
43
+ }
44
+ {{else}}
45
+ func NewHandler(svc application.ServicePort{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
46
+ h := &Handler{svc: svc}
47
+ {{#if auth}}
48
+ h.jwtSecret = jwtSecret
49
+ {{/if}}
50
+ {{#if permission}}
51
+ h.authz = authz
52
+ {{/if}}
53
+ return h
54
+ }
55
+ {{/if}}
56
+
57
+ // Register wires the module's route group. Add endpoints with
58
+ // `go-scaffold generate method {{pkg}} <name> --type ...`.
59
+ func (h *Handler) Register(rg gin.IRouter) {
60
+ g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
61
+ _ = g
62
+ _ = appError
63
+ // go-scaffold:handler-routes
64
+ }
65
+
66
+ func appError(err error) error {
67
+ switch {
68
+ case errors.Is(err, domain.ErrNotFound):
69
+ return apperror.New(http.StatusNotFound, "{{errorPrefix}}_NOT_FOUND", "{{pkg}} not found")
70
+ case errors.Is(err, domain.ErrConflict):
71
+ return apperror.New(http.StatusConflict, "{{errorPrefix}}_CONFLICT", "{{pkg}} already exists")
72
+ case errors.Is(err, domain.ErrStaleVersion):
73
+ return apperror.New(http.StatusConflict, "{{errorPrefix}}_STALE", "{{pkg}} was modified by someone else — reload and try again")
74
+ case errors.Is(err, domain.ErrHasReferences):
75
+ return apperror.New(http.StatusConflict, "{{errorPrefix}}_HAS_REFERENCES", "{{pkg}} still has related records")
76
+ case errors.Is(err, domain.ErrNotImplemented):
77
+ return apperror.New(http.StatusNotImplemented, "NOT_IMPLEMENTED", "not implemented")
78
+ default:
79
+ return apperror.NewInternal(err)
80
+ }
81
+ }
82
+
83
+ // go-scaffold:handler-funcs
@@ -0,0 +1,18 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/gin-gonic/gin"
7
+ )
8
+
9
+ func TestHandler_RegisterCRUD(t *testing.T) {
10
+ gin.SetMode(gin.TestMode)
11
+ r := gin.New()
12
+ {{#if cqrs}}
13
+ h := NewHandler(nil, nil{{#if auth}}, "test-secret"{{/if}}{{#if permission}}, nil{{/if}})
14
+ {{else}}
15
+ h := NewHandler(nil{{#if auth}}, "test-secret"{{/if}}{{#if permission}}, nil{{/if}})
16
+ {{/if}}
17
+ h.Register(r)
18
+ }
@@ -0,0 +1,30 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ {{#if permission}}
5
+ "context"
6
+ "time"
7
+ {{/if}}
8
+ "testing"
9
+
10
+ {{#if permission}}
11
+ "{{goModule}}/internal/shared/middleware"
12
+ {{/if}}
13
+ "github.com/gin-gonic/gin"
14
+ )
15
+
16
+ func TestHandler_Register(t *testing.T) {
17
+ gin.SetMode(gin.TestMode)
18
+ r := gin.New()
19
+ {{#if permission}}
20
+ authz := middleware.NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
21
+ return map[string]struct{}{"{{permission}}": {}}, nil
22
+ }, time.Minute)
23
+ {{/if}}
24
+ {{#if cqrs}}
25
+ h := NewHandler(nil, nil{{#if auth}}, "test-secret"{{/if}}{{#if permission}}, authz{{/if}})
26
+ {{else}}
27
+ h := NewHandler(nil{{#if auth}}, "test-secret"{{/if}}{{#if permission}}, authz{{/if}})
28
+ {{/if}}
29
+ h.Register(r)
30
+ }
@@ -0,0 +1,37 @@
1
+ // Package postgres is the {{pkg}} outbound adapter. Its GORM model is not
2
+ // exported as a domain type; conversion happens at this boundary.
3
+ package postgres
4
+
5
+ import (
6
+ "time"
7
+
8
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ type {{pascalName}}Model struct {
14
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
15
+ CreatedAt time.Time
16
+ UpdatedAt time.Time
17
+ Version int `gorm:"not null;default:1"`
18
+ // TODO: keep persistence-only fields here and map them explicitly.
19
+ }
20
+
21
+ func ({{pascalName}}Model) TableName() string {
22
+ return "{{schemaName}}.{{tableName}}"
23
+ }
24
+
25
+ func toDomain(m *{{pascalName}}Model) *domain.{{pascalName}} {
26
+ if m == nil {
27
+ return nil
28
+ }
29
+ return &domain.{{pascalName}}{ID: m.ID, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, Version: m.Version}
30
+ }
31
+
32
+ func fromDomain(m *domain.{{pascalName}}) *{{pascalName}}Model {
33
+ if m == nil {
34
+ return nil
35
+ }
36
+ return &{{pascalName}}Model{ID: m.ID, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, Version: m.Version}
37
+ }
@@ -0,0 +1,95 @@
1
+ package postgres
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+
7
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
8
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
9
+ "{{goModule}}/internal/shared/dberr"
10
+ "{{goModule}}/internal/shared/tx"
11
+
12
+ "github.com/google/uuid"
13
+ "gorm.io/gorm"
14
+ )
15
+
16
+ var _ ports.Repository = (*Repository)(nil)
17
+
18
+ type Repository struct {
19
+ db *gorm.DB
20
+ }
21
+
22
+ func NewRepository(db *gorm.DB) *Repository {
23
+ return &Repository{db: db}
24
+ }
25
+
26
+ func (r *Repository) Create(ctx context.Context, m *domain.{{pascalName}}) error {
27
+ if err := tx.From(ctx, r.db).WithContext(ctx).Create(fromDomain(m)).Error; err != nil {
28
+ return mapDatabaseError(err)
29
+ }
30
+ return nil
31
+ }
32
+
33
+ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
34
+ var rows []{{pascalName}}Model
35
+ if err := tx.From(ctx, r.db).WithContext(ctx).Order("id desc").Limit(limit).Offset(offset).Find(&rows).Error; err != nil {
36
+ return nil, mapDatabaseError(err)
37
+ }
38
+ items := make([]domain.{{pascalName}}, len(rows))
39
+ for i := range rows {
40
+ items[i] = *toDomain(&rows[i])
41
+ }
42
+ return items, nil
43
+ }
44
+
45
+ func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
46
+ var row {{pascalName}}Model
47
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "id = ?", id).Error; err != nil {
48
+ return nil, mapDatabaseError(err)
49
+ }
50
+ return toDomain(&row), nil
51
+ }
52
+
53
+ func (r *Repository) Update(ctx context.Context, m *domain.{{pascalName}}) error {
54
+ expected := m.Version
55
+ row := fromDomain(m)
56
+ row.Version = expected + 1
57
+ result := tx.From(ctx, r.db).WithContext(ctx).
58
+ Model(&{{pascalName}}Model{}).
59
+ Where("id = ? AND version = ?", m.ID, expected).
60
+ Select("*").Omit("id", "created_at").Updates(row)
61
+ if result.Error != nil {
62
+ return mapDatabaseError(result.Error)
63
+ }
64
+ if result.RowsAffected == 0 {
65
+ return domain.ErrStaleVersion
66
+ }
67
+ m.Version = row.Version
68
+ return nil
69
+ }
70
+
71
+ func (r *Repository) Delete(ctx context.Context, id uuid.UUID) error {
72
+ result := tx.From(ctx, r.db).WithContext(ctx).Delete(&{{pascalName}}Model{}, "id = ?", id)
73
+ if result.Error != nil {
74
+ return mapDatabaseError(result.Error)
75
+ }
76
+ if result.RowsAffected == 0 {
77
+ return domain.ErrNotFound
78
+ }
79
+ return nil
80
+ }
81
+
82
+ func mapDatabaseError(err error) error {
83
+ if errors.Is(err, gorm.ErrRecordNotFound) {
84
+ return domain.ErrNotFound
85
+ }
86
+ if dberr.IsDuplicate(err) {
87
+ return domain.ErrConflict
88
+ }
89
+ if dberr.IsForeignKey(err) {
90
+ return domain.ErrHasReferences
91
+ }
92
+ return err
93
+ }
94
+
95
+ // go-scaffold:repository-methods
@@ -1,4 +1,4 @@
1
- package {{pkg}}
1
+ package postgres
2
2
 
3
3
  import (
4
4
  "context"
@@ -6,7 +6,7 @@ import (
6
6
  "os"
7
7
  "testing"
8
8
 
9
- "{{goModule}}/internal/app/{{modulePath}}/model"
9
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
10
10
 
11
11
  "github.com/google/uuid"
12
12
  "gorm.io/driver/postgres"
@@ -15,7 +15,7 @@ import (
15
15
 
16
16
  // repositoryDBForTest expects the test database schema to come from the same
17
17
  // versioned SQL migrations used in production. Unit tests stay database-free;
18
- // CI sets REQUIRE_TEST_DB=true so an unavailable/unmigrated database fails
18
+ // CI sets REQUIRE_TEST_DB=true so an unavailable or unmigrated database fails
19
19
  // instead of becoming a false-green skip.
20
20
  func repositoryDBForTest(t *testing.T) *gorm.DB {
21
21
  t.Helper()
@@ -62,7 +62,7 @@ func TestRepository_CreateFindDelete(t *testing.T) {
62
62
  repo := NewRepository(repositoryDBForTest(t))
63
63
  ctx := context.Background()
64
64
  id := uuid.New()
65
- item := &model.{{pascalName}}{ID: id}
65
+ item := &domain.{{pascalName}}{ID: id, Version: 1}
66
66
 
67
67
  if err := repo.Create(ctx, item); err != nil {
68
68
  t.Fatalf("create: %v", err)
@@ -85,13 +85,13 @@ func TestRepository_CreateFindDelete(t *testing.T) {
85
85
  func TestRepository_Update_RefusesStaleVersion(t *testing.T) {
86
86
  repo := NewRepository(repositoryDBForTest(t))
87
87
  ctx := context.Background()
88
- item := &model.{{pascalName}}{ID: uuid.New()}
88
+ item := &domain.{{pascalName}}{ID: uuid.New(), Version: 1}
89
89
 
90
90
  if err := repo.Create(ctx, item); err != nil {
91
91
  t.Fatalf("create: %v", err)
92
92
  }
93
93
 
94
- first := &model.{{pascalName}}{ID: item.ID, Version: item.Version}
94
+ first := &domain.{{pascalName}}{ID: item.ID, Version: item.Version}
95
95
  if err := repo.Update(ctx, first); err != nil {
96
96
  t.Fatalf("first update: %v", err)
97
97
  }
@@ -99,8 +99,8 @@ func TestRepository_Update_RefusesStaleVersion(t *testing.T) {
99
99
  t.Fatalf("want version bumped to %d, got %d", item.Version+1, first.Version)
100
100
  }
101
101
 
102
- stale := &model.{{pascalName}}{ID: item.ID, Version: item.Version}
103
- if err := repo.Update(ctx, stale); !errors.Is(err, ErrStaleVersion) {
102
+ stale := &domain.{{pascalName}}{ID: item.ID, Version: item.Version}
103
+ if err := repo.Update(ctx, stale); !errors.Is(err, domain.ErrStaleVersion) {
104
104
  t.Fatalf("want ErrStaleVersion for the second writer, got %v", err)
105
105
  }
106
106
  if stale.Version != item.Version {
@@ -0,0 +1,54 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
6
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
7
+ "{{goModule}}/internal/shared/id"
8
+
9
+ "github.com/google/uuid"
10
+ )
11
+
12
+ type CommandPort interface {
13
+ Create(context.Context, CreateInput) (*domain.{{pascalName}}, error)
14
+ Update(context.Context, uuid.UUID, UpdateInput) (*domain.{{pascalName}}, error)
15
+ Delete(context.Context, uuid.UUID) error
16
+ // go-scaffold:command-interface
17
+ }
18
+
19
+ type CommandHandler struct {
20
+ repo ports.CommandRepository
21
+ }
22
+
23
+ func NewCommandHandler(repo ports.CommandRepository) *CommandHandler {
24
+ return &CommandHandler{repo: repo}
25
+ }
26
+
27
+ func (h *CommandHandler) Create(ctx context.Context, in CreateInput) (*domain.{{pascalName}}, error) {
28
+ // TODO: map in to real domain fields and enforce invariants.
29
+ _ = in
30
+ m := &domain.{{pascalName}}{ID: id.New(), Version: 1}
31
+ if err := h.repo.Create(ctx, m); err != nil {
32
+ return nil, err
33
+ }
34
+ return m, nil
35
+ }
36
+
37
+ func (h *CommandHandler) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (*domain.{{pascalName}}, error) {
38
+ m, err := h.repo.FindByID(ctx, id)
39
+ if err != nil {
40
+ return nil, err
41
+ }
42
+ // TODO: map in to real domain fields before saving.
43
+ m.Version = in.Version
44
+ if err := h.repo.Update(ctx, m); err != nil {
45
+ return nil, err
46
+ }
47
+ return m, nil
48
+ }
49
+
50
+ func (h *CommandHandler) Delete(ctx context.Context, id uuid.UUID) error {
51
+ return h.repo.Delete(ctx, id)
52
+ }
53
+
54
+ // go-scaffold:command-methods
@@ -0,0 +1,25 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+
6
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
7
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
8
+ )
9
+
10
+ type CommandPort interface {
11
+ // go-scaffold:command-interface
12
+ }
13
+
14
+ type CommandHandler struct {
15
+ repo ports.CommandRepository
16
+ }
17
+
18
+ func NewCommandHandler(repo ports.CommandRepository) *CommandHandler {
19
+ return &CommandHandler{repo: repo}
20
+ }
21
+
22
+ var _ context.Context
23
+ var _ *domain.{{pascalName}}
24
+
25
+ // go-scaffold:command-methods
@@ -0,0 +1,66 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+
7
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
8
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ // repositoryStub is shared by command and query tests. It intentionally
14
+ // exposes one function per operation so a focused test can assert exactly
15
+ // which side of the CQRS boundary was called.
16
+ type repositoryStub struct {
17
+ createFn func(context.Context, *domain.{{pascalName}}) error
18
+ findAllFn func(context.Context, int, int) ([]domain.{{pascalName}}, error)
19
+ findByIDFn func(context.Context, uuid.UUID) (*domain.{{pascalName}}, error)
20
+ updateFn func(context.Context, *domain.{{pascalName}}) error
21
+ deleteFn func(context.Context, uuid.UUID) error
22
+ // go-scaffold:repository-stub-fields
23
+ }
24
+
25
+ func (s *repositoryStub) Create(ctx context.Context, m *domain.{{pascalName}}) error {
26
+ if s.createFn == nil {
27
+ panic("unexpected repository.Create call")
28
+ }
29
+ return s.createFn(ctx, m)
30
+ }
31
+ func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
32
+ if s.findAllFn == nil {
33
+ panic("unexpected repository.FindAll call")
34
+ }
35
+ return s.findAllFn(ctx, limit, offset)
36
+ }
37
+ func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
38
+ if s.findByIDFn == nil {
39
+ panic("unexpected repository.FindByID call")
40
+ }
41
+ return s.findByIDFn(ctx, id)
42
+ }
43
+ func (s *repositoryStub) Update(ctx context.Context, m *domain.{{pascalName}}) error {
44
+ if s.updateFn == nil {
45
+ panic("unexpected repository.Update call")
46
+ }
47
+ return s.updateFn(ctx, m)
48
+ }
49
+ func (s *repositoryStub) Delete(ctx context.Context, id uuid.UUID) error {
50
+ if s.deleteFn == nil {
51
+ panic("unexpected repository.Delete call")
52
+ }
53
+ return s.deleteFn(ctx, id)
54
+ }
55
+
56
+ var _ ports.CommandRepository = (*repositoryStub)(nil)
57
+ var _ ports.QueryRepository = (*repositoryStub)(nil)
58
+
59
+ func TestCQRSHandlersCompose(t *testing.T) {
60
+ repo := &repositoryStub{}
61
+ if NewCommandHandler(repo) == nil || NewQueryHandler(repo) == nil {
62
+ t.Fatal("expected command and query handlers")
63
+ }
64
+ }
65
+
66
+ // go-scaffold:repository-stub-methods
@@ -0,0 +1,35 @@
1
+ package application
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/{{modulePath}}/domain"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ // CreateInput and UpdateInput are transport-neutral application inputs. JSON
12
+ // names and binding/validation tags stay in adapters/inbound/http/dto.go.
13
+ type CreateInput struct {
14
+ // TODO: add command fields and validate them in the application/domain.
15
+ }
16
+
17
+ type UpdateInput struct {
18
+ // Version is the optimistic-concurrency version the client last observed.
19
+ Version int
20
+ // TODO: add update fields.
21
+ }
22
+
23
+ // Response is a transport-neutral application output. The inbound adapter
24
+ // maps it to its protocol response before serialization.
25
+ type Response struct {
26
+ ID uuid.UUID
27
+ CreatedAt time.Time
28
+ Version int
29
+ }
30
+
31
+ func ToResponse(m *domain.{{pascalName}}) Response {
32
+ return Response{ID: m.ID, CreatedAt: m.CreatedAt, Version: m.Version}
33
+ }
34
+
35
+ // go-scaffold:dto