diff --git a/CLAUDE.md b/CLAUDE.md index a32fa78..b4b3995 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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_.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_.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 @@ -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. @@ -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) diff --git a/backend/Makefile b/backend/Makefile index 8ffe711..3fee7b6 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -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: diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 3432c02..e9d9b28 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -3,7 +3,7 @@ package main import ( "context" "fmt" - "log" + "log/slog" "net/http" "os" "os/signal" @@ -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 } @@ -54,5 +54,5 @@ func main() { } <-done - log.Println("Graceful shutdown complete.") + slog.Info("graceful shutdown complete") } diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go index be8d25d..cfcb1a7 100644 --- a/backend/cmd/migrate/main.go +++ b/backend/cmd/migrate/main.go @@ -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 { diff --git a/backend/docs/_index.md b/backend/docs/_index.md index c6cd9d1..067ca94 100644 --- a/backend/docs/_index.md +++ b/backend/docs/_index.md @@ -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` | diff --git a/backend/docs/bootstrap.md b/backend/docs/bootstrap.md new file mode 100644 index 0000000..64f9fa3 --- /dev/null +++ b/backend/docs/bootstrap.md @@ -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)`. diff --git a/backend/docs/database.md b/backend/docs/database.md index af1de7a..50e8bce 100644 --- a/backend/docs/database.md +++ b/backend/docs/database.md @@ -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 @@ -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: @@ -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): @@ -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 diff --git a/backend/docs/environment.md b/backend/docs/environment.md index 25464c7..1e0de7d 100644 --- a/backend/docs/environment.md +++ b/backend/docs/environment.md @@ -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. @@ -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. diff --git a/backend/docs/error-handling.md b/backend/docs/error-handling.md index ab4067e..abd2ea6 100644 --- a/backend/docs/error-handling.md +++ b/backend/docs/error-handling.md @@ -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`: diff --git a/backend/docs/migrations.md b/backend/docs/migrations.md index a599abe..39f9b7e 100644 --- a/backend/docs/migrations.md +++ b/backend/docs/migrations.md @@ -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 --- @@ -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_.sql`, created automatically by `make migrate-create`. +`backend/internal/infrastructure/database/migrations/` — SQL files only. Naming: `YYYYMMDDHHMMSS_.sql`, created automatically by `make migrate-create`. ## Makefile targets | Target | What it does | |---|---| -| `make migrate-create name=` | Create a new timestamped SQL file in `migrations/` | +| `make migrate-create name=` | 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 | diff --git a/backend/docs/routing.md b/backend/docs/routing.md index af5935a..8f51093 100644 --- a/backend/docs/routing.md +++ b/backend/docs/routing.md @@ -26,17 +26,16 @@ func NewHandler(healthUC usecase.HealthUseCase) *Handler { The `Handler` struct holds use case interfaces — not `*sql.DB` directly. Add new use case fields here as features are added. ## Wiring (server.go) -`internal/server/server.go` contains `NewServer() (*http.Server, error)` — wiring only, no logic. -It builds `DBConfig` from env, calls `NewPostgresDB`, constructs the repository, use case, and handler in order, then returns a configured `*http.Server`. +`internal/server/server.go` contains `NewServer(app *bootstrap.App) *http.Server` — wiring only, no logic. +It receives the already-validated `*bootstrap.App` (which holds `*sql.DB` and `Config`), constructs the repository, use case, and handler in order, then returns a configured `*http.Server`. It does not read env vars or return an error. ```go -db, err := postgres.NewPostgresDB(cfg) -healthRepo := postgres.NewHealthRepository(db) +healthRepo := postgres.NewHealthRepository(app.DB) healthUC := usecase.NewHealthUseCase(healthRepo) h := handler.NewHandler(healthUC) -srv := &http.Server{ - Addr: fmt.Sprintf(":%d", port), +return &http.Server{ + Addr: fmt.Sprintf(":%d", app.Config.Port), Handler: h.RegisterRoutes(), IdleTimeout: time.Minute, ReadTimeout: 10 * time.Second, @@ -85,7 +84,8 @@ Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH. ## Graceful shutdown Wired in `cmd/api/main.go` via `signal.NotifyContext` for SIGINT/SIGTERM. 5-second shutdown timeout. Server notifies `done chan bool` when complete. -`main()` handles the error returned by `server.NewServer()` with `log.Fatalf`. +`main()` calls `bootstrap.Run(ctx)` first; on failure it writes to stderr and calls `os.Exit(1)`. +`server.NewServer` does not return an error — all fallible startup work is in bootstrap. Do not add shutdown logic to `internal/` — it belongs in `cmd/`. ## Adding a new route — checklist diff --git a/backend/docs/testing.md b/backend/docs/testing.md index 7929c61..fb205dd 100644 --- a/backend/docs/testing.md +++ b/backend/docs/testing.md @@ -1,9 +1,9 @@ --- topic: testing -last_verified: 2026-06-14 +last_verified: 2026-06-15 sources: - - internal/repository/postgres/health_repository_test.go - - internal/handler/hello_handler_test.go + - internal/infrastructure/database/postgres/health_repository_test.go + - internal/transport/handlers/hello_handler_test.go --- # Testing @@ -56,8 +56,8 @@ func TestMain(m *testing.M) { ## Package placement Tests live in the **same package** as the code under test. -- Repository tests: `package postgres` in `internal/repository/postgres/` -- Handler tests: `package handler` in `internal/handler/` +- Repository tests: `package postgres` in `internal/infrastructure/database/postgres/` +- Handler tests: `package handlers` in `internal/transport/handlers/` ## Handler unit tests Handlers that have no DB dependency (e.g. `HelloWorldHandler`) use `httptest` without Testcontainers: @@ -79,12 +79,12 @@ func TestHelloWorldHandler(t *testing.T) { ## Running tests ```bash make test # unit + integration (requires Docker) -make itest # integration only — runs ./internal/repository/postgres/... -go test ./internal/repository/postgres/... -v -run TestHealth # single test +make itest # integration only — runs ./internal/infrastructure/database/postgres/... +go test ./internal/infrastructure/database/postgres/... -v -run TestHealth # single test ``` ## Adding a new integration test -1. Add a `TestXxx(t *testing.T)` function in a `_test.go` file under `internal/repository/postgres/` (same package). +1. Add a `TestXxx(t *testing.T)` function in a `_test.go` file under `internal/infrastructure/database/postgres/` (same package). 2. Construct the repository under test using `testDB`: e.g. `repo := NewHealthRepository(testDB)`. 3. Call repository methods directly and assert on the results. 4. Use table-driven tests for multiple cases: diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 88b8e6b..b976419 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -15,7 +15,8 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" _ "github.com/joho/godotenv/autoload" - "backend/internal/repository/postgres" + "backend/internal/infrastructure/database/postgres" + "backend/pkg/logger" ) const ( @@ -36,9 +37,9 @@ type App struct { // Config holds all validated configuration values read from environment variables. type Config struct { - Port int - AppEnv string - DB postgres.DBConfig + Port int + Env string + DB postgres.DBConfig } // ConfigError is returned when required configuration is absent or invalid. @@ -59,7 +60,8 @@ type Pinger interface { // config, and probes services for readiness before returning. A non-nil error means // the process should not start; callers should exit with a non-zero status code. func Run(ctx context.Context) (*App, error) { - log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + log := logger.New(os.Getenv("ENV")) + slog.SetDefault(log) log.Info("bootstrap: starting") @@ -107,8 +109,8 @@ func loadConfig() Config { } return Config{ - Port: port, - AppEnv: os.Getenv("APP_ENV"), + Port: port, + Env: os.Getenv("ENV"), DB: postgres.DBConfig{ Host: os.Getenv("BLUEPRINT_DB_HOST"), Port: os.Getenv("BLUEPRINT_DB_PORT"), diff --git a/backend/migrations/.gitkeep b/backend/internal/infrastructure/database/migrations/.gitkeep similarity index 100% rename from backend/migrations/.gitkeep rename to backend/internal/infrastructure/database/migrations/.gitkeep diff --git a/backend/migrations/20260614201325_init.sql b/backend/internal/infrastructure/database/migrations/20260614201325_init.sql similarity index 100% rename from backend/migrations/20260614201325_init.sql rename to backend/internal/infrastructure/database/migrations/20260614201325_init.sql diff --git a/backend/internal/repository/postgres/db.go b/backend/internal/infrastructure/database/postgres/db.go similarity index 100% rename from backend/internal/repository/postgres/db.go rename to backend/internal/infrastructure/database/postgres/db.go diff --git a/backend/internal/repository/postgres/health_repository.go b/backend/internal/infrastructure/database/postgres/health_repository.go similarity index 100% rename from backend/internal/repository/postgres/health_repository.go rename to backend/internal/infrastructure/database/postgres/health_repository.go diff --git a/backend/internal/repository/postgres/health_repository_test.go b/backend/internal/infrastructure/database/postgres/health_repository_test.go similarity index 100% rename from backend/internal/repository/postgres/health_repository_test.go rename to backend/internal/infrastructure/database/postgres/health_repository_test.go diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 0a89555..eb801d6 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -5,17 +5,26 @@ import ( "net/http" "time" + "github.com/gin-gonic/gin" + "backend/internal/bootstrap" - "backend/internal/handler" - "backend/internal/repository/postgres" + "backend/internal/infrastructure/database/postgres" + "backend/internal/transport/handlers" "backend/internal/usecase" ) // NewServer wires all layers and returns a configured *http.Server. func NewServer(app *bootstrap.App) *http.Server { + switch app.Config.Env { + case "staging", "production": + gin.SetMode(gin.ReleaseMode) + default: + gin.SetMode(gin.DebugMode) + } + healthRepo := postgres.NewHealthRepository(app.DB) healthUC := usecase.NewHealthUseCase(healthRepo) - h := handler.NewHandler(healthUC) + h := handlers.NewHandler(healthUC) return &http.Server{ Addr: fmt.Sprintf(":%d", app.Config.Port), diff --git a/backend/internal/handler/handler.go b/backend/internal/transport/handlers/handler.go similarity index 94% rename from backend/internal/handler/handler.go rename to backend/internal/transport/handlers/handler.go index 84e1ea7..e738fb6 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/transport/handlers/handler.go @@ -1,4 +1,4 @@ -package handler +package handlers import ( "backend/internal/usecase" diff --git a/backend/internal/handler/health_handler.go b/backend/internal/transport/handlers/health_handler.go similarity index 77% rename from backend/internal/handler/health_handler.go rename to backend/internal/transport/handlers/health_handler.go index d1c073b..d7d1772 100644 --- a/backend/internal/handler/health_handler.go +++ b/backend/internal/transport/handlers/health_handler.go @@ -1,7 +1,7 @@ -package handler +package handlers import ( - "log" + "log/slog" "net/http" "github.com/gin-gonic/gin" @@ -10,7 +10,7 @@ import ( func (h *Handler) HealthHandler(c *gin.Context) { stats, err := h.healthUC.GetHealth(c.Request.Context()) if err != nil { - log.Printf("health check failed: %v", err) + slog.Warn("health check failed", "error", err) c.JSON(http.StatusServiceUnavailable, stats) return } diff --git a/backend/internal/handler/hello_handler.go b/backend/internal/transport/handlers/hello_handler.go similarity index 90% rename from backend/internal/handler/hello_handler.go rename to backend/internal/transport/handlers/hello_handler.go index 3764e42..2415134 100644 --- a/backend/internal/handler/hello_handler.go +++ b/backend/internal/transport/handlers/hello_handler.go @@ -1,4 +1,4 @@ -package handler +package handlers import ( "net/http" diff --git a/backend/internal/handler/hello_handler_test.go b/backend/internal/transport/handlers/hello_handler_test.go similarity index 97% rename from backend/internal/handler/hello_handler_test.go rename to backend/internal/transport/handlers/hello_handler_test.go index c41d36e..1e5dad2 100644 --- a/backend/internal/handler/hello_handler_test.go +++ b/backend/internal/transport/handlers/hello_handler_test.go @@ -1,4 +1,4 @@ -package handler +package handlers import ( "net/http" diff --git a/backend/internal/handler/routes.go b/backend/internal/transport/handlers/routes.go similarity index 82% rename from backend/internal/handler/routes.go rename to backend/internal/transport/handlers/routes.go index 048f12d..4759ad3 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/transport/handlers/routes.go @@ -1,15 +1,18 @@ -package handler +package handlers import ( "net/http" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + + "backend/internal/transport/middleware" ) // RegisterRoutes creates the Gin engine, applies middleware, and registers all routes. func (h *Handler) RegisterRoutes() http.Handler { - r := gin.Default() + r := gin.New() + r.Use(gin.Recovery(), middleware.Logger()) r.Use(cors.New(cors.Config{ AllowOrigins: []string{"http://localhost:3000"}, diff --git a/backend/internal/transport/middleware/logger.go b/backend/internal/transport/middleware/logger.go new file mode 100644 index 0000000..fb8083f --- /dev/null +++ b/backend/internal/transport/middleware/logger.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "log/slog" + "time" + + "github.com/gin-gonic/gin" +) + +// Logger is a Gin middleware that emits one slog record per request. +// It uses slog.Default() so it picks up whichever handler bootstrap configured. +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + c.Next() + + latency := time.Since(start) + status := c.Writer.Status() + + attrs := []any{ + "status", status, + "method", c.Request.Method, + "path", path, + "latency", latency, + "ip", c.ClientIP(), + } + if query != "" { + attrs = append(attrs, "query", query) + } + if len(c.Errors) > 0 { + attrs = append(attrs, "errors", c.Errors.String()) + } + + switch { + case status >= 500: + slog.Error("request", attrs...) + case status >= 400: + slog.Warn("request", attrs...) + default: + slog.Info("request", attrs...) + } + } +} diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go new file mode 100644 index 0000000..d354bf2 --- /dev/null +++ b/backend/pkg/logger/logger.go @@ -0,0 +1,19 @@ +package logger + +import ( + "log/slog" + "os" +) + +// New returns a structured logger configured for the given environment. +// JSON format is used for staging and production (suitable for log aggregators). +// Human-readable text format is used for all other values (local dev). +func New(env string) *slog.Logger { + opts := &slog.HandlerOptions{Level: slog.LevelInfo} + switch env { + case "staging", "production": + return slog.New(slog.NewJSONHandler(os.Stdout, opts)) + default: + return slog.New(slog.NewTextHandler(os.Stdout, opts)) + } +}