@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,83 @@
1
+ ---
2
+ name: go-scaffold
3
+ description: >
4
+ Use whenever the user asks to add, create, scaffold, wire up, or generate a
5
+ new domain/feature/resource OR a new endpoint/method in THIS project —
6
+ including indirect or paraphrased requests that imply new backend surface
7
+ area without saying "module"/"method" literally, e.g. "add a products
8
+ feature", "let admins approve orders", "we need an endpoint for X", "look
9
+ up a user by email". This project was scaffolded by go-scaffold and has a
10
+ go-scaffold.config.json at its root — check for that file before assuming
11
+ this applies. Do NOT use for: business logic inside an already-generated
12
+ method body, model/DTO field edits, foreign keys/relations between
13
+ domains, bug fixes, refactors of existing code, or any project that lacks
14
+ go-scaffold.config.json at its root.
15
+ ---
16
+
17
+ # go-scaffold
18
+
19
+ This project's domain modules (model, dto, errors, repository, service,
20
+ handler) and their endpoints are generated with the `go-scaffold` CLI — do
21
+ not hand-write a new one. Hand-writing a new `internal/app/<name>/` package,
22
+ or adding a method by editing the handler/service directly, produces a shape
23
+ that doesn't match the rest of the codebase (missing route registration,
24
+ missing AutoMigrate wiring, a `repository` interface out of sync with its
25
+ `fakeRepo` test mock, inconsistent error-catalog naming, etc.).
26
+
27
+ ## When to use this skill
28
+
29
+ - Any request for a new feature/resource/domain, even named informally
30
+ ("add invoices", "we need a way to track X")
31
+ - Any request for a new endpoint on an existing feature, even phrased as a
32
+ capability ("let users do X", "admins should be able to Y", "add a lookup
33
+ by Z")
34
+ - The user explicitly says "generate", "scaffold", or names the CLI
35
+
36
+ ## When NOT to use this skill
37
+
38
+ - Filling in business logic inside a method the CLI already generated
39
+ - Editing model fields, DTO validation, or existing method bodies
40
+ - Wiring a foreign key / relation between two domains
41
+ - Bug fixes and refactors
42
+ - The project has no `go-scaffold.config.json` at its root
43
+
44
+ ## How to use it
45
+
46
+ Run from the project root:
47
+
48
+ ```bash
49
+ go-scaffold generate module <name>
50
+ go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]
51
+ ```
52
+
53
+ `<name>` for a module should be a singular, lowercase, one-word domain noun
54
+ (e.g. `order`, not `Orders` or `order-item`) — the CLI derives the Go
55
+ package name, the pluralized REST route (`/orders`), and the table name
56
+ from it.
57
+
58
+ ### `generate module <name>`
59
+
60
+ What you get: `internal/app/<name>/{model/,dto,errors,repository,service,handler,service_test,handler_test}.go`,
61
+ a route registered under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
62
+ in `cmd/api/main.go`, the model added to the `AutoMigrate` call, and a new
63
+ file in `migrations/`. There's no per-domain versioning — the route prefix
64
+ is a single project-wide choice made at `create` time.
65
+
66
+ ### `generate method <module> <name>`
67
+
68
+ Patches the existing module in place — never overwrites a method with the
69
+ same name; if the name collides it asks for a different one instead. Route
70
+ and shape depend on `--type`:
71
+
72
+ | `--type` | Route | Notes |
73
+ |---|---|---|
74
+ | `get --get-mode all` | `GET /<plural>/<kebab-name>` | list-style, reuses `FindAll` — TODO to add real filtering |
75
+ | `get --get-mode one --field <f>` | `GET /<plural>/<f>/:<f>` | adds a real `FindBy<F>` query to the repository (and its `repository` interface + `fakeRepo` test stub); `--field` can't be `id` |
76
+ | `post` | `POST /<plural>/<kebab-name>` | adds a body DTO; service body is a TODO stub (`apperror.NewInternal()` until implemented) |
77
+ | `put` / `patch` | `<VERB> /<plural>/:id/<kebab-name>` | finds the record by id, TODO before saving (safe no-op until implemented) |
78
+ | `delete` | `DELETE /<plural>/:id/<kebab-name>` | TODO stub (`apperror.NewInternal()` until implemented) |
79
+
80
+ What you still do by hand: real field names on the model/DTOs (the generated
81
+ ones are placeholders), any foreign key to another domain (see
82
+ `docs/architect/patterns.md` for the 3 rules), and the actual business logic
83
+ behind every `TODO` the CLI leaves.
@@ -0,0 +1,7 @@
1
+ PORT=8080
2
+ DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable
3
+ LOG_LEVEL=info
4
+ AUTO_MIGRATE=true
5
+ DB_MAX_OPEN_CONNS=10
6
+ DB_MAX_IDLE_CONNS=10
7
+ DB_CONN_MAX_LIFETIME_MIN=5
@@ -0,0 +1,46 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ services:
12
+ postgres:
13
+ image: postgres:16
14
+ env:
15
+ POSTGRES_USER: postgres
16
+ POSTGRES_PASSWORD: postgres
17
+ POSTGRES_DB: {{dbName}}
18
+ ports:
19
+ - 5432:5432
20
+ options: >-
21
+ --health-cmd pg_isready
22
+ --health-interval 10s
23
+ --health-timeout 5s
24
+ --health-retries 5
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+
28
+ - uses: actions/setup-go@v5
29
+ with:
30
+ go-version-file: go.mod
31
+
32
+ - run: go build ./...
33
+
34
+ - run: go vet ./...
35
+
36
+ - name: gofmt check
37
+ run: |
38
+ diff <(gofmt -l .) <(printf '')
39
+
40
+ - uses: golangci/golangci-lint-action@v6
41
+ with:
42
+ version: latest
43
+
44
+ # TEST_DB_DSN not set: the default in handler_test.go already points at
45
+ # localhost:5432/{{dbName}} with postgres/postgres, matching the service above
46
+ - run: go test ./...
@@ -0,0 +1,5 @@
1
+ app.db
2
+ *.db
3
+ .env
4
+ bin/
5
+ docs/openapi.bundled.yaml
@@ -0,0 +1,32 @@
1
+ # golangci-lint v2 config format — v1's `run:`/`linters: enable:` + `issues: exclude-dirs:`
2
+ # top-level keys were replaced by `version: "2"` + `linters:`/`formatters:` with their own
3
+ # `exclusions:` blocks (see https://golangci-lint.run/docs/product/migration-guide/).
4
+ version: "2"
5
+
6
+ linters:
7
+ enable:
8
+ - errcheck
9
+ - govet
10
+ - ineffassign
11
+ - staticcheck
12
+ - unused
13
+ settings:
14
+ errcheck:
15
+ exclude-functions:
16
+ # gin.Context.Error()'s return value is intentionally never checked —
17
+ # it just appends to Context.Errors for middleware.Error() to render,
18
+ # the "handling" of the error already happened via `return` right after.
19
+ - (*github.com/gin-gonic/gin.Context).Error
20
+ exclusions:
21
+ paths:
22
+ - migrations
23
+
24
+ formatters:
25
+ # gofmt only, not goimports: the CLI's own gofmtTree() only runs `gofmt -w .`
26
+ # (goimports needs a separate binary install), so keep the lint config matching
27
+ # what actually formats generated code, or every fresh project starts pre-failing.
28
+ enable:
29
+ - gofmt
30
+ exclusions:
31
+ paths:
32
+ - migrations
@@ -0,0 +1,11 @@
1
+ {
2
+ "go.useLanguageServer": true,
3
+ "go.lintTool": "golangci-lint",
4
+ "go.formatTool": "gofmt",
5
+ "[go]": {
6
+ "editor.formatOnSave": true,
7
+ "editor.codeActionsOnSave": {
8
+ "source.organizeImports": "explicit"
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,68 @@
1
+ # Agent Guidance: {{projectName}}
2
+
3
+ This project was scaffolded by `@nakedev/go-scaffold` and is maintained using
4
+ its `generate` subcommands — new domain modules and new endpoints are not
5
+ hand-written.
6
+
7
+ ## Hard rule: use the CLI to add a new domain module or a new endpoint
8
+
9
+ If you are about to create a **new** domain (a new `internal/app/<name>/`
10
+ package with its own model/handler/service/repository), or add a **new**
11
+ endpoint to an existing one — stop and run the CLI instead:
12
+
13
+ ```bash
14
+ go-scaffold generate module <name>
15
+ go-scaffold generate method <module> <name> --type <get|post|put|patch|delete>
16
+ ```
17
+
18
+ This applies **even when the request doesn't say "module"/"method" or name
19
+ the CLI at all**. Recognize indirect asks as generation work, for example:
20
+
21
+ - "add a products feature" → a new domain (`generate module products`)
22
+ - "let admins approve orders" → a new method on an existing module
23
+ (`generate method orders approve --type patch`)
24
+ - "we need an endpoint that lists overdue invoices" → a new `get` method
25
+ (`generate method invoices findOverdue --type get --get-mode all`)
26
+ - "add a way to look up a user by email" → a new `get` lookup
27
+ (`generate method users findByEmail --type get --get-mode one --field email`)
28
+
29
+ Run `go-scaffold generate module --help` / `generate method --help` before
30
+ hand-rolling anything that looks like scaffolding.
31
+
32
+ ## What the CLI does NOT do — these are still done by hand, as normal
33
+
34
+ - Business logic inside a generated method (the CLI leaves a TODO-marked
35
+ stub — filling in real behavior is your job)
36
+ - Adding fields to a generated model/DTO beyond `id`/`created_at`/`updated_at`
37
+ - Foreign keys / relations between domains — add the column, the migration
38
+ `REFERENCES ... ON DELETE ...` clause, and the `dberr.IsForeignKey` mapping
39
+ yourself; see `docs/architect/patterns.md` for the 3 rules domains with a
40
+ relation must follow
41
+ - Bug fixes, refactors, or edits to existing logic
42
+ - Anything in a project that does **not** have `go-scaffold.config.json` at
43
+ its root — that means the project wasn't scaffolded by this CLI, and this
44
+ rule does not apply
45
+
46
+ ## Command quick reference
47
+
48
+ - `go-scaffold generate module <name>` — model + dto + errors + repository +
49
+ service + handler + tests, wired into `cmd/api/main.go` (AutoMigrate +
50
+ route registration) and appended to `migrations/`. Use `--no-full` for a
51
+ bare skeleton (no default CRUD/routes) when you'd rather add endpoints one
52
+ at a time with `generate method`
53
+ - `go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]` —
54
+ patches an *existing* module's handler + service (and repository, for a
55
+ `get --get-mode one --field` lookup) in place; never overwrites a method
56
+ with the same name — pick a different one if it collides. Endpoint docs in
57
+ `docs/openapi.yaml` are **not** auto-updated for methods — the command
58
+ prints the route so you can add the spec entry by hand
59
+ - `go-scaffold remove module <name>` — the inverse of `generate module`:
60
+ deletes the package and un-wires main.go / openapi.yaml / migrations. Use
61
+ this to drop a domain instead of hand-deleting the folder (a partial
62
+ hand-delete leaves duplicate wiring behind)
63
+ - Every route in this project is grouped under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
64
+ (set once at `create` time via `--api-prefix`) — there is no per-domain
65
+ versioning; a breaking API change gets a new domain package or a new DTO
66
+ field, not a duplicated model
67
+ - Full architecture rationale for this project's configuration: see
68
+ `docs/architect/`
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,93 @@
1
+ DB_HOST ?= localhost
2
+ DB_PORT ?= 5432
3
+ DB_USER ?= postgres
4
+ DB_NAME ?= {{dbName}}
5
+ PGPASSWORD ?= postgres
6
+
7
+ .PHONY: run build test fmt vet lint tidy db-create db-drop migrate-up migrate-down{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
8
+
9
+ run:
10
+ go run ./cmd/api
11
+
12
+ build:
13
+ go build -o bin/api ./cmd/api
14
+
15
+ test:
16
+ go test ./...
17
+
18
+ fmt:
19
+ gofmt -w .
20
+
21
+ vet:
22
+ go vet ./...
23
+
24
+ lint:
25
+ golangci-lint run
26
+
27
+ tidy:
28
+ go mod tidy
29
+
30
+ # creates the database itself (not the schema — AUTO_MIGRATE or migrate-up does that).
31
+ # connects to the always-present "postgres" maintenance DB to run CREATE DATABASE,
32
+ # since the target DB may not exist yet. Safe to re-run — skips if it already exists.
33
+ # \gexec only works read from stdin, not through -c, hence the pipe.
34
+ # no local psql? falls back to `docker exec` into whatever container is publishing
35
+ # DB_PORT — the project's own `docker compose up`'d postgres, or an unrelated
36
+ # shared Postgres container you already have running, either works the same way.
37
+ # override DB_HOST/DB_PORT/DB_USER/DB_NAME/PGPASSWORD to point at a different server.
38
+ db-create:
39
+ @if command -v psql >/dev/null 2>&1; then \
40
+ echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
41
+ PGPASSWORD=$(PGPASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d postgres && echo "SUCCESS! database $(DB_NAME) is ready"; \
42
+ elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
43
+ container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
44
+ if [ -z "$$container" ]; then \
45
+ echo "psql not found and no container is publishing port $(DB_PORT) — install the PostgreSQL client (brew install libpq && brew link --force libpq), or start Postgres any way you like and re-run" >&2; \
46
+ exit 1; \
47
+ fi; \
48
+ echo "no local psql — using docker exec into $$container"; \
49
+ echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
50
+ docker exec -i -e PGPASSWORD=$(PGPASSWORD) $$container psql -U $(DB_USER) -d postgres && echo "SUCCESS! database $(DB_NAME) is ready"; \
51
+ else \
52
+ echo "psql not found — install the PostgreSQL client (brew install libpq && brew link --force libpq) to connect to $(DB_HOST):$(DB_PORT)" >&2; \
53
+ exit 1; \
54
+ fi
55
+
56
+ db-drop:
57
+ @if command -v psql >/dev/null 2>&1; then \
58
+ PGPASSWORD=$(PGPASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d postgres -c "DROP DATABASE IF EXISTS $(DB_NAME);" && echo "SUCCESS! database $(DB_NAME) dropped"; \
59
+ elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
60
+ container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
61
+ if [ -z "$$container" ]; then \
62
+ echo "psql not found and no container is publishing port $(DB_PORT) — install the PostgreSQL client (brew install libpq && brew link --force libpq), or start Postgres any way you like and re-run" >&2; \
63
+ exit 1; \
64
+ fi; \
65
+ docker exec -i -e PGPASSWORD=$(PGPASSWORD) $$container psql -U $(DB_USER) -d postgres -c "DROP DATABASE IF EXISTS $(DB_NAME);" && echo "SUCCESS! database $(DB_NAME) dropped"; \
66
+ else \
67
+ echo "psql not found — install the PostgreSQL client (brew install libpq && brew link --force libpq) to connect to $(DB_HOST):$(DB_PORT)" >&2; \
68
+ exit 1; \
69
+ fi
70
+
71
+ migrate-up:
72
+ migrate -path migrations -database "$$DB_DSN" up
73
+
74
+ migrate-down:
75
+ migrate -path migrations -database "$$DB_DSN" down 1
76
+ {{#if openapiDocs}}
77
+
78
+ # docs/openapi.yaml is hand-written and split across sibling files via relative
79
+ # $ref (common/, health/, per-domain folders) — fine for tools that resolve
80
+ # $ref over HTTP (Scalar, Swagger UI, Redoc), but importers that read a local
81
+ # file as-is (Bruno, some Postman flows) never follow $ref and see zero routes.
82
+ # This bundles everything into one fully-resolved file for those tools.
83
+ openapi-bundle:
84
+ npx --yes @redocly/cli bundle docs/openapi.yaml -o docs/openapi.bundled.yaml
85
+ {{/if}}
86
+ {{#if docker}}
87
+
88
+ docker-up:
89
+ docker compose up -d
90
+
91
+ docker-down:
92
+ docker compose down
93
+ {{/if}}
@@ -0,0 +1,143 @@
1
+ # {{projectName}}
2
+
3
+ Gin + GORM backend, scaffolded by [go-scaffold](https://github.com/nakedev/go-scaffold). Organized by feature (domain), Postgres-backed, schema managed with [golang-migrate](https://github.com/golang-migrate/migrate).
4
+
5
+ This is a bare skeleton — no domain modules yet. Add one with:
6
+
7
+ ```bash
8
+ go-scaffold generate module orders
9
+ ```
10
+
11
+ See `docs/architect/` for the conventions every generated module follows, and `AGENTS.md`/`CLAUDE.md` if you're working with an AI coding agent in this repo.
12
+
13
+ ## Layout
14
+
15
+ ```
16
+ cmd/
17
+ └── api/
18
+ └── main.go # config, slog, graceful shutdown, wires domains — the only entry point for now
19
+ internal/
20
+ ├── platform/ # talks to real external systems (DB, later: cache, queue, mail, ...)
21
+ │ └── database/ # opens the connection + pool (GORM)
22
+ ├── shared/ # pure logic/framework glue, no I/O
23
+ │ ├── config/ # loads config from env
24
+ │ ├── apperror/ # central error type (status + payload)
25
+ │ ├── dberr/ # maps DB errors to constraint kind (IsDuplicate, IsForeignKey) — shared by every domain
26
+ │ ├── httpx/ # HTTP helpers shared by every domain (ParseID, BindErr)
27
+ │ ├── middleware/ # RequestID, Logger (slog), Error
28
+ │ └── pagination/ # parses ?limit=&offset=, response envelope
29
+ └── app/ # domain packages — empty until you `generate module`
30
+ ```
31
+
32
+ ## Run
33
+
34
+ ```bash
35
+ {{#if docker}}
36
+ make docker-up # local Postgres (postgres:5432)
37
+ {{/if}}
38
+ make db-create # create the {{dbName}} database itself (once — safe to re-run)
39
+ go mod tidy
40
+ make run # AUTO_MIGRATE=true creates the schema automatically in dev
41
+ # or override with env (the app reads os.Getenv directly, see .env.example):
42
+ export $(grep -v '^#' .env.example | xargs) && make run
43
+ ```
44
+
45
+ `make db-create` connects to Postgres at `DB_HOST`/`DB_PORT`/`DB_USER` (default:
46
+ `localhost`/`5432`/`postgres`, matching `.env.example`) using the `psql`
47
+ client — works the same whether Postgres came from `make docker-up` or an
48
+ existing instance you already have running. Override any of
49
+ `DB_HOST`/`DB_PORT`/`DB_USER`/`DB_NAME`/`PGPASSWORD` to point at a different
50
+ server. If `psql` isn't installed locally and `DB_HOST` is `localhost`, it
51
+ falls back to `docker exec` into whichever container is publishing
52
+ `DB_PORT` — this project's own Postgres (`make docker-up` first) or any
53
+ other Postgres container you already have running.
54
+
55
+ Server listens on `:8080` (override with `PORT`). Ctrl+C = graceful shutdown.
56
+
57
+ ## Makefile
58
+
59
+ ```bash
60
+ make run # go run ./cmd/api
61
+ make build # go build -o bin/api ./cmd/api
62
+ make test # go test ./...
63
+ make fmt # gofmt -w .
64
+ make vet # go vet ./...
65
+ make lint # golangci-lint run (see .golangci.yml)
66
+ make tidy # go mod tidy
67
+ make db-create # create the database itself (safe to re-run)
68
+ make db-drop # drop the database
69
+ make migrate-up # apply migrations (needs $DB_DSN)
70
+ make migrate-down # roll back one migration
71
+ {{#if docker}}make docker-up # docker compose up -d
72
+ make docker-down # docker compose down
73
+ {{/if}}```
74
+
75
+ ## Migrations
76
+
77
+ Schema is managed with [golang-migrate](https://github.com/golang-migrate/migrate), files live in `migrations/`.
78
+
79
+ ```bash
80
+ brew install golang-migrate
81
+ # or: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
82
+
83
+ migrate -path migrations -database "$DB_DSN" up
84
+ migrate -path migrations -database "$DB_DSN" down 1
85
+ migrate create -ext sql -dir migrations -seq add_something
86
+ ```
87
+
88
+ **dev:** leave `AUTO_MIGRATE=true` (default) so GORM's AutoMigrate creates the schema quickly.
89
+ **prod:** set `AUTO_MIGRATE=false` and run `migrate up` as a separate deploy step — versioned, has rollback (`down`), doesn't lock the table the way AutoMigrate does once there's real data.
90
+
91
+ ## Env vars
92
+
93
+ | var | default | notes |
94
+ |---|---|---|
95
+ | `PORT` | `8080` | |
96
+ | `DB_DSN` | `postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable` | used by both GORM and the `migrate` CLI |
97
+ | `LOG_LEVEL` | `info` | debug/info/warn/error |
98
+ | `AUTO_MIGRATE` | `true` | disable in prod and use `migrate up` (see Migrations) |
99
+ | `DB_MAX_OPEN_CONNS` | `10` | |
100
+ | `DB_MAX_IDLE_CONNS` | `10` | |
101
+ | `DB_CONN_MAX_LIFETIME_MIN` | `5` | minutes |
102
+
103
+ ## Tests
104
+
105
+ Integration tests (handler-level) run against a **real Postgres** instance (same engine as prod), each test in a transaction that's rolled back — no leftover rows. If the DB isn't reachable those tests **skip** (unit tests using a fake repo always run).
106
+
107
+ ```bash
108
+ {{#if docker}}
109
+ make docker-up # local postgres first
110
+ {{/if}}
111
+ make test
112
+ # point at a different test DB: TEST_DB_DSN=postgres://... go test ./...
113
+ ```
114
+
115
+ ## Error payload
116
+
117
+ ```json
118
+ {"error":{"code":"VALIDATION_ERROR","message":"invalid input","details":{"email":"email"},"request_id":"a1b2..."}}
119
+ ```
120
+
121
+ `code` is machine-readable, `details` names the field that failed, `request_id` correlates with server logs (header `X-Request-ID`).
122
+ {{#if openapiDocs}}
123
+
124
+ ## API spec
125
+
126
+ `docs/openapi.yaml` is hand-written and served, along with every file it `$ref`s (`common/`, `health/`, per-domain folders), under `/docs` while the server runs — point a renderer (Scalar, Swagger UI, Redoc) or a client generator (Hey API) at `http://localhost:8080/docs/openapi.yaml`. Update the spec by hand whenever an endpoint/DTO changes.
127
+
128
+ Some tools don't resolve external `$ref`s when importing a local file — they read it as-is and see zero routes (Bruno's "Import Collection" does this; some Postman flows too). For those, bundle the spec into one fully-resolved file first:
129
+
130
+ ```bash
131
+ make openapi-bundle # writes docs/openapi.bundled.yaml (gitignored — regenerate anytime)
132
+ ```
133
+
134
+ Import `docs/openapi.bundled.yaml` instead. Tools that resolve `$ref` over HTTP or when reading a URL (Scalar, Swagger UI, Redoc) don't need this — point them at the live `/docs/openapi.yaml` URL above.
135
+ {{/if}}
136
+
137
+ ## Adding a domain
138
+
139
+ ```bash
140
+ go-scaffold generate module orders
141
+ ```
142
+
143
+ Scaffolds `internal/app/orders/` (model/dto/errors/repository/service/handler + tests), wires it into `cmd/api/main.go` (AutoMigrate, route registration), and appends a migration file. See `docs/architect/patterns.md` for the module shape and the rules for domains with foreign keys.
@@ -0,0 +1,116 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "log/slog"
7
+ "net/http"
8
+ "os"
9
+ "os/signal"
10
+ "syscall"
11
+ "time"
12
+
13
+ "{{goModule}}/internal/platform/database"
14
+ "{{goModule}}/internal/shared/config"
15
+ "{{goModule}}/internal/shared/middleware"
16
+ // go-scaffold:imports
17
+
18
+ "github.com/gin-gonic/gin"
19
+ )
20
+
21
+ func main() {
22
+ cfg := config.Load()
23
+
24
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
25
+ slog.SetDefault(logger)
26
+
27
+ db, err := database.Open(cfg)
28
+ if err != nil {
29
+ logger.Error("open db", "error", err)
30
+ os.Exit(1)
31
+ }
32
+
33
+ sqlDB, err := db.DB()
34
+ if err != nil {
35
+ logger.Error("db handle", "error", err)
36
+ os.Exit(1)
37
+ }
38
+
39
+ if cfg.AutoMigrate {
40
+ // ponytail: AutoMigrate is for dev only (add-only, locks the table once data grows)
41
+ // prod: set AUTO_MIGRATE=false and run golang-migrate as versioned SQL instead
42
+ if err := db.AutoMigrate(
43
+ // go-scaffold:models
44
+ ); err != nil {
45
+ logger.Error("migrate", "error", err)
46
+ os.Exit(1)
47
+ }
48
+ }
49
+
50
+ r := gin.New()
51
+ r.Use(gin.Recovery(), middleware.RequestID(), middleware.Logger(logger), middleware.Error())
52
+
53
+ // liveness = is the process up / readiness = ready for traffic (can it reach the DB)
54
+ r.GET("/livez", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
55
+ r.GET("/readyz", func(c *gin.Context) {
56
+ if err := sqlDB.PingContext(c.Request.Context()); err != nil {
57
+ c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable"})
58
+ return
59
+ }
60
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
61
+ })
62
+ {{#if openapiDocs}}
63
+ // hand-written spec at docs/openapi.yaml, split across sibling files (common/, health/,
64
+ // <domain>/) via relative $ref — serve the whole tree under one prefix so a client that
65
+ // resolves $ref over HTTP (Scalar, Swagger UI, Redoc, Hey API pointed at a URL) can reach
66
+ // them too; StaticFile on just the index file would 404 on every $ref it follows.
67
+ r.Static("/docs", "./docs")
68
+ {{/if}}
69
+
70
+ api := r.Group("/{{apiPrefix}}")
71
+ // go-scaffold:routes
72
+ _ = api // dropped once `generate module` registers the first route
73
+
74
+ srv := &http.Server{
75
+ Addr: ":" + cfg.Port,
76
+ Handler: r,
77
+ ReadTimeout: 10 * time.Second,
78
+ WriteTimeout: 10 * time.Second,
79
+ IdleTimeout: 60 * time.Second,
80
+ }
81
+
82
+ // graceful shutdown: catch SIGINT/SIGTERM, let in-flight requests finish before closing
83
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
84
+ defer stop()
85
+
86
+ go func() {
87
+ logger.Info("listening", "addr", srv.Addr)
88
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
89
+ logger.Error("server", "error", err)
90
+ os.Exit(1)
91
+ }
92
+ }()
93
+
94
+ <-ctx.Done()
95
+ logger.Info("shutting down")
96
+
97
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
98
+ defer cancel()
99
+ if err := srv.Shutdown(shutdownCtx); err != nil {
100
+ logger.Error("shutdown", "error", err)
101
+ }
102
+ logger.Info("stopped")
103
+ }
104
+
105
+ func parseLevel(s string) slog.Level {
106
+ switch s {
107
+ case "debug":
108
+ return slog.LevelDebug
109
+ case "warn":
110
+ return slog.LevelWarn
111
+ case "error":
112
+ return slog.LevelError
113
+ default:
114
+ return slog.LevelInfo
115
+ }
116
+ }
@@ -0,0 +1,11 @@
1
+ module {{goModule}}
2
+
3
+ go 1.25
4
+
5
+ require (
6
+ github.com/gin-gonic/gin v1.10.0
7
+ github.com/go-playground/validator/v10 v10.20.0
8
+ github.com/google/uuid v1.6.0
9
+ gorm.io/driver/postgres v1.5.9
10
+ gorm.io/gorm v1.25.12
11
+ )
@@ -0,0 +1,28 @@
1
+ package database
2
+
3
+ import (
4
+ "{{goModule}}/internal/shared/config"
5
+
6
+ "gorm.io/driver/postgres"
7
+ "gorm.io/gorm"
8
+ )
9
+
10
+ // Open connects to Postgres and sets the connection pool — it talks to a
11
+ // real external system, so it lives in platform/, not shared/.
12
+ // Schema is managed via golang-migrate (migrations/) in prod, not AutoMigrate — see README.
13
+ func Open(cfg config.Config) (*gorm.DB, error) {
14
+ db, err := gorm.Open(postgres.Open(cfg.DBDSN), &gorm.Config{TranslateError: true})
15
+ if err != nil {
16
+ return nil, err
17
+ }
18
+
19
+ sqlDB, err := db.DB()
20
+ if err != nil {
21
+ return nil, err
22
+ }
23
+ sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns)
24
+ sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns)
25
+ sqlDB.SetConnMaxLifetime(cfg.DBConnMaxLifetime)
26
+
27
+ return db, nil
28
+ }