# go-ddd, a Domain Driven Design template that hands you a working marketplace

> sklinkert/go-ddd is an opinionated Go template that assembles CQRS commands and queries, idempotent writes, domain events with a transactional outbox, sqlc generated access and a Docker Compose stack. It is built for teams that want the structure in place on day one and accept the marketplace example domain in exchange.

**sklinkert/go-ddd** — Opinionated Domain Driven Design Template for Go

- Repository: https://github.com/sklinkert/go-ddd
- Website: https://sklinkert.github.io/go-ddd/
- Stars: 821 · Forks: 67
- Language: Go
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/sklinkert-go-ddd

## A template repository, not a package you import

The name is misleading if you read it as a library. go-ddd describes itself as an opinionated Domain Driven Design template for Go, and the module path, github.com/sklinkert/go-ddd, belongs to the example service that ships inside it. What you clone is a working backend with a marketplace domain, and what you take from it is the arrangement of the code, not a dependency you add to your own project.

That makes it a starting kit rather than a component. The README lists what the template gives you: aggregates modelled as Seller and Product, a Money value object, layered modules under internal/, an executable entrypoint at cmd/marketplace/main.go, database assets under migrations/ and sql/, generated access through sqlc, an OpenAPI spec at api/openapi.yaml and health probes at /healthz and /readyz.

The copy tells you the intended reader too. A nine chapter tutorial at sklinkert.github.io/go-ddd walks through Domain Driven Design from zero using this codebase as the running example, and a separate document, DDD_CQRS_PRINCIPLES.md, generalises the patterns to other domains. The template is therefore paired with teaching docs, which is unusual for a project skeleton and is the main reason it works as an educational starting point.

## The dependency rule that keeps the domain layer pure

The layering is stated as a set of rules rather than a diagram alone. The domain must not depend on other layers, provides interfaces for infrastructure but must not access infrastructure, and implements business logic and validation. The application layer is described as glue code between the domain and infrastructure. The infrastructure layer holds the repositories, translates a domain entity into a database model and performs persistence logic, with no business logic executed there.

The directory layout mirrors that rule. Under internal/ there are domain, application, infrastructure and interface, plus testhelpers for fixture reuse. Inside domain sit entities such as Product and Seller with basic validation logic, infrastructure carries db and repositories, and interface/api/rest holds the HTTP controllers.

Two rules reveal the author's actual pain points. The domain sets entity defaults, for example a uuid for the identifier or a creation timestamp, and the README is explicit that defaults must not be set in the infrastructure layer or in the database, because two sources of truth are dangerous and the domain is easier to test on its own. The second rule is about reads: validated entities are not returned from read methods, instead the domain entity type is returned directly, because validation rules change over time and historical rows written under older rules must still load. The rationale is stated plainly, you should not have to migrate data when validation logic evolves.

## The idempotency key is reserved before the domain is consulted

Write requests in this template are designed to be retried. The client sends a POST to /api/v1/products with an idempotency key, the application service reserves that key atomically in Postgres, and only then does it ask the domain to build the entity. The README describes this as race-safe idempotency with atomic reservation and no check-then-write step, which is the detail that matters, since a naive implementation that checks for the key and then writes it can pass the check twice under concurrency.

The sequence recorded in the README runs in this order. The REST controller turns the request into a CreateProductCommand, the application service reserves the idempotency key, the domain constructs the product from a name, a Money amount and a validated seller, validation inside the domain produces a ValidatedProduct together with a ProductCreated event, and the service inserts the product row and the outbox event, then stores the response under the idempotency key. The controller returns 201 Created with JSON.

Two properties of that flow matter for review. The idempotency record is stored after the write rather than before it, so a failure between the database write and the response storage has to be handled by the retry path, and the response is stored rather than just a completion flag, which is what lets a repeated request return the original outcome instead of a second insert.

## Domain events ride the same transaction as the row that produced them

Event publishing is handled with a transactional outbox. The application service inserts the product and the outbox event in the same write, so the state change and the event describing it are persisted together, and a separate relay publishes the event afterwards with at-least-once delivery.

At-least-once is the important qualifier. Consumers of ProductCreated must therefore tolerate the same event arriving twice, which is a different contract from exactly-once delivery and one that the template leaves to you to handle. The relay itself is not described in detail in the README, so if your consumers are not idempotent, an outbox adoption here can push duplication problems downstream rather than solving them.

This is also the point where the layering pays off. The domain knows nothing about the relay or about Postgres. It produces an event as a byproduct of a state change, the infrastructure layer persists it, and the infrastructure layer also owns the machinery that later publishes it. A design that mixes publishing directly into the domain would have produced a coupled service, and this one does not.

## sqlc and golang-migrate keep schema and code tied together

Database work is split between two tools with clearly separate jobs. sqlc generates type-safe data access from the SQL files under sql/ and the configuration in sqlc.yaml, and golang-migrate applies schema migrations, with migrate.go at the repository root serving as the migration entrypoint the container image builds. The README calls this migration discipline, with schema evolution made explicit and reproducible.

The driver stack is pgx/v5 for PostgreSQL access rather than an ORM, and the go.mod file pins the direct dependencies. The module is github.com/sklinkert/go-ddd, it requires go 1.26.0 with toolchain go1.26.6, and it pulls in golang-migrate/migrate/v4, google/uuid, jackc/pgx/v5, labstack/echo/v4, stretchr/testify, and the testcontainers postgres module. Echo v4 is the HTTP stack, and testify is what the tests are written against.

Testcontainers appears in the dependency list, and the README explains why: integration tests provision disposable Postgres instances rather than pointing at a shared database. That is the more expensive but more honest option, and it explains why the Docker Compose stack matters beyond local development. The repository also vendors its dependencies, and the Dockerfile copies the vendor directory and builds with GOFLAGS=-mod=vendor, so builds do not depend on module proxy availability.

## The compose stack runs Postgres, migrations and the app in order

There is no install command in the README, because there is nothing to install. The repository ships a docker-compose.yml at the root with three services, and the environment configuration lives in .env.example, whose comment says to copy it to .env or export the values in a shell to override defaults. That file holds exactly two variables.

```bash
DATABASE_URL=postgres://marketplace:marketplace@localhost:5432/marketplace?sslmode=disable
PORT=8080
```

The compose file defines the Postgres service with image postgres:17-alpine, the user, password and database all set to marketplace, port 5432 published to the host, and a healthcheck that runs pg_isready against that user and database every five seconds with ten retries. The services are ordered by that healthcheck rather than by start order.

```yaml
  migrate:
    build: .
    entrypoint: ["/app/migrate", "-command", "up"]
    environment:
      DATABASE_URL: postgres://marketplace:marketplace@postgres:5432/marketplace?sslmode=disable
    depends_on:
      postgres:
        condition: service_healthy
```

The migrate service is described in the file as a one-shot job that applies migrations and then exits, and the app service waits for the postgres healthcheck and for the migrate service to complete successfully. The app container receives DATABASE_URL pointing at the postgres service and PORT set to 8080, with 8080 published to the host.

The Dockerfile explains the shape of the image. A build stage on golang:1.26 copies go.mod, go.sum and vendor, builds the server and the migration tool, and the runtime stage is distroless static-debian12 running as nonroot.

```dockerfile
FROM golang:1.26 AS build
WORKDIR /src

COPY go.mod go.sum ./
COPY vendor ./vendor
COPY . .

ENV CGO_ENABLED=0 GOFLAGS=-mod=vendor
RUN go build -o /out/marketplace ./cmd/marketplace
RUN go build -o /out/migrate ./migrate.go
```

Two details are worth noting before you use the image. The runtime image contains no shell and no package manager, so a container that fails to start cannot be debugged from inside it, and the migrations directory is copied into the image because the migrate tool reads migrations at runtime. A distroless runtime also means the image cannot exec into a shell to inspect the database, so use the published port and a local client instead.

## Where the template stops being useful

The opinionated parts are the point, and they are also the cost. Every layer, the idempotency reservation, the outbox write, the sqlc generated access and the Compose layout are choices that a team either accepts or reverses. A team that prefers a single migration tool, or that writes its own command bus, will spend its first days deleting infrastructure it does not want. The README presents the choices as settled rather than as configurable, and no profile system or feature flags are listed.

The example domain is the other limit. Seller and Product with a Money value object exist to exercise the patterns, not to model a real marketplace. The README itself points at DDD_CQRS_PRINCIPIS.md for applying the patterns to another business domain, which is an admission that the domain code is a vehicle. Testcontainers based integration tests are also a deliberate expense, each test needing a disposable Postgres container, and that cost is paid on every run.

Finally, there is exactly one release, v1.0.0, published on 2026-07-14, and the last push was on 2026-10-14. The template is moving, but consumers are not consuming a versioned library, so upgrading means pulling template changes and resolving the conflicts yourself.

## Conclusion

Adopt go-ddd if you are starting a Go backend with Postgres and want CQRS, idempotency and an outbox already wired, and if you are prepared to delete the Seller and Product example code before it reaches production. Do not adopt it as a library or a general purpose framework, because it is a template repository whose module path serves its own example service rather than a published package. Before you build on it, confirm that your Go toolchain matches the go 1.26.0 directive in go.mod, run the Docker Compose stack and apply the migrations so the idempotency and outbox tables exist, and read the dependencies in go.mod for yourself since the module vendors them.

## FAQ

### What does the go-ddd template include out of the box?

It includes CQRS command and query flows, idempotent write paths with atomic idempotency key reservation, domain events with a transactional outbox, sqlc generated data access, golang-migrate migrations, an OpenAPI spec at api/openapi.yaml, /healthz and /readyz probes, and a Docker Compose stack.

### How do I run the go-ddd project locally?

The repository ships a docker-compose.yml with a postgres:17-alpine service, a one-shot migrate service that runs /app/migrate with the up command, and an app service listening on PORT 8080. Connection details are in .env.example as DATABASE_URL and PORT.

### Is go-ddd actively maintained and what license does it use?

The last push was on 2026-10-14 and the single release is v1.0.0 from 2026-07-14. The repository is licensed under MIT.

### Can I use go-ddd with a database other than Postgres?

The template as shipped is wired to Postgres, with pgx/v5 for access, sqlc generating from SQL files and Testcontainers provisioning disposable Postgres instances for tests. The README only names Postgres and mysql as examples of what infrastructure repositories can persist to, so other engines are not covered.

## Sources

- [License: MIT](https://github.com/sklinkert/go-ddd/blob/main/LICENSE)
- [Project website](https://sklinkert.github.io/go-ddd/)
- [README](https://github.com/sklinkert/go-ddd/blob/main/README.md)
- [Releases](https://github.com/sklinkert/go-ddd/releases)
- [sklinkert/go-ddd on GitHub](https://github.com/sklinkert/go-ddd)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/sklinkert-go-ddd
