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
36 changes: 25 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,24 @@ Each doc file has `last_verified` and `sources` frontmatter. The `docs` agent ma
## Project layout
```
backend/
cmd/api/main.go # entry point — wiring only, no logic
cmd/api/main.go # entry point — wires layers, graceful shutdown
internal/
server/server.go # Server struct, NewServer()
server/routes.go # RegisterRoutes(), all handlers as *Server methods
database/database.go # Service interface + implementation, all queries
database/database_test.go # integration tests (Testcontainers)
domain/ # Layer 1: entities + repository interfaces (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
server/
server.go # NewServer() — wires all layers, returns *http.Server
docker-compose.yml
.env # never commit secrets
Makefile
Expand All @@ -78,10 +90,11 @@ mobile/
CLAUDE.md → AGENTS.md # mobile-specific rules (read before writing Kotlin/Compose)
```

## Go conventions
- Business logic in `internal/` only. `cmd/` just wires things together.
- New query → add to `Service` interface, implement on `service`, test in `database_test.go`.
- New route → register in `RegisterRoutes()`, handler as method on `*Server`.
## 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/`.
- Return errors up the stack. Never `log.Fatal` or `os.Exit` inside `internal/`.
- Run `go vet ./...` before committing.

Expand All @@ -102,8 +115,9 @@ mobile/

## Testing — non-negotiable
- **Never mock the database.** Always use Testcontainers.
- Follow the `TestMain` + `mustStartPostgresContainer()` pattern in `database_test.go`.
- Tests live in the same package as the code (`package database`, `package server`).
- 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.
- Docker must be running for integration tests.

## Hard rules (hooks enforce some of these)
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
PORT=8080
APP_ENV=local
BLUEPRINT_DB_HOST=localhost
BLUEPRINT_DB_PORT=5432
BLUEPRINT_DB_DATABASE=blueprint
BLUEPRINT_DB_USERNAME=
BLUEPRINT_DB_PASSWORD=
BLUEPRINT_DB_SCHEMA=public
BLUEPRINT_DB_SSLMODE=disable
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:
# Integrations Tests for the application
itest:
@echo "Running integration tests..."
@go test ./internal/database -v
@go test ./internal/repository/postgres/... -v

# Clean the binary
clean:
Expand Down
23 changes: 7 additions & 16 deletions backend/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,46 +13,37 @@ import (
)

func gracefulShutdown(apiServer *http.Server, done chan bool) {
// Create context that listens for the interrupt signal from the OS.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

// Listen for the interrupt signal.
<-ctx.Done()

log.Println("shutting down gracefully, press Ctrl+C again to force")
stop() // Allow Ctrl+C to force shutdown
stop()

// The context is used to inform the server it has 5 seconds to finish
// the request it is currently handling
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)
}

log.Println("Server exiting")

// Notify the main goroutine that the shutdown is complete
done <- true
}

func main() {
srv, err := server.NewServer()
if err != nil {
log.Fatalf("failed to initialize server: %v", err)
}

server := server.NewServer()

// Create a done channel to signal when the shutdown is complete
done := make(chan bool, 1)
go gracefulShutdown(srv, done)

// Run graceful shutdown in a separate goroutine
go gracefulShutdown(server, done)

err := server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(fmt.Sprintf("http server error: %s", err))
}

// Wait for the graceful shutdown to complete
<-done
log.Println("Graceful shutdown complete.")
}
10 changes: 5 additions & 5 deletions backend/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ 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/database/database.go` |
| HTTP routing & handler patterns | [routing.md](routing.md) | `internal/server/routes.go`, `internal/server/server.go` |
| Integration testing with Testcontainers | [testing.md](testing.md) | `internal/database/database_test.go` |
| Error handling conventions | [error-handling.md](error-handling.md) | `internal/database/database.go`, `cmd/api/main.go` |
| Environment variables | [environment.md](environment.md) | `.env`, `internal/database/database.go`, `internal/server/server.go` |
| 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` |
| 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` |
| 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` |
94 changes: 65 additions & 29 deletions backend/docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,69 +2,105 @@
topic: database
last_verified: 2026-06-14
sources:
- internal/database/database.go
- internal/database/database_test.go
- internal/domain/health.go
- internal/usecase/health_usecase.go
- internal/repository/postgres/db.go
- internal/repository/postgres/health_repository.go
---

# Database

## Driver
`database/sql` standard library with pgx v5 as the stdlib driver.
Import: `_ "github.com/jackc/pgx/v5/stdlib"` (blank import, registers the driver).
Import: `_ "github.com/jackc/pgx/v5/stdlib"` (blank import in `db.go`, registers the driver).
Do NOT use pgx's native API — use `database/sql` methods only.

## Connection
Singleton pattern via package-level `dbInstance *service` var.
`New()` returns early if `dbInstance != nil`.
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.

`DBConfig` holds all connection parameters:

```go
type DBConfig struct {
Host string
Port string
Database string
Username string
Password string
Schema string
SSLMode string
}
```

`NewPostgresDB(cfg DBConfig) (*sql.DB, error)` builds the connection string and calls `sql.Open`. It returns an error instead of calling `log.Fatal`.

Connection string format:
```
postgres://username:password@host:port/database?sslmode=disable&search_path=schema
```

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

## 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
```

## Service interface
All DB operations are defined as methods on the `Service` interface.
The `service` struct is the private implementation.
The `HealthReader` interface is defined in the `usecase` package (Dependency Inversion — the use case owns the interface it depends on):

```go
type Service interface {
Health() map[string]string
Close() error
// Add new methods here
// usecase/health_usecase.go
type HealthReader interface {
Health(ctx context.Context) (domain.HealthStats, error)
}

type HealthUseCase interface {
GetHealth(ctx context.Context) (domain.HealthStats, error)
}
```

## Repository pattern
Each repository is a struct that holds `*sql.DB` and is constructed with a `New*` function.

type service struct {
```go
type HealthRepository struct {
db *sql.DB
}

func NewHealthRepository(db *sql.DB) *HealthRepository {
return &HealthRepository{db: db}
}
```

## Adding a new query — exact pattern
1. Add method signature to `Service` interface.
2. Implement on `*service` using `s.db.QueryContext` / `s.db.QueryRowContext` / `s.db.ExecContext`.
3. Always use parameterized queries — `$1`, `$2`, etc. Never string-concatenate SQL.
4. Add integration test in `database_test.go`.
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.
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/`.

```go
// Interface
GetUser(ctx context.Context, id int64) (*User, error)

// Implementation
func (s *service) GetUser(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
var u User
// Repository method
func (r *UserRepository) GetUser(ctx context.Context, id int64) (*domain.User, error) {
row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
var u domain.User
if err := row.Scan(&u.ID, &u.Name); err != nil {
return nil, err
return nil, fmt.Errorf("postgres: GetUser: %w", err)
}
return &u, nil
}
```

## Health check
`Health()` returns a `map[string]string` with `status`, `message`, and connection pool stats.
Called by the `/health` route. Calls `s.db.PingContext` with a 1-second timeout.
Note: `Health()` calls `log.Fatalf` on ping failure — this is intentional (terminates on unrecoverable DB loss).
`HealthRepository.Health(ctx)` returns `(domain.HealthStats, error)`.
On ping failure: sets `stats["status"] = "down"` and returns a non-nil error.
On success: sets `stats["status"] = "up"` plus connection pool stats.
The HTTP handler returns 503 when this method returns an error (see routing.md).

## Connection pool stats
`Health()` exposes: `open_connections`, `in_use`, `idle`, `wait_count`, `wait_duration`, `max_idle_closed`, `max_lifetime_closed`.
Expand Down
48 changes: 38 additions & 10 deletions backend/docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,59 @@
topic: error-handling
last_verified: 2026-06-14
sources:
- internal/database/database.go
- internal/server/routes.go
- internal/repository/postgres/health_repository.go
- internal/handler/health_handler.go
- cmd/api/main.go
---

# Error Handling

## General rule
Return errors up the call stack. Callers decide how to handle them.
Never use `log.Fatal` or `os.Exit` inside `internal/` except for the two documented exceptions below.
Never use `log.Fatal` or `os.Exit` inside `internal/`.

## Documented exceptions (intentional)
## Documented exception (intentional)
| Location | Call | Reason |
|---|---|---|
| `database.go: New()` | `log.Fatal(err)` | Startup failure — if the DB can't connect at boot, the process should not continue |
| `database.go: Health()` | `log.Fatalf(...)` | Unrecoverable DB loss detected during health check — intentional termination |
| `cmd/api/main.go: main()` | `log.Fatalf(...)` | `server.NewServer()` returned an error — process cannot start |

These are startup/health-check paths only. All other paths in `internal/` return errors.
This is the only permitted `log.Fatal` call and it lives in `cmd/`, not `internal/`.

## Repository errors
Repository methods return `(Result, error)`. On failure, wrap with context using `fmt.Errorf`:

```go
func (r *HealthRepository) Health(ctx context.Context) (domain.HealthStats, error) {
if err := r.db.PingContext(pingCtx); err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("db down: %v", err)
return stats, fmt.Errorf("postgres: health ping: %w", err)
}
// ...
return stats, nil
}
```

## Handler error responses
In Gin handlers, map errors to HTTP status codes explicitly:
Handlers call use cases, check errors, and map them to HTTP status codes. The health handler returns 503 when the DB is unreachable:

```go
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)
c.JSON(http.StatusServiceUnavailable, stats)
return
}
c.JSON(http.StatusOK, stats)
}
```

For general handlers, map errors to status codes explicitly:

```go
func (s *Server) getItemHandler(c *gin.Context) {
item, err := s.db.GetItem(c.Request.Context(), id)
func (h *Handler) getItemHandler(c *gin.Context) {
item, err := h.itemUC.GetItem(c.Request.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
Expand Down
Loading
Loading