Go-DDD-Scaffold

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@latest
Go-DDD-Scaffold logo: nested layers around a solid core, an arrow pointing inwards
$ 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

FileWhat 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.goThe 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.goFiber 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.goDrives the router through app.Test: no port to bind, no database — the smoke test to keep green while you add routes.
env/env.goConfiguration 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.
.gitignorebin/ and env/.env.* (the local credentials) stay out of the first git add ..
database/db.goGORM MySQL pool with idle/open limits and connection recycling. The DSN carries the password, so it never reaches a log or an error.
DockerfileMulti-stage: dependencies cached in their own layer, an alpine runtime of a few MB, running as an unprivileged user.
docker-compose.ymlThe service and MySQL 8.4, started only once the database passes its health check.
Makefilerun, 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:

MilestoneWhat changesStatus
v0.9The 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.0Real 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.1Production: /livez and /readyz, Prometheus metrics, request id, migrations, a CI for the generated project, a supported Go toolchain.open
v1.2Choices: --http std, --db postgres|none, --dry-run.open
DistributionA 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.