A Go service that compiles, runs and ships — from one command.
scaffold init writes an HTTP service in DDD layers — a bounded context with its domain, use cases, adapters and an architecture test that enforces the dependency rule — with graceful shutdown, a health check, configuration from the environment, a connection pool, a non-root multi-stage image and docker compose. Before every release CI generates a project and compiles it: a template that emits broken Go does not ship.
go install github.com/Allan-Nava/Go-DDD-Scaffold@latestThe binary is named Go-DDD-Scaffold after the module — rename it to scaffold, or use docker run --rm -v "$PWD:/work" ghcr.io/allan-nava/go-ddd-scaffold init, or the VS Code extension. Templates are embedded: no $GOPATH, no checkout beside it.
$ mkdir -p ~/code/github.com/acme/orders && cd $_ $ scaffold init Create Dockerfile Create cmd/main.go Create cmd/modules.go # composition root Create internal/archtest/arch_test.go # the dependency rule, as a test … Create cmd/module_catalog.go Create internal/catalog/domain/model.go Create internal/catalog/application/service.go Create internal/catalog/infrastructure/gorm.go Create internal/catalog/transport/http/handler.go … 28 file(s) created in ~/code/github.com/acme/orders # module github.com/acme/orders, read from the path $ go mod tidy && go test ./... ok github.com/acme/orders/cmd 0.007s ok github.com/acme/orders/env 0.003s ok github.com/acme/orders/internal/archtest 0.003s ok github.com/acme/orders/internal/catalog/application 0.004s ok github.com/acme/orders/internal/catalog/domain 0.003s ok github.com/acme/orders/internal/catalog/infrastructure 0.004s ok github.com/acme/orders/internal/catalog/transport/http 0.006s
Real output of v1.0.0, abridged. --no-example skips the catalog module. Inside a git clone the module path comes from the origin remote instead; --module and --name override both. Run it again and every file is reported as Skip: nothing is overwritten without --force.
What it generates
| File | What it does |
|---|---|
internal/catalog/ | The example bounded context: a Product aggregate with a Name value object and an archive rule in domain/, use cases in application/, a GORM and an in-memory store in infrastructure/, REST handlers in transport/http/. Tests at every layer, none needing a database. |
cmd/modules.go, cmd/module_catalog.go | The composition root, by hand: each module wires store → service → handler and mounts under /api/<name>. With no database it falls back to the in-memory store. |
internal/archtest/ | Parses the imports of every domain and application package and fails go test when one imports GORM, Fiber or an outer layer. |
cmd/main.go | Fiber router with recover and logger; CORS closed unless CORS_ALLOWED_ORIGINS lists origins; zap at LOG_LEVEL; /health; listens on HTTP_ADDR; on SIGINT/SIGTERM drains in-flight requests for up to 10 s. |
cmd/main_test.go | Drives the router through app.Test: no port to bind, no database — the smoke test to keep green while you add routes. |
env/env.go | Configuration from the environment (caarlos0/env), with env/.env.<APP_ENV> loaded when present; defaults pinned by env_test.go. env/.env.local leaves DB_HOST empty, so no database is needed to start. |
.gitignore | bin/ and env/.env.* (the local credentials) stay out of the first git add .. |
database/db.go | GORM MySQL pool with idle/open limits and connection recycling. The DSN carries the password, so it never reaches a log or an error. |
Dockerfile | Multi-stage: dependencies cached in their own layer, an alpine runtime of a few MB, running as an unprivileged user. |
docker-compose.yml | The service and MySQL 8.4, started only once the database passes its health check. |
Makefile | run, build, test, check, up, down, logs. |
$ make run # no MySQL on this machine: it starts anyway {"msg":"DB_HOST is empty: starting without a database"} {"msg":"listening","addr":"0.0.0.0:8080"} $ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/health 200 $ curl -s -X POST localhost:8080/api/catalog -H 'Content-Type: application/json' -d '{"name":"Laptop"}' {"id":"890916a0…","name":"Laptop","status":"active",…} $ curl -s -X POST localhost:8080/api/catalog/890916a0…/archive # twice product is archived # 409: the rule lives in the aggregate
make up starts the service with MySQL through docker compose; set DB_HOST=localhost in env/.env.local to run on the host against it. CI starts the generated service exactly like make run, with APP_ENV=local and no database.
DDD, enforced
Dependencies point inwards — transport → application → domain ← infrastructure — and that is a test, not a convention:
$ scaffold add module billing --entity Invoice # four layers + tests + cmd/module_billing.go Create cmd/module_billing.go Create internal/billing/domain/model.go … 12 file(s) created for module billing # no existing file edited: the module registers itself $ echo 'package domain; import _ "gorm.io/gorm"' > internal/billing/domain/leak.go $ go test ./internal/archtest --- FAIL: TestDependencyRule internal/billing/domain/leak.go imports gorm.io/gorm: the domain stays free of third-party code
Where the rules go, rich versus anemic model, and when each fits: the generated DDD layout. Wiring is plain Go in cmd/: no DI framework.
Guarantees, enforced in CI
It compiles, or it does not ship
Every push generates a project in a temporary directory and runs go mod tidy, go build, go vet, gofmt and its tests, then starts it like make run and calls /health.
Your files are yours
Existing files are skipped, never overwritten unless you ask with --force. Each file is written aside and moved into place only when complete, so a failure never truncates what was there.
One binary, anywhere
Templates live in an embed.FS. A go install, a release tarball or the container image all carry everything they need.
Go stays Go
Rendered with text/template, never html/template, so && and <- are never escaped — a regression test pins it.
Roadmap
Since v1.0 the generated project has real DDD layers and scaffold add module; next comes what a service needs in production. The product audit lists what is missing and orders it:
| Milestone | What changes | Status |
|---|---|---|
| v0.9 | The first five minutes work on every machine: make run without MySQL, module path with a dotted home directory, --module and --name, a generated .gitignore, CORS closed by default. | done |
| v1.0 | Real DDD: one module per bounded context with domain, application, infrastructure and transport; a complete example module; scaffold add module; an architecture test that fails when the domain imports infrastructure. | done |
| v1.1 | Production: /livez and /readyz, Prometheus metrics, request id, migrations, a CI for the generated project, a supported Go toolchain. | open |
| v1.2 | Choices: --http std, --db postgres|none, --dry-run. | open |
| Distribution | A binary named scaffold, GoReleaser with checksums and Homebrew, golden tests of the generated tree. | open |
Each milestone links to its GitHub issues; the plan itself lives in docs/backlog.md.
In VS Code
The extension runs the same CLI: New Scaffold Project on the folder you pick, Add Bounded Context inside a generated project. It carries no templates of its own, so the editor and the terminal always generate the same project.