Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 173 additions & 44 deletions PROJECT_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,104 @@ Comprehensive guide for AI agents and developers scaffolding new Go projects tha

> **Audience:** AI code-generation agents (Claude, Cursor, Copilot) and human developers.
> **Scope:** Consumer projects — applications built _with_ this library, not contributions _to_ it.
> **Prerequisite:** Go 1.24+ (generics required).
> **Prerequisites:** [Nix](https://nixos.org/) (with flakes enabled), [go-task](https://taskfile.dev/) (global via Homebrew). Go 1.24+ is provided by the Nix flake.

---

## 0. Dev Environment Setup

### Three-Layer Tool Strategy

Each layer handles a different concern. They don't overlap.

| Layer | Tool | What It Manages | Examples |
|---|---|---|---|
| **Global CLI + GUI apps** | Homebrew | Always-available tools, GUI apps, system utilities | fish, git, go-task, direnv, gh |
| **Per-project dev tools** | Nix (`flake.nix`) | Compilers, linters, language runtimes, build tools | go, golangci-lint, gofumpt, buf, bun |
| **Services** | Podman/Docker | Databases, message brokers, infrastructure | PostgreSQL, Redis, Temporal |

### Why Nix

Nix provides **per-project, reproducible development environments**. Each project declares its exact tool versions in a `flake.nix` file. The `flake.lock` file pins those versions — commit it to git so all machines (macOS ARM, Linux x86_64) get identical tooling. When you enter the project directory with `direnv`, tools activate automatically. When you leave, they deactivate.

Without Nix, tool versions drift across machines, global upgrades break unrelated projects, and onboarding requires manually installing the right versions of everything.

### Why `go-task` Stays Global

`go-task` is the entry point that runs Taskfile commands. Taskfile commands invoke Nix (`nix develop -c ...`), so if `go-task` were inside the flake, you'd need Nix to run tasks, but you'd need tasks to run Nix — a chicken-and-egg problem. Install `go-task` globally via Homebrew. Same reasoning applies to `gh` (GitHub CLI).

### Taskfile Integration: Pattern A (`nix develop -c` prefix)

Every Taskfile command that uses a Nix-provided tool is prefixed with `nix develop -c` via a variable:

```yaml
vars:
N: "nix develop -c"

tasks:
test:
cmds:
- '{{.N}} go test ./...'
lint:
cmds:
- '{{.N}} golangci-lint run'
```

This is the default because most commands are executed by AI agents through the Taskfile — tasks work without requiring `direnv` to be active. `direnv` with `.envrc` (`use flake`) is optional for interactive shell use.

Infrastructure commands (`docker compose`, `podman`) run bare — they are system-level, not Nix-provided.

### `flake.nix` Template (Go Service)

```nix
{
description = "Project development environment";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};

outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in {
devShells.default = pkgs.mkShell {
packages = [
pkgs.go
pkgs.golangci-lint
pkgs.gofumpt
pkgs.jq
# Add project-specific tools here (buf, grpcurl, etc.)
# go-task is global (Homebrew), not in flake
];

shellHook = ''
export GOPATH="$HOME/go"
export PATH="$GOPATH/bin:$PATH"
echo "Dev environment ready — Go $(go version | awk '{print $3}')"
'';
};
});
}
```

### `.envrc` and `.gitignore`

```bash
# .envrc (optional — for interactive shell auto-activation)
use flake
```

Add to `.gitignore`:

```gitignore
# Nix / direnv
.direnv/
```

> **No Nix?** If you skip Nix, install Go, golangci-lint, and other tools globally and remove the `N:` variable prefix from the Taskfile. Everything else in this template still applies.

---

Expand All @@ -18,6 +115,9 @@ Each domain concept is a self-contained package under `internal/`. All layers fo

```
myapp/
├── flake.nix # Nix: per-project dev tools
├── flake.lock # Nix: pinned versions (committed)
├── .envrc # direnv: auto-activate nix (optional)
├── cmd/
│ ├── server/
│ │ └── main.go # API server entry point
Expand Down Expand Up @@ -69,16 +169,19 @@ myapp/
├── http/
│ └── api.http # IntelliJ/VS Code REST Client file
├── docker/
│ └── compose.yml # Dev infrastructure (PostgreSQL, etc.)
│ └── compose.yml # Podman/Docker: dev services (PostgreSQL, etc.)
├── tools.go # //go:build tools — pin swag, testcontainers, etc.
├── Taskfile.yml # Task runner configuration
├── Taskfile.yml # Task runner (commands prefixed with nix develop -c)
├── config.yaml # Default configuration
├── go.mod
└── go.sum
```

| Directory | Purpose |
|-----------|---------|
| `flake.nix` | Nix flake — declares project dev tools (Go, linters, etc.) |
| `flake.lock` | Pinned Nix package versions — commit this file |
| `.envrc` | direnv auto-activation (optional — `use flake`) |
| `cmd/server/` | API server entry point — config load, wiring, server start |
| `cmd/worker/` | Temporal worker entry point — registers workflows/activities |
| `internal/config/` | `AppConfig` struct with YAML + env var support |
Expand All @@ -92,7 +195,7 @@ myapp/
| `test/e2e/` | End-to-end API tests against real HTTP + real DB |
| `docs/` | Generated Swagger/OpenAPI files (committed) |
| `http/` | `.http` files for manual API testing |
| `docker/` | Docker Compose files for dev infrastructure |
| `docker/` | Podman/Docker Compose for dev services (PostgreSQL, Temporal, etc.) |

#### Module Rules

Expand Down Expand Up @@ -138,6 +241,9 @@ For small projects with 1-2 domains, a flat layout groups files by layer instead

```
myapp/
├── flake.nix # Nix: per-project dev tools
├── flake.lock # Nix: pinned versions (committed)
├── .envrc # direnv: auto-activate nix (optional)
├── cmd/
│ └── server/
│ └── main.go
Expand Down Expand Up @@ -1724,10 +1830,13 @@ GET {{host}}/swagger/index.html

## 13. Taskfile Configuration

Commands that use Nix-provided tools (Go, linters, formatters) are prefixed with `{{.N}}` which expands to `nix develop -c`. Infrastructure commands (`docker compose`, `podman`) run bare since they are system-level.

```yaml
version: "3"

vars:
N: "nix develop -c"
BIN_DIR: ./bin

tasks:
Expand All @@ -1736,43 +1845,43 @@ tasks:
cmds:
- task --list-all

# --- Infrastructure ---
# --- Infrastructure (system-level — no Nix prefix) ---
infra:up:
desc: Start dev infrastructure (PostgreSQL, Temporal, etc.)
desc: Start dev services (PostgreSQL, Temporal, etc.)
cmds:
- docker compose -f docker/compose.yml up -d

infra:down:
desc: Stop dev infrastructure
desc: Stop dev services
cmds:
- docker compose -f docker/compose.yml down

# --- Development ---
dev:api:
desc: Run the API server locally
cmds:
- go run ./cmd/server
- '{{.N}} go run ./cmd/server'

dev:worker:
desc: Run the Temporal worker locally
cmds:
- go run ./cmd/worker
- '{{.N}} go run ./cmd/worker'

# --- Test ---
test:unit:
desc: Run unit tests (short mode, no Docker)
cmds:
- go test ./... -short -v
- '{{.N}} go test ./... -short -v'

test:integration:
desc: Run integration tests (Docker required)
cmds:
- go test ./internal/... -tags=integration -v -count=1 -timeout 300s
- '{{.N}} go test ./internal/... -tags=integration -v -count=1 -timeout 300s'

test:e2e:
desc: Run E2E API tests (full server + real DB)
cmds:
- go test ./test/e2e/ -tags=integration -v -count=1 -timeout 300s
- '{{.N}} go test ./test/e2e/ -tags=integration -v -count=1 -timeout 300s'

test:all:
desc: Run all tests (unit + integration + e2e)
Expand All @@ -1785,25 +1894,39 @@ tasks:
docs:swagger:
desc: Generate Swagger/OpenAPI docs
cmds:
- swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
- '{{.N}} swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal'

# --- Quality ---
lint:
desc: Run golangci-lint
cmds:
- golangci-lint run
- '{{.N}} golangci-lint run'

fmt:
desc: Format code with gofumpt
cmds:
- gofumpt -w .
- '{{.N}} gofumpt -w .'

# --- Dependencies ---
vendor:
desc: Tidy and vendor dependencies
cmds:
- go mod tidy
- go mod vendor
- '{{.N}} go mod tidy'
- '{{.N}} go mod vendor'

# --- Nix ---
nix:check:
desc: Verify Nix environment and tool availability
cmds:
- '{{.N}} go version'
- '{{.N}} golangci-lint --version'
- echo "All tools available"

nix:update:
desc: Update flake inputs (bump tool versions)
cmds:
- nix flake update
- echo "Flake inputs updated. Run 'task nix:check' to verify."

clean:
desc: Remove build artifacts
Expand All @@ -1815,46 +1938,52 @@ tasks:

## 14. Architecture Rules (Checklist)

### Dev Environment
1. **`flake.nix` exists** with project-specific dev tools. `flake.lock` is committed.
2. **`go-task` is not in `flake.nix`** — it's global via Homebrew (chicken-and-egg). Same for `gh`.
3. **Taskfile uses `{{.N}}` prefix** (`nix develop -c`) for all Nix-provided tool commands. Infrastructure commands (`docker compose`) run bare.
4. **`.direnv/` is in `.gitignore`** if `.envrc` is present.

### Configuration & OTel
1. **Config tags:** Every config struct field has `yaml`, `mapstructure`, and `validate` tags.
2. **OTelConfig isolation:** `OTelConfig *otel.Config` always tagged `yaml:"-" mapstructure:"-"`. Never serialized.
3. **OTel injection:** OTelConfig is set at runtime via functional options or direct assignment — never from YAML/env.
4. **LayerContext usage:** Every service/repository method starts with `otel.Layers.Start*()` and defers `lc.End()`.
5. **Context propagation:** Always pass `lc.Context()` to downstream calls, never the original `ctx`.
6. **Error returns:** `lc.Error(err, msg)` returns `error` — use it as a return value. `lc.Success(msg)` returns nothing.
5. **Config tags:** Every config struct field has `yaml`, `mapstructure`, and `validate` tags.
6. **OTelConfig isolation:** `OTelConfig *otel.Config` always tagged `yaml:"-" mapstructure:"-"`. Never serialized.
7. **OTel injection:** OTelConfig is set at runtime via functional options or direct assignment — never from YAML/env.
8. **LayerContext usage:** Every service/repository method starts with `otel.Layers.Start*()` and defers `lc.End()`.
9. **Context propagation:** Always pass `lc.Context()` to downstream calls, never the original `ctx`.
10. **Error returns:** `lc.Error(err, msg)` returns `error` — use it as a return value. `lc.Success(msg)` returns nothing.

### Architecture & Layer Separation
7. **Layer separation:** Handler → Service → Repository. No skipping layers. Handlers never touch `*gorm.DB` directly.
8. **Consumer-defined interfaces:** Interfaces live in the consuming module's `interfaces.go`. Each consumer defines only the methods it uses.
9. **Module-local errors:** Each module defines domain errors in `errors.go` (`var ErrXxx = errors.New(...)`). Handlers map these to HTTP status codes.
10. **DTO mapping:** Handlers use DTOs (`dto.go`) for request/response. GORM models stay in `shared/model/`. Map between them in handlers or services.
11. **Exported DTOs:** Request/Response structs must be exported (capitalized) for `swag` to parse.
11. **Layer separation:** Handler → Service → Repository. No skipping layers. Handlers never touch `*gorm.DB` directly.
12. **Consumer-defined interfaces:** Interfaces live in the consuming module's `interfaces.go`. Each consumer defines only the methods it uses.
13. **Module-local errors:** Each module defines domain errors in `errors.go` (`var ErrXxx = errors.New(...)`). Handlers map these to HTTP status codes.
14. **DTO mapping:** Handlers use DTOs (`dto.go`) for request/response. GORM models stay in `shared/model/`. Map between them in handlers or services.
15. **Exported DTOs:** Request/Response structs must be exported (capitalized) for `swag` to parse.

### Database & Data Access
12. **One DB hit per repository method** — no multi-step queries, no loops with queries inside.
13. **Always use `r.db.WithContext(lc.Context())`** — never skip context for OTel tracing.
14. **Always use parameterized queries** (`?` placeholders) — never string concatenation for SQL values.
15. **Service owns transactions** — repositories never start transactions. Use `db.Transaction()` in the service.
16. **Transaction callbacks use `tx` directly** — not repository methods (which hold their own `r.db` outside the tx scope).
17. **Keep transactions short** — no external API calls or long-running work inside them.
16. **One DB hit per repository method** — no multi-step queries, no loops with queries inside.
17. **Always use `r.db.WithContext(lc.Context())`** — never skip context for OTel tracing.
18. **Always use parameterized queries** (`?` placeholders) — never string concatenation for SQL values.
19. **Service owns transactions** — repositories never start transactions. Use `db.Transaction()` in the service.
20. **Transaction callbacks use `tx` directly** — not repository methods (which hold their own `r.db` outside the tx scope).
21. **Keep transactions short** — no external API calls or long-running work inside them.

### Client Layer
18. **One client per external service** — each in `shared/client/`, using `rest.NewClient()` with OTel.
19. **Never expose HTTP details** — return typed Go structs, not raw responses.
20. **Prefer header-based auth** — over query-string API keys.
22. **One client per external service** — each in `shared/client/`, using `rest.NewClient()` with OTel.
23. **Never expose HTTP details** — return typed Go structs, not raw responses.
24. **Prefer header-based auth** — over query-string API keys.

### API & Documentation
21. **Swagger annotations:** Every handler method has `@Summary`, `@Tags`, `@Param`, `@Success`, `@Failure`, `@Router`, and `@Security` (where applicable).
22. **`.http` file exists:** `http/api.http` covers every API endpoint with example payloads.
25. **Swagger annotations:** Every handler method has `@Summary`, `@Tags`, `@Param`, `@Success`, `@Failure`, `@Router`, and `@Security` (where applicable).
26. **`.http` file exists:** `http/api.http` covers every API endpoint with example payloads.

### Testing
23. **E2E tests exist:** `test/e2e/` contains full-stack API tests using `startTestServer(t)` with testcontainer DB.
24. **Migration naming:** `{6-digit sequence}_{description}.{up|down}.sql` with `embed.FS`.
25. **Test assertions:** Use `testify/assert` and `testify/require` — never `if err != nil { t.Fatal() }` patterns.
27. **E2E tests exist:** `test/e2e/` contains full-stack API tests using `startTestServer(t)` with testcontainer DB.
28. **Migration naming:** `{6-digit sequence}_{description}.{up|down}.sql` with `embed.FS`.
29. **Test assertions:** Use `testify/assert` and `testify/require` — never `if err != nil { t.Fatal() }` patterns.

### Temporal
26. **Temporal separation:** Workflow functions use `workflow.Context`, activity functions use `context.Context`. Activities hold injected dependencies via a struct. Worker binary lives in `cmd/worker/`, separate from the API server.
27. **Temporal config:** `temporal.Config` has no `OTelConfig` field — it is fully serializable. Embed as a value type in `AppConfig`, not a pointer.
30. **Temporal separation:** Workflow functions use `workflow.Context`, activity functions use `context.Context`. Activities hold injected dependencies via a struct. Worker binary lives in `cmd/worker/`, separate from the API server.
31. **Temporal config:** `temporal.Config` has no `OTelConfig` field — it is fully serializable. Embed as a value type in `AppConfig`, not a pointer.

---

Expand Down
46 changes: 46 additions & 0 deletions docs/plans/2026-03-30-project-template-nix-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# PROJECT_TEMPLATE Nix Development Environment Update

**Date:** 2026-03-30
**Status:** Approved

## Goal

Update PROJECT_TEMPLATE.md to make Nix the default dev environment for consumer projects, matching the three-layer tool strategy established in the `pkg` library itself.

## Design Decisions

- **Nix is the default** with a brief note that tools can be installed globally if Nix is skipped
- **New Section 0** before directory layout — foundational context that informs everything else
- **Moderate detail** — enough for AI agents to scaffold correctly and understand rationale, but no installation/troubleshooting (that's user-facing, not scaffolding)

## Changes

### 1. New Section 0: Dev Environment Setup

- Update prerequisite line: Nix + go-task global, Go provided by flake
- Three-layer strategy table (Homebrew / Nix / Podman)
- Why Nix: reproducible, per-project, cross-platform
- Why `go-task` stays global: chicken-and-egg problem
- Pattern A (`nix develop -c` prefix) as default Taskfile integration
- `flake.nix` Go service template
- `.envrc` content and `.gitignore` additions

### 2. Section 1: Directory Layout

- Add `flake.nix`, `flake.lock`, `.envrc` to both layout trees
- Add rows to directory purpose table
- Update `docker/compose.yml` and `Taskfile.yml` descriptions to align with three-layer terminology

### 3. Section 13: Taskfile Configuration

- Add `vars: N: "nix develop -c"` block
- Prefix all tool commands with `{{.N}}`
- Infrastructure commands stay bare
- Add `nix:check` and `nix:update` standard tasks
- Brief note explaining the prefix pattern

### 4. Section 14: Architecture Rules

- New "Dev Environment" subsection at top of checklist
- Rules: flake.nix exists, go-task not in flake, Taskfile uses {{.N}}, .direnv/ gitignored
- Existing items shift numbering
Loading