A Go project template built with care πΉ It comes with Hexagonal Architecture, CQRS, and observability out of the box (structured logs and Prometheus /metrics) β so you can skip the boring setup and focus on what matters: writing great code β¨ Just clone, configure, and start crafting.
You need a recent Go toolchain (this repo targets Go 1.26). Clone the repository, then download modules and run the API entrypoint:
go mod download
make runThe API listens for HTTP (by default on :3000; override with HTTP_ADDR). A minimal GET /status route answers 200 with {"status":"ok"} so you can verify the stack quickly. GET /metrics exposes Prometheus text format for scraping or for wiring Grafana with Prometheus as a datasource β see Metrics (Prometheus).
The service uses structured logging so you avoid ad-hoc fmt/log at the edges and keep output consistent for operators and log aggregators.
| Piece | Location | Role |
|---|---|---|
| Port | internal/domain/port/logger.go |
Logger β framework-agnostic API (Info, Warn, Error, Debug, Fatal, With). Args are alternating key-value pairs (same idea as log/slog). |
| Adapter | internal/infrastructure/logger |
zerolog implementation: JSON lines by default, optional human-readable console output in development. |
| HTTP | internal/infrastructure/handler/middleware_logger.go |
Replaces Ginβs default request logger with a line per request (method, path, status, latency_ms). |
At startup, cmd/api/main.go builds the adapter and injects it into the HTTP server. Use cases can accept port.Logger later the same way.
Environment variables
| Variable | Default | Meaning |
|---|---|---|
LOG_LEVEL |
info |
Zerolog level: trace, debug, info, warn, error, fatal, panic, or disabled. Invalid values fall back to info. |
LOG_PRETTY |
(off) | Set to true or 1 for colored, multi-field console lines instead of JSON. Use this for local or docker compose terminals; keep JSON (LOG_PRETTY unset) in production so Loki, ELK, Cloud Logging, etc. can parse one object per line. |
HTTP_ADDR |
:3000 |
Listen address for the HTTP server. |
OTEL_SERVICE_NAME |
β | If set, becomes service.name on every log line (OpenTelemetry resource). |
SERVICE_NAME |
β | Used as service.name when OTEL_SERVICE_NAME is empty. |
| (implicit default) | gophercraft |
service.name when neither of the above is set. |
DEPLOYMENT_ENVIRONMENT |
β | If set, becomes deployment.environment (OTel-style). |
ENV / APP_ENV |
β | Fallback for deployment.environment when DEPLOYMENT_ENVIRONMENT is empty. |
In Docker or Kubernetes, JSON + service.name + deployment.environment is the usual pattern so you can filter and correlate logs across services.
The HTTP server exposes a GET /metrics endpoint in Prometheus exposition format, so a Prometheus server can scrape it and Grafana can chart request rates and latencies.
| Piece | Location | Role |
|---|---|---|
| Port | internal/domain/port/metrics_recorder.go |
MetricsRecorder β records HTTP request samples (method, normalized route, status, duration in seconds) without tying the domain to Prometheus. |
| Adapter | internal/infrastructure/metrics/prometheus_recorder.go |
prometheus/client_golang implementation: dedicated registry (not the global default), suitable for tests. |
| No-op | internal/infrastructure/metrics/noop_recorder.go |
Drops samples β used in HTTP tests and anywhere you do not need metrics. |
| HTTP | internal/infrastructure/handler/http.go |
metricsMiddleware emits per-request metrics; /metrics is served with promhttp.HandlerFor when the composition root supplies a prometheus.Gatherer. |
Exported series (application HTTP traffic; GET /metrics itself is not counted):
| Metric | Type | Labels |
|---|---|---|
http_requests_total |
Counter | method, route, status_code |
http_request_duration_seconds |
Histogram | method, route |
At startup, cmd/api/main.go constructs PrometheusRecorder, injects it into the server as both MetricsRecorder (middleware) and Gatherer (scraping). To try locally after make run:
curl -s http://localhost:3000/metrics | headThe layout follows hexagonal architecture (ports and adapters) with three main areas under internal/:
| Path | Role |
|---|---|
internal/domain |
Domain: entities, value objects, and ports (interfaces) that describe what the application needs from the outside world β including port.Logger for structured logs and port.MetricsRecorder for HTTP metrics. |
internal/application |
Application: use cases. Commands and queries are separated to keep a CQRS-friendly shape. |
internal/infrastructure |
Infrastructure: adapters β HTTP handlers (driving), logging (zerolog), and repositories (driven) that implement the domain ports. |
The cmd/api package is the composition root: it wires concrete adapters to handlers. Shared, stable helpers can live in pkg/ when they are safe to import from other modules.
Run the full test suite with the race detector:
make testTests use testify for assertions:
github.com/stretchr/testify/requireβ fatal checks (t.FailNow). Use for preconditions and setup (for example constructing a server or decoding a response) so the test stops immediately on failure.github.com/stretchr/testify/assertβ non-fatal checks. Use for comparing values (status codes, fields) when you still want clearer failures in one place.
Docs: testify on pkg.go.dev.
There is a small dummy test under internal/domain/model, HTTP and middleware tests under internal/infrastructure/handler, zerolog adapter tests under internal/infrastructure/logger, and Prometheus recorder tests under internal/infrastructure/metrics so CI exercises the stack end to end.
The Dummy example wires CQRS end to end: command.CreateDummyHandler handles writes and query.GetDummyHandler handles reads. The HTTP adapter in internal/infrastructure/handler exposes them as:
| Method | Route | Use case | Success |
|---|---|---|---|
POST |
/dummies |
Create | 201 Created (empty body) |
GET |
/dummies/:id |
Get by UUID | 200 OK with JSON body |
Request / validation rules
- Create (
POST /dummies) β JSON body withname(non-empty, max 255 chars after trim) andtype(one ofalpha,beta,gamma). - Get (
GET /dummies/:id) β pathidmust be a valid UUID. Unknown IDs return404.
Start the API, then exercise the routes from another terminal:
make runCreate a Dummy β expect 201 and an empty response body (the server assigns a UUID internally):
curl -i -X POST http://localhost:3000/dummies \
-H 'Content-Type: application/json' \
-d '{"name":"acme","type":"gamma"}'Get a Dummy β replace {id} with a valid UUID. A random valid UUID that was never created returns 404:
curl -i http://localhost:3000/dummies/550e8400-e29b-41d4-a716-446655440000Successful GET responses look like:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "acme",
"type": "gamma",
"created_at": "2026-05-14T15:30:00Z"
}Validation errors β invalid JSON or domain rules return 400; repository failures return 500:
# invalid JSON β 400
curl -i -X POST http://localhost:3000/dummies \
-H 'Content-Type: application/json' \
-d '{invalid'
# empty name β 400
curl -i -X POST http://localhost:3000/dummies \
-H 'Content-Type: application/json' \
-d '{"name":" ","type":"gamma"}'
# unknown type β 400
curl -i -X POST http://localhost:3000/dummies \
-H 'Content-Type: application/json' \
-d '{"name":"acme","type":"delta"}'
# malformed id β 400
curl -i http://localhost:3000/dummies/not-a-uuidPOST /dummies does not return the new id in the response yet, so a full create-then-fetch round trip over HTTP requires knowing the generated UUID (for example from structured logs) or relying on the automated tests below, which use a fixed UUID and in-memory repository.
Tests are split by layer, matching hexagonal boundaries:
| Layer | Package | What it covers |
|---|---|---|
| Application (command) | internal/application/command |
CreateDummyHandler β persistence, event publish, validation (create_dummy_test.go). |
| Application (query) | internal/application/query |
GetDummyHandler β load by ID, not-found (get_dummy_test.go). |
| HTTP adapter | internal/infrastructure/handler |
Gin handlers β status codes, JSON binding, error mapping (create_dummy_test.go, get_dummy_test.go). |
Run only the Dummy-related packages:
go test -v -race ./internal/application/command/... ./internal/application/query/... ./internal/infrastructure/handler/...Or run a single test by name (same pattern as make test):
go test -v -race -run ^TestCreateDummyHandler_Handle_PersistsAndPublishes$ ./internal/application/command
go test -v -race -run ^TestGetDummyHandler_Handle_ReturnsStoredDummy$ ./internal/application/query
go test -v -race -run ^TestCreateDummyGinHandler_Handle_Created$ ./internal/infrastructure/handler
go test -v -race -run ^TestGetDummyGinHandler_Handle_OK$ ./internal/infrastructure/handlerThis repository ships with a GitHub Actions workflow at .github/workflows/ci.yml. It runs on pull requests and on pushes to main, and includes:
- Testing:
go test -v -race ./...(same idea asmake test) - Linting: golangci-lint using the checked-in
.golangci.yml
You get the same checks locally with make test and make lint.
Build the container image (tag defaults to gophercraft:latest; override with IMAGE):
make docker-buildRun the stack with Docker Compose (uses compose.yml). Create a .env file when you need overrides; you can start from .env.example:
cp .env.example .env # optional
docker compose up -d --buildThe api service maps port 3000 on the host to 3000 in the container. Adjust ports, environment, or .env as needed.
The compose.yml file also starts a small monitoring stack next to the API, so you can scrape and chart the /metrics endpoint without any extra setup:
| Service | Image | Host port | What it does |
|---|---|---|---|
prometheus |
prom/prometheus:latest |
9090 | Scrapes the API and stores the time series. It reads its config from deploy/prometheus.yml, which is mounted read-only into the container. |
grafana |
grafana/grafana:latest |
4000 | Dashboards on top of Prometheus. Grafana also listens on 3000 inside the container, so it is mapped to 4000 on the host to avoid a clash with the API. Default login is admin / admin. |
Prometheus uses the scrape job defined in deploy/prometheus.yml:
global:
scrape_interval: 5s
scrape_configs:
- job_name: "gophercraft"
metrics_path: /metrics
static_configs:
- targets: ["api:3000"]Because every service shares the Compose network, Prometheus reaches the API by its service name (api:3000), not localhost. The prometheus and grafana services declare depends_on so they start after the API. Grafana data is kept in the grafana-data named volume, so dashboards and settings survive a restart.
Grafana starts with a pre-provisioned dashboard β no manual import needed. The datasource and dashboard are loaded automatically via the files under deploy/grafana/provisioning/:
datasources/prometheus.ymlβ registers Prometheus (http://prometheus:9090) as the default datasource.dashboards/gophercraft_overview.jsonβ the Gophercraft Overview dashboard with two panels:- HTTP request duration β p99 / p90 / p75 latency histogram over time.
- Requests per second β per-route throughput derived from
http_requests_total.
After docker compose up -d --build, open:
- API metrics: http://localhost:3000/metrics
- Prometheus (check Status β Targets β the
gophercrafttarget should beUP): http://localhost:9090 - Grafana (
admin/adminβ dashboard loads automatically): http://localhost:4000
Stop and remove containers for this project:
docker compose down| Target | Description |
|---|---|
make build |
go build ./... |
make test |
go test -v -race ./... |
make lint |
golangci-lint run ./... (requires golangci-lint on your PATH) |
make run |
go run ./cmd/api |
make docker-build |
Builds the Docker image (IMAGE overrides the tag) |
Hexagonal architecture keeps domain rules in the centre. Driving adapters (for example HTTP) call into the application layer. Driven adapters (for example a database or in-memory store) implement ports defined next to the domain. Dependencies point inward, so the domain does not know about frameworks or IO details.
This template separates commands (internal/application/command) from queries (internal/application/query) so you can grow toward CQRS without rewriting the folder layout. Structured logging is wired for HTTP and fatal startup errors; extend it by injecting port.Logger into application handlers as you add behaviour. Prometheus metrics cover HTTP traffic at the adapter layer and can be extended behind port.MetricsRecorder the same way.
