@nakedev/go-scaffold 0.1.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 (73) hide show
  1. package/README.md +223 -0
  2. package/bin/go-scaffold.js +2 -0
  3. package/dist/commands/create.js +57 -0
  4. package/dist/commands/generate.js +97 -0
  5. package/dist/commands/method.js +70 -0
  6. package/dist/commands/remove.js +72 -0
  7. package/dist/index.js +138 -0
  8. package/dist/prompts/create-wizard.js +43 -0
  9. package/dist/prompts/generate-wizard.js +68 -0
  10. package/dist/templates/create-manifest.js +110 -0
  11. package/dist/templates/module-manifest.js +28 -0
  12. package/dist/types.js +2 -0
  13. package/dist/utils/config.js +53 -0
  14. package/dist/utils/main-patcher.js +59 -0
  15. package/dist/utils/marker-patch.js +63 -0
  16. package/dist/utils/method-patcher.js +271 -0
  17. package/dist/utils/migrations.js +17 -0
  18. package/dist/utils/module-paths.js +33 -0
  19. package/dist/utils/naming.js +160 -0
  20. package/dist/utils/openapi-patcher.js +47 -0
  21. package/dist/utils/template-renderer.js +51 -0
  22. package/package.json +49 -0
  23. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +83 -0
  24. package/templates/create/base/.env.example.hbs +7 -0
  25. package/templates/create/base/.github/workflows/ci.yml.hbs +46 -0
  26. package/templates/create/base/.gitignore.hbs +5 -0
  27. package/templates/create/base/.golangci.yml.hbs +32 -0
  28. package/templates/create/base/.vscode/settings.json.hbs +11 -0
  29. package/templates/create/base/AGENTS.md.hbs +68 -0
  30. package/templates/create/base/CLAUDE.md.hbs +1 -0
  31. package/templates/create/base/Makefile.hbs +93 -0
  32. package/templates/create/base/README.md.hbs +143 -0
  33. package/templates/create/base/cmd/api/main.go.hbs +116 -0
  34. package/templates/create/base/go.mod.hbs +11 -0
  35. package/templates/create/base/internal/platform/database/database.go.hbs +28 -0
  36. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +36 -0
  37. package/templates/create/base/internal/shared/config/config.go.hbs +46 -0
  38. package/templates/create/base/internal/shared/dberr/dberr.go.hbs +28 -0
  39. package/templates/create/base/internal/shared/httpx/httpx.go.hbs +37 -0
  40. package/templates/create/base/internal/shared/id/id.go.hbs +16 -0
  41. package/templates/create/base/internal/shared/middleware/error.go.hbs +33 -0
  42. package/templates/create/base/internal/shared/middleware/logger.go.hbs +23 -0
  43. package/templates/create/base/internal/shared/middleware/requestid.go.hbs +36 -0
  44. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +39 -0
  45. package/templates/create/base/migrations/.gitkeep.hbs +0 -0
  46. package/templates/create/features/docker-compose.yml.hbs +14 -0
  47. package/templates/create/features/docs/architecture.md.hbs +99 -0
  48. package/templates/create/features/docs/common/parameters.yaml.hbs +13 -0
  49. package/templates/create/features/docs/common/responses.yaml.hbs +20 -0
  50. package/templates/create/features/docs/common/schemas.yaml.hbs +23 -0
  51. package/templates/create/features/docs/health/health-livez.yaml.hbs +13 -0
  52. package/templates/create/features/docs/health/health-readyz.yaml.hbs +21 -0
  53. package/templates/create/features/docs/openapi.yaml.hbs +33 -0
  54. package/templates/create/features/docs/patterns.md.hbs +119 -0
  55. package/templates/create/features/docs/techstack.md.hbs +38 -0
  56. package/templates/generate/module/docs/collection.yaml.hbs +36 -0
  57. package/templates/generate/module/docs/item.yaml.hbs +37 -0
  58. package/templates/generate/module/docs/schemas.yaml.hbs +13 -0
  59. package/templates/generate/module/dto.go.hbs +29 -0
  60. package/templates/generate/module/errors.go.hbs +28 -0
  61. package/templates/generate/module/handler.go.hbs +103 -0
  62. package/templates/generate/module/handler_test.go.hbs +105 -0
  63. package/templates/generate/module/migration.down.sql.hbs +1 -0
  64. package/templates/generate/module/migration.up.sql.hbs +5 -0
  65. package/templates/generate/module/minimal/dto.go.hbs +26 -0
  66. package/templates/generate/module/minimal/handler.go.hbs +24 -0
  67. package/templates/generate/module/minimal/handler_test.go.hbs +70 -0
  68. package/templates/generate/module/minimal/service.go.hbs +45 -0
  69. package/templates/generate/module/minimal/service_test.go.hbs +54 -0
  70. package/templates/generate/module/model/model.go.hbs +20 -0
  71. package/templates/generate/module/repository.go.hbs +49 -0
  72. package/templates/generate/module/service.go.hbs +97 -0
  73. package/templates/generate/module/service_test.go.hbs +65 -0
@@ -0,0 +1,36 @@
1
+ package apperror
2
+
3
+ import "net/http"
4
+
5
+ // AppError is an error that knows both its HTTP status and its payload.
6
+ // Lower layers (service/repo) can return one without importing net/http themselves.
7
+ type AppError struct {
8
+ HTTPStatus int `json:"-"`
9
+ Code string `json:"code"`
10
+ Message string `json:"message"`
11
+ Details any `json:"details,omitempty"`
12
+ RequestID string `json:"request_id,omitempty"` // filled in by the error middleware
13
+ }
14
+
15
+ func (e *AppError) Error() string { return e.Message }
16
+
17
+ func New(status int, code, msg string) *AppError {
18
+ return &AppError{HTTPStatus: status, Code: code, Message: msg}
19
+ }
20
+
21
+ func NewValidation(msg string, details any) *AppError {
22
+ return &AppError{HTTPStatus: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: msg, Details: details}
23
+ }
24
+
25
+ func NewNotFound(msg string) *AppError {
26
+ return &AppError{HTTPStatus: http.StatusNotFound, Code: "NOT_FOUND", Message: msg}
27
+ }
28
+
29
+ func NewConflict(msg string) *AppError {
30
+ return &AppError{HTTPStatus: http.StatusConflict, Code: "CONFLICT", Message: msg}
31
+ }
32
+
33
+ func NewInternal() *AppError {
34
+ // ponytail: never leak internal details to the client, log separately in middleware
35
+ return &AppError{HTTPStatus: http.StatusInternalServerError, Code: "INTERNAL", Message: "internal server error"}
36
+ }
@@ -0,0 +1,46 @@
1
+ package config
2
+
3
+ import (
4
+ "os"
5
+ "strconv"
6
+ "time"
7
+ )
8
+
9
+ // Config loads from env (with dev-friendly defaults).
10
+ type Config struct {
11
+ Port string
12
+ DBDSN string
13
+ LogLevel string
14
+ AutoMigrate bool
15
+ DBMaxOpenConns int
16
+ DBMaxIdleConns int
17
+ DBConnMaxLifetime time.Duration
18
+ }
19
+
20
+ func Load() Config {
21
+ return Config{
22
+ Port: env("PORT", "8080"),
23
+ DBDSN: env("DB_DSN", "postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable"),
24
+ LogLevel: env("LOG_LEVEL", "info"),
25
+ AutoMigrate: env("AUTO_MIGRATE", "true") == "true",
26
+ DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 10),
27
+ DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 10),
28
+ DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MIN", 5)) * time.Minute,
29
+ }
30
+ }
31
+
32
+ func env(k, def string) string {
33
+ if v := os.Getenv(k); v != "" {
34
+ return v
35
+ }
36
+ return def
37
+ }
38
+
39
+ func envInt(k string, def int) int {
40
+ if v := os.Getenv(k); v != "" {
41
+ if n, err := strconv.Atoi(v); err == nil {
42
+ return n
43
+ }
44
+ }
45
+ return def
46
+ }
@@ -0,0 +1,28 @@
1
+ // Package dberr translates driver/GORM errors into "which constraint kind
2
+ // broke" — glue with no I/O, so it lives in shared/, not platform/.
3
+ // Centralized so every domain maps DB errors to HTTP status the same way,
4
+ // instead of copy-pasting the logic per module.
5
+ package dberr
6
+
7
+ import (
8
+ "errors"
9
+ "strings"
10
+
11
+ "gorm.io/gorm"
12
+ )
13
+
14
+ // IsDuplicate reports a unique constraint violation — gorm.ErrDuplicatedKey
15
+ // (requires TranslateError:true), with a string fallback in case the driver
16
+ // doesn't translate: postgres "duplicate key value violates unique constraint".
17
+ func IsDuplicate(err error) bool {
18
+ return errors.Is(err, gorm.ErrDuplicatedKey) ||
19
+ strings.Contains(strings.ToLower(err.Error()), "unique constraint")
20
+ }
21
+
22
+ // IsForeignKey reports a foreign key violation (referencing a missing parent,
23
+ // or deleting a parent that's still referenced).
24
+ // gorm.ErrForeignKeyViolated + string fallback: postgres "...foreign key constraint...".
25
+ func IsForeignKey(err error) bool {
26
+ return errors.Is(err, gorm.ErrForeignKeyViolated) ||
27
+ strings.Contains(strings.ToLower(err.Error()), "foreign key")
28
+ }
@@ -0,0 +1,37 @@
1
+ package httpx
2
+
3
+ import (
4
+ "errors"
5
+ "strings"
6
+
7
+ "{{goModule}}/internal/shared/apperror"
8
+
9
+ "github.com/gin-gonic/gin"
10
+ "github.com/go-playground/validator/v10"
11
+ "github.com/google/uuid"
12
+ )
13
+
14
+ // ParseID reads the ":id" route param as a UUID — shared by every domain
15
+ // whose resource is identified by id.
16
+ func ParseID(c *gin.Context) (uuid.UUID, bool) {
17
+ id, err := uuid.Parse(c.Param("id"))
18
+ if err != nil {
19
+ c.Error(apperror.NewValidation("invalid id", nil))
20
+ return uuid.Nil, false
21
+ }
22
+ return id, true
23
+ }
24
+
25
+ // BindErr converts a validator error into a validation payload with a field
26
+ // map (shared by every domain).
27
+ func BindErr(err error) *apperror.AppError {
28
+ var ve validator.ValidationErrors
29
+ if errors.As(err, &ve) {
30
+ details := make(map[string]string, len(ve))
31
+ for _, fe := range ve {
32
+ details[strings.ToLower(fe.Field())] = fe.Tag()
33
+ }
34
+ return apperror.NewValidation("invalid input", details)
35
+ }
36
+ return apperror.NewValidation("invalid json body", nil)
37
+ }
@@ -0,0 +1,16 @@
1
+ // Package id creates entity identifiers — "the whole system uses UUID v7" is
2
+ // decided in one place, so changing the scheme is a one-file change.
3
+ package id
4
+
5
+ import "github.com/google/uuid"
6
+
7
+ // New returns a UUID v7 (time-ordered, so rows sort by creation time and the
8
+ // B-tree takes fewer page splits than v4 random IDs under heavy writes).
9
+ // Falls back to v4 if entropy fails (practically impossible — reads
10
+ // crypto/rand), so the entity-creation path never has to handle the error.
11
+ func New() uuid.UUID {
12
+ if v7, err := uuid.NewV7(); err == nil {
13
+ return v7
14
+ }
15
+ return uuid.New()
16
+ }
@@ -0,0 +1,33 @@
1
+ package middleware
2
+
3
+ import (
4
+ "errors"
5
+ "log/slog"
6
+
7
+ "{{goModule}}/internal/shared/apperror"
8
+
9
+ "github.com/gin-gonic/gin"
10
+ )
11
+
12
+ // Error reads the last error attached via c.Error() and renders it once, so
13
+ // handlers only ever do c.Error(err); return — no c.JSON per call site.
14
+ func Error() gin.HandlerFunc {
15
+ return func(c *gin.Context) {
16
+ c.Next()
17
+
18
+ err := c.Errors.Last()
19
+ if err == nil {
20
+ return
21
+ }
22
+
23
+ var appErr *apperror.AppError
24
+ if !errors.As(err.Err, &appErr) {
25
+ // Unexpected error: log the real thing, answer the client generically.
26
+ slog.Error("unhandled error", "error", err.Err, "request_id", c.GetString(RequestIDKey))
27
+ appErr = apperror.NewInternal()
28
+ }
29
+
30
+ appErr.RequestID = c.GetString(RequestIDKey)
31
+ c.JSON(appErr.HTTPStatus, gin.H{"error": appErr})
32
+ }
33
+ }
@@ -0,0 +1,23 @@
1
+ package middleware
2
+
3
+ import (
4
+ "log/slog"
5
+ "time"
6
+
7
+ "github.com/gin-gonic/gin"
8
+ )
9
+
10
+ // Logger logs each request as structured JSON instead of gin's default text logger.
11
+ func Logger(log *slog.Logger) gin.HandlerFunc {
12
+ return func(c *gin.Context) {
13
+ start := time.Now()
14
+ c.Next()
15
+ log.Info("request",
16
+ "method", c.Request.Method,
17
+ "path", c.Request.URL.Path,
18
+ "status", c.Writer.Status(),
19
+ "duration_ms", time.Since(start).Milliseconds(),
20
+ "request_id", c.GetString(RequestIDKey),
21
+ )
22
+ }
23
+ }
@@ -0,0 +1,36 @@
1
+ package middleware
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "encoding/hex"
6
+
7
+ "github.com/gin-gonic/gin"
8
+ )
9
+
10
+ const (
11
+ RequestIDKey = "request_id"
12
+ requestIDHeader = "X-Request-ID"
13
+ )
14
+
15
+ // RequestID reads X-Request-ID from the client, generating one if absent,
16
+ // stores it in context, and echoes it back in the response header — used to
17
+ // correlate logs/errors across services.
18
+ func RequestID() gin.HandlerFunc {
19
+ return func(c *gin.Context) {
20
+ id := c.GetHeader(requestIDHeader)
21
+ if id == "" {
22
+ id = newID()
23
+ }
24
+ c.Set(RequestIDKey, id)
25
+ c.Header(requestIDHeader, id)
26
+ c.Next()
27
+ }
28
+ }
29
+
30
+ func newID() string {
31
+ b := make([]byte, 8)
32
+ if _, err := rand.Read(b); err != nil {
33
+ return "unknown"
34
+ }
35
+ return hex.EncodeToString(b)
36
+ }
@@ -0,0 +1,39 @@
1
+ package pagination
2
+
3
+ import (
4
+ "strconv"
5
+
6
+ "github.com/gin-gonic/gin"
7
+ )
8
+
9
+ const (
10
+ defaultLimit = 20
11
+ maxLimit = 100
12
+ )
13
+
14
+ // Params is a validated limit/offset pair (default 20, capped at 100).
15
+ type Params struct {
16
+ Limit int
17
+ Offset int
18
+ }
19
+
20
+ // Parse reads ?limit=&offset= off the request so every feature parses the
21
+ // same way.
22
+ func Parse(c *gin.Context) Params {
23
+ p := Params{Limit: defaultLimit}
24
+ if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
25
+ p.Limit = v
26
+ }
27
+ if p.Limit > maxLimit {
28
+ p.Limit = maxLimit
29
+ }
30
+ if v, err := strconv.Atoi(c.Query("offset")); err == nil && v >= 0 {
31
+ p.Offset = v
32
+ }
33
+ return p
34
+ }
35
+
36
+ // Response wraps a list so every feature responds with the same shape.
37
+ func (p Params) Response(data any) gin.H {
38
+ return gin.H{"data": data, "limit": p.Limit, "offset": p.Offset}
39
+ }
File without changes
@@ -0,0 +1,14 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:16-alpine
4
+ environment:
5
+ POSTGRES_USER: postgres
6
+ POSTGRES_PASSWORD: postgres
7
+ POSTGRES_DB: {{dbName}}
8
+ ports:
9
+ - "5432:5432"
10
+ volumes:
11
+ - pgdata:/var/lib/postgresql/data
12
+
13
+ volumes:
14
+ pgdata:
@@ -0,0 +1,99 @@
1
+ # Architecture Decision Record: {{projectName}}
2
+
3
+ > Status: generated by `@nakedev/go-scaffold`
4
+ > Scope: initial scaffold
5
+
6
+ ## 1. Topology
7
+
8
+ **Decision:** Single Go binary (`cmd/api`), REST over Gin.
9
+ **Rationale:** One process serves all domains; a second entry point
10
+ (`cmd/worker`, `cmd/migrate`) gets its own `main.go` only once there's a real
11
+ second process — `main.go` has no importers, so adding one later touches no
12
+ other file.
13
+
14
+ ## 2. Package Layout
15
+
16
+ ```text
17
+ cmd/
18
+ └── api/main.go # config, slog, graceful shutdown, wires domains
19
+ internal/
20
+ ├── platform/ # talks to real external systems (DB, later: cache, queue, mail, ...)
21
+ │ └── database/
22
+ ├── shared/ # pure logic/framework glue, no I/O
23
+ │ ├── config/ apperror/ dberr/ httpx/ middleware/ pagination/
24
+ └── app/ # domain packages (one per feature)
25
+ └── <domain>/
26
+ ```
27
+
28
+ **Decision:** `platform/` vs `shared/` is split by whether the code talks to
29
+ a real external system.
30
+ **Rationale:** Code that does I/O against Postgres/cache/queue/SMTP/S3 →
31
+ `platform/`. Pure logic or HTTP-framework glue with no I/O → `shared/`. Never
32
+ add a `shared/utils` or similarly generic package — name it after what it
33
+ actually does.
34
+
35
+ ## 3. API Style
36
+
37
+ **Decision:** REST over Gin, one handler package per domain, every route
38
+ grouped under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}.
39
+ **Rationale:** `handler → service → repository`, one direction, all three
40
+ layers in the same package — no cross-domain imports. The prefix is a
41
+ single project-wide choice made at `create` time (`--api-prefix`) — there is
42
+ no per-domain versioning; a domain that needs a real breaking change gets a
43
+ new domain package (or a new field on the existing DTO), not a duplicated
44
+ model pointed at the same table under a different URL.
45
+
46
+ ## 4. Response and Error Model
47
+
48
+ **Decision:** Central `apperror.AppError` (HTTP status + machine-readable
49
+ `code` + message), rendered once by `middleware.Error()`.
50
+ **Rationale:** Handlers only ever do `c.Error(err); return` — no `c.JSON` per
51
+ call site, no error-shape drift between domains.
52
+
53
+ - Domain-specific errors live in each package's `errors.go` (e.g.
54
+ `USER_NOT_FOUND`, not a bare `NOT_FOUND`)
55
+ - DB errors are classified once in `shared/dberr` (`IsDuplicate`,
56
+ `IsForeignKey`) and mapped to the right HTTP status per domain
57
+
58
+ ## 5. Pagination
59
+
60
+ **Decision:** Shared `limit`/`offset` parsing and response envelope
61
+ (`shared/pagination`), used by every list endpoint.
62
+ **Rationale:** One implementation, one response shape (`{data, limit,
63
+ offset}`) — no per-domain reinvention.
64
+
65
+ ## 6. Persistence
66
+
67
+ **Decision:** PostgreSQL + GORM, schema managed by
68
+ [golang-migrate](https://github.com/golang-migrate/migrate).
69
+ **Rationale:** `AUTO_MIGRATE=true` runs GORM's AutoMigrate in dev for speed;
70
+ prod runs `migrate up` as a separate, versioned, rollback-capable step —
71
+ AutoMigrate is add-only and locks tables once there's real data.
72
+
73
+ {{#if docker}}- `docker-compose.yml` provides the local Postgres instance
74
+ {{/if}}
75
+ ## 7. IDs
76
+
77
+ **Decision:** UUID v7 for every entity, generated app-side
78
+ (`shared/id.New()`), not by a DB default.
79
+ **Rationale:** Time-ordered IDs sort by creation time, reducing B-tree page
80
+ splits versus random v4 under heavy writes — and the app has the ID before
81
+ insert, so it doesn't need `gen_random_uuid()`.
82
+
83
+ {{#if openapiDocs}}
84
+ ## 8. API Documentation
85
+
86
+ **Decision:** Hand-written OpenAPI spec (`docs/openapi.yaml`), split by
87
+ domain module, served under `/docs` (so relative `$ref`s to sibling files
88
+ resolve over HTTP too — the index alone isn't enough for a renderer like
89
+ Scalar/Swagger UI/Redoc to follow them).
90
+ **Rationale:** Cheap while the endpoint count is low; switch to
91
+ comment-generated (swaggo) if hand-updates start drifting.
92
+ {{/if}}
93
+
94
+ ## Evolution Notes
95
+
96
+ - `go-scaffold generate module <name>` adds a new domain package and
97
+ wires it into `cmd/api/main.go`
98
+ - This document only reflects the initial scaffold — update it as the real
99
+ architecture evolves
@@ -0,0 +1,13 @@
1
+ IdParam:
2
+ name: id
3
+ in: path
4
+ required: true
5
+ schema: { type: string, format: uuid }
6
+ Limit:
7
+ name: limit
8
+ in: query
9
+ schema: { type: integer, default: 20, maximum: 100 }
10
+ Offset:
11
+ name: offset
12
+ in: query
13
+ schema: { type: integer, default: 0, minimum: 0 }
@@ -0,0 +1,20 @@
1
+ ValidationError:
2
+ description: invalid input
3
+ content:
4
+ application/json:
5
+ schema: { $ref: './schemas.yaml#/Error' }
6
+ NotFoundError:
7
+ description: not found
8
+ content:
9
+ application/json:
10
+ schema: { $ref: './schemas.yaml#/Error' }
11
+ ConflictError:
12
+ description: conflict (duplicate, or resource still referenced)
13
+ content:
14
+ application/json:
15
+ schema: { $ref: './schemas.yaml#/Error' }
16
+ UnprocessableEntityError:
17
+ description: input well-formed but references a resource that doesn't exist
18
+ content:
19
+ application/json:
20
+ schema: { $ref: './schemas.yaml#/Error' }
@@ -0,0 +1,23 @@
1
+ PageEnvelope:
2
+ type: object
3
+ description: "envelope shared by every list endpoint — data is an array of that resource"
4
+ properties:
5
+ limit: { type: integer }
6
+ offset: { type: integer }
7
+
8
+ Error:
9
+ type: object
10
+ properties:
11
+ error:
12
+ type: object
13
+ properties:
14
+ code: { type: string, description: "machine-readable, e.g. USER_NOT_FOUND, VALIDATION_ERROR" }
15
+ message: { type: string }
16
+ details: { type: object, additionalProperties: true, nullable: true, description: "only present for VALIDATION_ERROR, field->tag that failed" }
17
+ request_id: { type: string, description: "correlates with server logs (header X-Request-ID)" }
18
+ example:
19
+ error:
20
+ code: "VALIDATION_ERROR"
21
+ message: "invalid input"
22
+ details: { email: "email" }
23
+ request_id: "a1b2c3d4e5f6a7b8"
@@ -0,0 +1,13 @@
1
+ get:
2
+ summary: Liveness probe
3
+ operationId: getLivez
4
+ tags: [health]
5
+ responses:
6
+ "200":
7
+ description: process is running
8
+ content:
9
+ application/json:
10
+ schema:
11
+ type: object
12
+ properties:
13
+ status: { type: string, example: ok }
@@ -0,0 +1,21 @@
1
+ get:
2
+ summary: Readiness probe (ping DB)
3
+ operationId: getReadyz
4
+ tags: [health]
5
+ responses:
6
+ "200":
7
+ description: ready
8
+ content:
9
+ application/json:
10
+ schema:
11
+ type: object
12
+ properties:
13
+ status: { type: string, example: ok }
14
+ "503":
15
+ description: DB unreachable
16
+ content:
17
+ application/json:
18
+ schema:
19
+ type: object
20
+ properties:
21
+ status: { type: string, example: unavailable }
@@ -0,0 +1,33 @@
1
+ # ponytail: hand-written, not generated from annotations — cheap while the
2
+ # endpoint count is low. Ceiling: must be updated by hand whenever an
3
+ # endpoint/DTO changes beyond what `generate module` scaffolds.
4
+ # Switch to swaggo (comment-generated) if drift becomes a recurring problem.
5
+ # multi-file: split by domain module (health/<domain>) + common/ for shared
6
+ # pieces — this file is just the index. `generate module <name>`
7
+ # adds its paths/schemas here automatically; `generate method` does not
8
+ # (endpoint-specific docs stay hand-written).
9
+ openapi: 3.0.3
10
+ info:
11
+ title: {{projectName}} API
12
+ version: "1.0.0"
13
+ description: Every resource uses UUID v7 as its id; list endpoints return the {data, limit, offset} envelope.
14
+ servers:
15
+ - url: http://localhost:8080
16
+
17
+ paths:
18
+ /livez:
19
+ $ref: './health/health-livez.yaml'
20
+ /readyz:
21
+ $ref: './health/health-readyz.yaml'
22
+ # go-scaffold:paths
23
+
24
+ components:
25
+ schemas:
26
+ PageEnvelope: { $ref: './common/schemas.yaml#/PageEnvelope' }
27
+ Error: { $ref: './common/schemas.yaml#/Error' }
28
+ # go-scaffold:schemas
29
+ responses:
30
+ ValidationError: { $ref: './common/responses.yaml#/ValidationError' }
31
+ NotFoundError: { $ref: './common/responses.yaml#/NotFoundError' }
32
+ ConflictError: { $ref: './common/responses.yaml#/ConflictError' }
33
+ UnprocessableEntityError: { $ref: './common/responses.yaml#/UnprocessableEntityError' }
@@ -0,0 +1,119 @@
1
+ # Coding Patterns: {{projectName}}
2
+
3
+ > Status: generated by `@nakedev/go-scaffold`
4
+ > Last updated: scaffold creation time
5
+
6
+ ## Domain Package Shape
7
+
8
+ Every domain generated by `go-scaffold generate module <name>` looks
9
+ like this — copy the shape by hand if you ever add one without the CLI:
10
+
11
+ ```text
12
+ internal/app/<domain>/
13
+ ├── model/ # domain model(s) + GORM table(s) — a folder, one file per table
14
+ │ └── model.go
15
+ ├── dto.go # request/response structs + mapping (never leak the model directly)
16
+ ├── errors.go # domain error catalog (<DOMAIN>_NOT_FOUND, ...)
17
+ ├── repository.go # the only place that touches the DB for this domain, every method takes ctx
18
+ ├── service.go # business logic; declares the repository interface it needs (mockable in tests)
19
+ ├── handler.go # HTTP: routing, bind, delegate, respond
20
+ ├── service_test.go # unit test, fake repo, no DB
21
+ └── handler_test.go # integration test, real Postgres, transaction rolled back per test
22
+ ```
23
+
24
+ ### Model
25
+
26
+ - Lives in its own `model` subpackage (`internal/app/<domain>/model`), imported
27
+ by the rest of the domain as `model.<Type>`
28
+ - A folder, not a single file, so a domain with more than one table (e.g.
29
+ `order` + `order_item`) adds one file per table instead of growing a single
30
+ file — `generate module` only ever creates the first one
31
+ - Every consumer inside `cmd/api/main.go` imports each domain's `model`
32
+ package under an alias (`ordermodel`, `usermodel`, ...) since they all share
33
+ the package name `model`
34
+
35
+ ## Layer Conventions
36
+
37
+ ### Handler
38
+ - Owns routing, request binding, and calling the service
39
+ - No business logic — `c.Error(err); return` on failure, nothing more
40
+ - Reads pagination via `pagination.Parse(c)`, wraps list responses with
41
+ `p.Response(out)`
42
+ - Reads the `:id` param via `httpx.ParseID(c)`
43
+
44
+ ### Service
45
+ - Contains the business logic, knows nothing about HTTP
46
+ - Declares a `repository` interface for what it needs from the data layer —
47
+ this is what makes it mockable in `service_test.go` without a DB
48
+ - Generates the ID itself via `id.New()` before calling `repository.Create`
49
+ - Maps DB errors to domain errors with `dberr.IsDuplicate` /
50
+ `dberr.IsForeignKey`, never lets a raw DB error escape
51
+
52
+ ### Repository
53
+ - The only file per domain that talks to GORM
54
+ - Every method takes `ctx context.Context` first, so a cancelled request
55
+ cancels the query
56
+ - Look up by ID with an explicit `"id = ?"` — the PK is a UUID, not an int,
57
+ and GORM can misinterpret a bare struct arg
58
+
59
+ ### DTOs
60
+ - `createInput`/`updateInput` (request, `binding:` tags) and `response`
61
+ (what's actually sent back) are separate types from the model — adding a
62
+ DB column later doesn't leak it to clients until you decide to
63
+
64
+ ### Error Catalog
65
+ - One function per error, not a shared `var` — the error middleware writes
66
+ the request ID onto the returned pointer, so a shared instance would race
67
+ across concurrent requests
68
+ - Codes are domain-specific (`ORDER_NOT_FOUND`), never the generic
69
+ `apperror.NewNotFound()` directly from a handler
70
+
71
+ ## Domains With a Foreign Key (Relations) — 3 Rules
72
+
73
+ The CLI does not scaffold relations between domains; when you add one by
74
+ hand, follow these three rules (see `internal/app/order` for a worked example
75
+ if this project has one):
76
+
77
+ 1. **Reference by ID column only** (`UserID uuid.UUID`). No GORM
78
+ associations / belongs-to — that's what keeps one domain package from
79
+ importing another.
80
+ 2. **Declare the FK constraint in migration SQL**
81
+ (`REFERENCES ... ON DELETE ...`), not a GORM tag — AutoMigrate doesn't
82
+ create the constraint, which would make dev and prod schemas diverge.
83
+ 3. **Map the FK error to the right status** via `dberr.IsForeignKey` —
84
+ inserting a reference to a missing parent, or deleting a parent that
85
+ still has children, is a client error (409/422), not a 500. Don't
86
+ pre-check existence before insert; let the DB enforce it atomically and
87
+ catch the error — a pre-check has a TOCTOU race.
88
+
89
+ ## Adding One Endpoint — `generate method`
90
+
91
+ ```bash
92
+ go-scaffold generate method <domain> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]
93
+ ```
94
+
95
+ Patches `handler.go`/`service.go` (and `repository.go` + the `repository`
96
+ interface + its `fakeRepo` test stub, for a `get --get-mode one --field`
97
+ lookup) in place, at the `// go-scaffold:*` marker comments near the end of
98
+ each file. **Don't delete those markers** — they're where the next
99
+ `generate method` call inserts. The method body is always left as a `TODO`
100
+ that compiles and returns a clean `500` (`apperror.NewInternal()`) rather
101
+ than guessing at business logic — same spirit as `generate module`'s
102
+ placeholder fields.
103
+
104
+ ## Testing Conventions
105
+
106
+ - Unit and integration tests live in the same directory as the code under
107
+ test (Go convention) — never a separate `test/` folder. `test/` is only
108
+ for e2e black-box suites or fixtures.
109
+ - `service_test.go` uses a fake repository — no DB required, always runs.
110
+ - `handler_test.go` runs against a real Postgres instance inside a
111
+ transaction that's rolled back after each test — skips automatically if
112
+ the DB isn't reachable.
113
+
114
+ ## Docs Maintenance
115
+
116
+ - This document describes the output produced by `go-scaffold create` for
117
+ the current configuration
118
+ - If new domains are generated or the architecture changes, update this doc
119
+ to match the real project