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
48 changes: 29 additions & 19 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,24 +72,34 @@ Each doc file has `last_verified` and `sources` frontmatter. The `docs` agent ma
backend/
cmd/api/main.go # entry point — wires layers, graceful shutdown
cmd/migrate/main.go # migration CLI — wraps goose, reads BLUEPRINT_DB_* env vars
migrations/ # SQL migration files (goose) — YYYYMMDDHHMMSS_<slug>.sql
internal/
domain/ # Layer 1: entities + repository interfaces (no external deps)
bootstrap/
bootstrap.go # App struct, Run() — config validation, DB init, service probing
domain/ # Layer 1: entities (no external deps)
health.go # HealthStats type
usecase/ # Layer 2: application logic
health_usecase.go # HealthReader interface, HealthUseCase interface + impl
repository/postgres/ # Layer 3: DB implementations
db.go # DBConfig, NewPostgresDB() → *sql.DB
health_repository.go # implements HealthReader
health_repository_test.go # integration tests (Testcontainers)
handler/ # Layer 3: HTTP adapters
handler.go # Handler struct, NewHandler()
routes.go # RegisterRoutes() on *Handler
hello_handler.go # HelloWorldHandler
health_handler.go # healthHandler (503 when DB down)
hello_handler_test.go # httptest unit tests
infrastructure/
database/
postgres/ # Layer 3: DB implementations
db.go # DBConfig, NewPostgresDB() → *sql.DB
health_repository.go # implements HealthReader
health_repository_test.go # integration tests (Testcontainers)
migrations/ # SQL migration files (goose) — YYYYMMDDHHMMSS_<slug>.sql
transport/
handlers/ # Layer 3: HTTP adapters
handler.go # Handler struct, NewHandler()
routes.go # RegisterRoutes() on *Handler
hello_handler.go # HelloWorldHandler
health_handler.go # HealthHandler (503 when DB down)
hello_handler_test.go # httptest unit tests
middleware/
logger.go # structuredLogger() — slog-based Gin middleware
server/
server.go # NewServer() — wires all layers, returns *http.Server
pkg/
logger/
logger.go # New(env) — JSON handler for staging/production, text otherwise
docker-compose.yml
.env # never commit secrets
Makefile
Expand All @@ -105,10 +115,10 @@ mobile/
```

## Go conventions (Clean Architecture)
- Follow the dependency rule: `domain` ← `usecase` ← `handler`/`repository` ← `server` ← `cmd`.
- New feature → add entity to `domain/`, interface to `usecase/`, implementation to `repository/postgres/`, handler to `handler/`, wire in `server/server.go`.
- New route → add use case interface + impl in `usecase/`, handler method on `*Handler`, register in `handler/routes.go`, wire in `server/server.go`.
- Repository interfaces live in `usecase/` (the layer that depends on them), not in `repository/`.
- Follow the dependency rule: `domain` ← `usecase` ← `transport`/`infrastructure` ← `server` ← `cmd`.
- New feature → add entity to `domain/`, interface to `usecase/`, implementation to `infrastructure/database/postgres/`, handler to `transport/handlers/`, wire in `server/server.go`.
- New route → add use case interface + impl in `usecase/`, handler method on `*Handler`, register in `transport/handlers/routes.go`, wire in `server/server.go`.
- Repository interfaces live in `usecase/` (the layer that depends on them), not in `infrastructure/`.
- Return errors up the stack. Never `log.Fatal` or `os.Exit` inside `internal/`.
- Run `go vet ./...` before committing.

Expand Down Expand Up @@ -137,9 +147,9 @@ mobile/

## Testing — non-negotiable
- **Never mock the database.** Always use Testcontainers.
- Follow the `TestMain` + `mustStartPostgresContainer()` pattern in `repository/postgres/health_repository_test.go`.
- DB integration tests live in `internal/repository/postgres/` (`package postgres`).
- Handler unit tests live in `internal/handler/` and may use mock use cases — that is not mocking the database.
- Follow the `TestMain` + `mustStartPostgresContainer()` pattern in `internal/infrastructure/database/postgres/health_repository_test.go`.
- DB integration tests live in `internal/infrastructure/database/postgres/` (`package postgres`).
- Handler unit tests live in `internal/transport/handlers/` and may use mock use cases — that is not mocking the database.
- Docker must be running for integration tests.

## Hard rules (hooks enforce some of these)
Expand Down
2 changes: 1 addition & 1 deletion backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ test:
# Integration tests (requires Docker)
itest:
@echo "Running integration tests..."
@go test ./internal/repository/postgres/... -v
@go test ./internal/infrastructure/database/postgres/... -v

# Clean the binary
clean:
Expand Down
10 changes: 5 additions & 5 deletions backend/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package main
import (
"context"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
Expand All @@ -20,16 +20,16 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) {

<-ctx.Done()

log.Println("shutting down gracefully, press Ctrl+C again to force")
slog.Info("shutting down gracefully, press Ctrl+C again to force")
stop()

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := apiServer.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown with error: %v", err)
slog.Error("server forced to shutdown", "error", err)
}

log.Println("Server exiting")
slog.Info("server exiting")
done <- true
}

Expand All @@ -54,5 +54,5 @@ func main() {
}

<-done
log.Println("Graceful shutdown complete.")
slog.Info("graceful shutdown complete")
}
4 changes: 2 additions & 2 deletions backend/cmd/migrate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import (
_ "github.com/joho/godotenv/autoload"
"github.com/pressly/goose/v3"

"backend/internal/repository/postgres"
"backend/internal/infrastructure/database/postgres"
)

const migrationsDir = "migrations"
const migrationsDir = "internal/infrastructure/database/migrations"

func main() {
if len(os.Args) < 2 {
Expand Down
9 changes: 5 additions & 4 deletions backend/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ The `docs` agent reads this index first to locate the right file before diving i

| Topic | File | Source files covered |
|---|---|---|
| Database connection & query patterns | [database.md](database.md) | `internal/repository/postgres/db.go`, `internal/repository/postgres/health_repository.go`, `internal/domain/health.go`, `internal/usecase/health_usecase.go` |
| Schema migrations (goose) | [migrations.md](migrations.md) | `cmd/migrate/main.go`, `migrations/`, `Makefile` |
| Startup lifecycle & dependency initialisation | [bootstrap.md](bootstrap.md) | `internal/bootstrap/bootstrap.go`, `internal/server/server.go`, `cmd/api/main.go` |
| Database connection & query patterns | [database.md](database.md) | `internal/infrastructure/database/postgres/db.go`, `internal/infrastructure/database/postgres/health_repository.go`, `internal/domain/health.go`, `internal/usecase/health_usecase.go` |
| Schema migrations (goose) | [migrations.md](migrations.md) | `cmd/migrate/main.go`, `internal/infrastructure/database/migrations/`, `Makefile` |
| HTTP routing & handler patterns | [routing.md](routing.md) | `internal/handler/handler.go`, `internal/handler/routes.go`, `internal/handler/hello_handler.go`, `internal/handler/health_handler.go`, `internal/server/server.go` |
| Integration testing with Testcontainers | [testing.md](testing.md) | `internal/repository/postgres/health_repository_test.go`, `internal/handler/hello_handler_test.go` |
| Integration testing with Testcontainers | [testing.md](testing.md) | `internal/infrastructure/database/postgres/health_repository_test.go`, `internal/transport/handlers/hello_handler_test.go` |
| Error handling conventions | [error-handling.md](error-handling.md) | `internal/repository/postgres/health_repository.go`, `internal/handler/health_handler.go`, `cmd/api/main.go` |
| Environment variables | [environment.md](environment.md) | `.env`, `internal/repository/postgres/db.go`, `internal/server/server.go` |
| Environment variables | [environment.md](environment.md) | `.env`, `internal/bootstrap/bootstrap.go`, `internal/repository/postgres/db.go` |
91 changes: 91 additions & 0 deletions backend/docs/bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
topic: bootstrap
last_verified: 2026-06-14
sources:
- internal/bootstrap/bootstrap.go
- internal/server/server.go
- cmd/api/main.go
---

# Bootstrap

## Purpose
`internal/bootstrap` owns the startup lifecycle: load config, validate it, initialise shared dependencies, and probe services for readiness. It runs before the HTTP server starts and aborts the process cleanly if anything is wrong.

## App struct
```go
type App struct {
DB *sql.DB
Config Config
Log *slog.Logger
}
```
`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point.

## Config struct
```go
type Config struct {
Port int
AppEnv string
DB postgres.DBConfig
}
```
`loadConfig()` reads all values from environment variables. `PORT` defaults to `8080`; `BLUEPRINT_DB_SCHEMA` defaults to `public`; `BLUEPRINT_DB_SSLMODE` defaults to `disable`.

## Run sequence
`bootstrap.Run(ctx)` executes these steps in order:

1. Build `Config` from env vars via `loadConfig()`
2. Validate required fields via `validateConfig()` — fast, no I/O
3. Open `*sql.DB` via `postgres.NewPostgresDB(cfg.DB)`
4. Probe Postgres with `probeWithRetry` under a 60-second total timeout
5. Return `*App` on success; return a non-nil error on any failure

```go
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
app, err := bootstrap.Run(ctx)
stop()
if err != nil {
fmt.Fprintf(os.Stderr, "startup failed: %v\n", err)
os.Exit(1)
}
```
The signal-aware context means Ctrl-C during startup cancels probes immediately rather than waiting for timeouts to expire.

## Config validation
`validateConfig` checks that all five required DB env vars are non-empty before attempting a connection:

```go
requireNonEmpty("BLUEPRINT_DB_HOST", cfg.DB.Host)
requireNonEmpty("BLUEPRINT_DB_PORT", cfg.DB.Port)
requireNonEmpty("BLUEPRINT_DB_DATABASE", cfg.DB.Database)
requireNonEmpty("BLUEPRINT_DB_USERNAME", cfg.DB.Username)
requireNonEmpty("BLUEPRINT_DB_PASSWORD", cfg.DB.Password)
```

Failures are collected and returned as `*ConfigError` — all issues reported at once, not just the first.

## Service probing
The `Pinger` interface is satisfied by `*sql.DB` natively:

```go
type Pinger interface {
PingContext(ctx context.Context) error
}
```

`probeWithRetry` attempts up to 5 pings. Between failures it sleeps for a full-jitter exponential backoff: random duration in `[0, min(16s, 500ms × 2^attempt)]`. Each attempt has a 15-second deadline (sized to accommodate Neon cold starts). The total probe budget is 60 seconds.

Log output during probing:
```
bootstrap: probing service service=postgres attempt=1 max_attempts=5
bootstrap: service not ready service=postgres attempt=1 error=...
bootstrap: waiting before retry service=postgres attempt=2 delay=347ms
bootstrap: service ready service=postgres attempts=2
```

## Adding a new dependency
1. Add an initialisation function `initFoo(cfg Config, log *slog.Logger) *FooClient` — return nil when the dependency is optional and not configured.
2. Add the field to `App`.
3. If the dependency supports `PingContext`, add it to the probes slice in a `probeAll`-style helper; otherwise just initialise and nil-check.
4. Pass the field through in `server.NewServer(app)`.
20 changes: 10 additions & 10 deletions backend/docs/database.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
topic: database
last_verified: 2026-06-14
last_verified: 2026-06-15
sources:
- internal/domain/health.go
- internal/usecase/health_usecase.go
- internal/repository/postgres/db.go
- internal/repository/postgres/health_repository.go
- internal/infrastructure/database/postgres/db.go
- internal/infrastructure/database/postgres/health_repository.go
---

# Database
Expand All @@ -16,7 +16,7 @@ Import: `_ "github.com/jackc/pgx/v5/stdlib"` (blank import in `db.go`, registers
Do NOT use pgx's native API — use `database/sql` methods only.

## Connection
No singleton. `NewPostgresDB` is called once in `internal/server/server.go` and the returned `*sql.DB` is passed down the dependency chain. The caller is responsible for calling `db.Close()` on shutdown.
No singleton. `NewPostgresDB` is called once in `internal/bootstrap/bootstrap.go` and the returned `*sql.DB` is stored on `bootstrap.App`, then passed to repositories in `internal/server/server.go`. The caller is responsible for calling `db.Close()` on shutdown.

`DBConfig` holds all connection parameters:

Expand All @@ -39,14 +39,14 @@ Connection string format:
postgres://username:password@host:port/database?sslmode=disable&search_path=schema
```

Env vars are loaded via `_ "github.com/joho/godotenv/autoload"` blank import in `internal/server/server.go` only.
Env vars are loaded via `_ "github.com/joho/godotenv/autoload"` blank import in `internal/bootstrap/bootstrap.go`.

## Architecture layers

```
internal/domain/health.go — HealthStats type (map[string]string alias)
internal/usecase/health_usecase.go — HealthReader interface (repo contract), HealthUseCase interface, healthUseCase impl
internal/repository/postgres/ — HealthRepository: implements HealthReader against *sql.DB
internal/domain/health.go — HealthStats type
internal/usecase/health_usecase.go — HealthReader interface (repo contract), HealthUseCase interface + impl
internal/infrastructure/database/postgres/ — HealthRepository: implements HealthReader against *sql.DB
```

The `HealthReader` interface is defined in the `usecase` package (Dependency Inversion — the use case owns the interface it depends on):
Expand Down Expand Up @@ -78,11 +78,11 @@ func NewHealthRepository(db *sql.DB) *HealthRepository {
## Adding a new query — exact pattern
1. Define a domain type in `internal/domain/` if needed.
2. Add a repository interface in the relevant `usecase/` file (the use case owns the interface).
3. Implement the repository in `internal/repository/postgres/` as a struct with a `New*` constructor.
3. Implement the repository in `internal/infrastructure/database/postgres/` as a struct with a `New*` constructor.
4. Use `db.QueryContext` / `db.QueryRowContext` / `db.ExecContext`. Always pass `ctx`.
5. Always use parameterized queries — `$1`, `$2`, etc. Never string-concatenate SQL.
6. Return `(Result, error)` — never swallow errors or call `log.Fatal`.
7. Add integration test in `internal/repository/postgres/`.
7. Add integration test in `internal/infrastructure/database/postgres/`.

```go
// Repository method
Expand Down
40 changes: 22 additions & 18 deletions backend/docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,34 @@ topic: environment
last_verified: 2026-06-14
sources:
- .env
- internal/database/database.go
- internal/server/server.go
- internal/bootstrap/bootstrap.go
- internal/repository/postgres/db.go
---

# Environment Variables

## Loading mechanism
`godotenv` is loaded automatically via blank imports in two files:
- `internal/database/database.go`: `_ "github.com/joho/godotenv/autoload"`
- `internal/server/server.go`: `_ "github.com/joho/godotenv/autoload"`

This means `.env` in the working directory is loaded on package init — no explicit `godotenv.Load()` call needed.
`godotenv` is loaded automatically via a blank import in `internal/bootstrap/bootstrap.go`:
```go
_ "github.com/joho/godotenv/autoload"
```
This runs on package init before any env var is read — no explicit `godotenv.Load()` call needed. Because `bootstrap` is the first package imported in `main`, `.env` is loaded before config validation runs.

## Variables reference

| Variable | Used in | Default | Description |
|---|---|---|---|
| `PORT` | `server.go` | `8080` | HTTP server listen port |
| `APP_ENV` | (available) | `local` | Environment name (`local`, `production`) |
| `BLUEPRINT_DB_HOST` | `database.go` | `localhost` | Postgres host |
| `BLUEPRINT_DB_PORT` | `database.go` | `5432` | Postgres port |
| `BLUEPRINT_DB_DATABASE` | `database.go` | `blueprint` | Database name |
| `BLUEPRINT_DB_USERNAME` | `database.go` | — | Postgres username |
| `BLUEPRINT_DB_PASSWORD` | `database.go` | — | Postgres password |
| `BLUEPRINT_DB_SCHEMA` | `database.go` | `public` | Postgres search_path schema |
| `PORT` | `bootstrap.go` | `8080` | HTTP server listen port |
| `APP_ENV` | `bootstrap.go` | — | Environment name (`local`, `production`) |
| `BLUEPRINT_DB_HOST` | `bootstrap.go` | — | Postgres host (**required**) |
| `BLUEPRINT_DB_PORT` | `bootstrap.go` | — | Postgres port (**required**) |
| `BLUEPRINT_DB_DATABASE` | `bootstrap.go` | — | Database name (**required**) |
| `BLUEPRINT_DB_USERNAME` | `bootstrap.go` | — | Postgres username (**required**) |
| `BLUEPRINT_DB_PASSWORD` | `bootstrap.go` | — | Postgres password (**required**) |
| `BLUEPRINT_DB_SCHEMA` | `bootstrap.go` | `public` | Postgres search_path schema |
| `BLUEPRINT_DB_SSLMODE` | `bootstrap.go` | `disable` | Postgres SSL mode (`disable`, `require`, `verify-full`) |

Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing.

## `.env` file
Located at `backend/.env`. Never commit this file with real credentials.
Expand All @@ -37,6 +40,7 @@ Docker Compose reads the same `.env` file to configure the Postgres container, s

## Adding a new environment variable
1. Add to `backend/.env` with a descriptive name.
2. Read with `os.Getenv("VAR_NAME")` at package level or inside the function that needs it.
3. Document it in this file.
4. Update `docker-compose.yml` if Docker also needs it.
2. Read it in `internal/bootstrap/bootstrap.go` inside `loadConfig()` and store it on `Config`.
3. If required, add a `requireNonEmpty` call in `validateConfig`.
4. Document it in this file.
5. Update `docker-compose.yml` if Docker also needs it.
5 changes: 3 additions & 2 deletions backend/docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ Never use `log.Fatal` or `os.Exit` inside `internal/`.
## Documented exception (intentional)
| Location | Call | Reason |
|---|---|---|
| `cmd/api/main.go: main()` | `log.Fatalf(...)` | `server.NewServer()` returned an error — process cannot start |
| `cmd/api/main.go: main()` | `fmt.Fprintf(os.Stderr, ...) + os.Exit(1)` | `bootstrap.Run()` returned an error — process cannot start |

This is the only permitted `log.Fatal` call and it lives in `cmd/`, not `internal/`.
This is the only permitted early-exit path and it lives in `cmd/`, not `internal/`.
`server.NewServer` does not return an error — all fallible startup work is done by `bootstrap.Run`.

## Repository errors
Repository methods return `(Result, error)`. On failure, wrap with context using `fmt.Errorf`:
Expand Down
8 changes: 4 additions & 4 deletions backend/docs/migrations.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
topic: migrations
last_verified: 2026-06-14
last_verified: 2026-06-15
sources:
- cmd/migrate/main.go
- migrations/
- internal/infrastructure/database/migrations/
- Makefile
---

Expand All @@ -14,12 +14,12 @@ goose v3 (`github.com/pressly/goose/v3`).
Entry point: `cmd/migrate/main.go` — a thin wrapper that reuses `postgres.NewPostgresDB` and reads the same `BLUEPRINT_DB_*` env vars as the server. No separate goose binary installation needed.

## File location
`backend/migrations/` — SQL files only. Naming: `YYYYMMDDHHMMSS_<slug>.sql`, created automatically by `make migrate-create`.
`backend/internal/infrastructure/database/migrations/` — SQL files only. Naming: `YYYYMMDDHHMMSS_<slug>.sql`, created automatically by `make migrate-create`.

## Makefile targets
| Target | What it does |
|---|---|
| `make migrate-create name=<slug>` | Create a new timestamped SQL file in `migrations/` |
| `make migrate-create name=<slug>` | Create a new timestamped SQL file in `internal/infrastructure/database/migrations/` |
| `make migrate-status` | Show applied vs. pending migrations |
| `make migrate-up` | Apply all pending migrations |
| `make migrate-up-one` | Apply only the next pending migration |
Expand Down
Loading
Loading