Project: vilbert/skills Commit: 0d787027c633b7138f8915812c29b82aa82f1748 Message: Split backend-dev skill into references/ folder and add SOLID principles Major restructuring: - Mov --- AGENTS.md --- @@ -6,7 +6,6 @@ OpenCode skill collection for reusable project templates and specialized develop - Each subdirectory = one skill domain - `SKILL.md` in each directory contains comprehensive usage instructions -- Detailed documentation split into focused files ## Adding New Skills @@ -20,39 +19,28 @@ OpenCode skill collection for reusable project templates and specialized develop | `backend-dev/` | Go hexagonal architecture template with Keycloak JWT auth | | `orchestrator/` | End-to-end development orchestration | -## backend-dev Skill +--- -Go backend service template using hexagonal (ports & adapters) architecture with 12-factor compliance. +# backend-dev Skill -### Documentation Structure +Go backend service template using hexagonal (ports & adapters) architecture. -| File | Contents | -|------|----------| -| [SKILL.md](backend-dev/SKILL.md) | Entry point, quick start, commands | -| [ARCHITECTURE.md](backend-dev/ARCHITECTURE.md) | Hexagonal patterns, DDD, SOLID principles | -| [12-FACTOR.md](backend-dev/12-FACTOR.md) | 12-factor methodology | -| [CONFIGURATION.md](backend-dev/CONFIGURATION.md) | OpenBao secrets, env loading | -| [AUTHENTICATION.md](backend-dev/AUTHENTICATION.md) | Keycloak JWT integration | -| [DATABASE.md](backend-dev/DATABASE.md) | PostgreSQL, migrations, caching | -| [DEPLOYMENT.md](backend-dev/DEPLOYMENT.md) | Docker, CI/CD, admin CLI | -| [CODING-STANDARDS.md](backend-dev/CODING-STANDARDS.md) | REST, SOLID, error handling, testing | - -### Tech Stack +## Tech Stack - Gin web framework - PostgreSQL/pgx - Prometheus metrics - OpenTelemetry tracing - Keycloak JWT authentication (keyfunc/v3) -- OpenBao v2 for secrets (token-based, KV v2) +- OpenBao v2 for secrets management -### Key Environment Variables +## Key Environment Variables ```env KEYCLOAK_JWKS_URL=https://auth.pharos.id/realms/production/protocol/openid-connect/certs KEYCLOAK_CLIENT_ID=exodus PERSISTENCE_DSN=postgres://user:pass@localhost:5432/dbname?sslmode=disable ``` -### Keycloak JWT Claims Mapping +## Keycloak JWT Claims Mapping | JWT Claim | Field | |-----------|-------| | `nip` | Primary key (VARCHAR(20)) | @@ -60,22 +48,73 @@ PERSISTENCE_DSN=postgres://user:pass@localhost:5432/dbname?sslmode=disable | `email` | Email (non-unique) | | `resource_access.{client_id}.roles` | Authorization roles | -### Design Principles +## Authentication Flow +1. Backend acts as OAuth2 Resource Server (validates Bearer tokens) +2. JWT validated locally via Keycloak JWKS (no client secret) +3. User auto-created/updated on each authentication +4. Roles extracted from `resource_access.{client_id}.roles` + +## Documentation + +Main entry: [backend-dev/SKILL.md](backend-dev/SKILL.md) + +Detailed references: +- [ARCHITECTURE.md](backend-dev/references/ARCHITECTURE.md) - Hexagonal patterns, DDD, provider +- [12-FACTOR.md](backend-dev/references/12-FACTOR.md) - All 12 factors with Go examples +- [CONFIGURATION.md](backend-dev/references/CONFIGURATION.md) - OpenBao, env, provider wiring +- [AUTHENTICATION.md](backend-dev/references/AUTHENTICATION.md) - Keycloak JWT integration +- [DATABASE.md](backend-dev/references/DATABASE.md) - PostgreSQL schema, migrations +- [DEPLOYMENT.md](backend-dev/references/DEPLOYMENT.md) - Docker, CI/CD, admin CLI +- [CODING-STANDARDS.md](backend-dev/references/CODING-STANDARDS.md) - REST, SOLID, error handling + +## Design Principles + - **SOLID**: SRP (one usecase per operation), DIP (depend on ports) -- **Hexagonal**: Core domain has no external dependencies -- **12-Factor**: Environment-based config, stateless processes -- **Error Handling**: NEVER skip error checking +- **Hexagonal Architecture**: Domain → Ports → Adapters +- **12-Factor Compliance**: All factors implemented +- **NEVER Skip Error Checking**: Always handle or return errors +- **NEVER Use Panic**: Return errors to parent -### Database +## Database - `users` table with `nip` as primary key - `cache.entries` unlogged table for caching -### Commands -```bash -./messaging-be # Run server (default) -./messaging-be admin migrate up # Run migrations -./messaging-be admin user list # List users +--- + +# orchestrator Skill + +End-to-end software development orchestration by delegating to specialized subagents. + +## Execution Pattern +``` +understand → plan → validate → delegate → review → integrate → commit → deliver +``` + +## Subagent Types +| Type | Use For | +|------|---------| +| `frontend-dev` | React/Vue/UI implementation | +| `backend-dev` | API, business logic, services | +| `devops` | CI/CD, deployment, containers | +| `qa` | Test strategy, automation | +| `infra` | Cloud, architecture, networking | +| `reviewer` | Code review, specification validation | + +## Critical Rules +1. Every code task gets a test +2. Every completed task gets reviewed before commit +3. Never commit failing tests +4. Escalate after 3 review rounds +5. Commit only after completion + +## Blocked Handling ``` +Stuck on issue → Spawn investigative subagent → Still stuck → Escalate to user +``` + +See [orchestrator/SKILL.md](orchestrator/SKILL.md) for full documentation. + +--- ## Skill Documentation Conventions --- backend-dev/DEPLOYMENT.md --- @@ -1,588 +0,0 @@ -# Deployment - -- [Table of Contents](#table-of-contents) -- [Application Bootstrapper](#application-bootstrapper) -- [Admin CLI](#admin-cli) -- [Health Endpoint](#health-endpoint) -- [Docker](#docker) -- [Docker Compose](#docker-compose) -- [GitLab CI/CD](#gitlab-cicd) -- [Operations](#operations) - ---- - -## Table of Contents - -1. [Application Bootstrapper](#application-bootstrapper) -2. [Admin CLI](#admin-cli) -3. [Health Endpoint](#health-endpoint) -4. [Docker](#docker) -5. [Docker Compose](#docker-compose) -6. [GitLab CI/CD](#gitlab-cicd) -7. [Operations](#operations) - ---- - -## Application Bootstrapper - -Single binary with subcommand dispatch. - -### main.go - -```go -package main - -import ( - "fmt" - "os" - - "myapp/cmd/admin" - "myapp/cmd/server" -) - -func main() { - if len(os.Args) < 2 { - server.Run([]string{}) - return - } - - switch os.Args[1] { - case "server": - server.Run(os.Args[2:]) - case "admin": - admin.Run(os.Args[2:]) - case "version", "-v", "--version": - printVersion() - case "--help", "-h": - printHelp() - default: - fmt.Printf("Unknown command: %s\n", os.Args[1]) - printHelp() - os.Exit(1) - } -} - -func printVersion() { - fmt.Printf("%s version %s (commit: %s)\n", - buildinfo.Name, - buildinfo.Version, - buildinfo.Commit, - ) -} - -func printHelp() { - fmt.Println("Usage: messaging-be [command] [args...]") - fmt.Println() - fmt.Println("Commands:") - fmt.Println(" server Run HTTP server (default)") - fmt.Println(" admin [cmd] Run administrative commands") - fmt.Println(" version Show version information") - fmt.Println(" --help Show this help") -} -``` - -### cmd/server/run.go - -```go -package server - -import ( - "context" - "flag" - "fmt" - "os" - - "myapp/config" - "myapp/internal/provider" - "myapp/pkg/logger" -) - -func Run(args []string) { - fs := flag.NewFlagSet("server", flag.ExitOnError) - port := fs.String("port", "", "Server port (overrides config)") - - if err := fs.Parse(args); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - ctx := context.Background() - cfg, err := config.Load(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) - os.Exit(1) - } - - if *port != "" { - cfg.App.Port = *port - } - - pvd, err := provider.NewProvider(ctx, cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to initialize: %v\n", err) - os.Exit(1) - } - defer pvd.Close(ctx) - - log := logger.Default() - log.Info("Starting server", "port", cfg.App.Port) - - if err := runServer(pvd, cfg); err != nil { - log.Error("Server error", "error", err) - os.Exit(1) - } -} -``` - -### cmd/admin/run.go - -```go -package admin - -import ( - "context" - "fmt" - "os" - "strconv" - - "github.com/golang-migrate/migrate/v4" - _ "github.com/golang-migrate/migrate/v4/database/postgres" - _ "github.com/golang-migrate/migrate/v4/source/file" - "github.com/urfave/cli/v2" - "myapp/config" - "myapp/internal/provider" -) - -func Run(args []string) { - ctx := context.Background() - - cfg, err := config.Load(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "Config error: %v\n", err) - os.Exit(1) - } - - pvd, err := provider.NewProvider(ctx, cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Provider error: %v\n", err) - os.Exit(1) - } - defer pvd.Close(ctx) - - app := &cli.App{ - Name: "admin", - Usage: "Administrative commands", - Commands: []*cli.Command{ - migrateCommand(cfg), - userCommand(pvd), - cacheCommand(pvd), - healthCommand(pvd), - }, - } - - if err := app.Run(args); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func migrateCommand(cfg *config.Config) *cli.Command { - return &cli.Command{ - Name: "migrate", - Usage: "Database migrations", - Subcommands: []*cli.Command{ - { - Name: "up", - Usage: "Apply all pending migrations", - Action: func(c *cli.Context) error { - m, err := migrate.New( - "file://db/migrations/persistence", - cfg.Persistence.DSN, - ) - if err != nil { - return fmt.Errorf("failed to create migrator: %w", err) - } - if err := m.Up(); err != nil && err != migrate.ErrNoChange { - return fmt.Errorf("migration failed: %w", err) - } - fmt.Println("Migrations applied successfully") - return nil - }, - }, - { - Name: "down", - Usage: "Rollback migrations", - Action: func(c *cli.Context) error { - if cfg.App.Env == "production" { - return fmt.Errorf("migrate down is disabled in production environment") - } - - steps := 1 - if c.Args().Len() > 0 { - steps, _ = strconv.Atoi(c.Args().First()) - } - - m, err := migrate.New( - "file://db/migrations/persistence", - cfg.Persistence.DSN, - ) - if err != nil { - return fmt.Errorf("failed to create migrator: %w", err) - } - if err := m.Steps(-steps); err != nil && err != migrate.ErrNoChange { - return fmt.Errorf("rollback failed: %w", err) - } - fmt.Printf("Rolled back %d migration(s)\n", steps) - return nil - }, - }, - { - Name: "status", - Usage: "Show migration status", - Action: func(c *cli.Context) error { - m, err := migrate.New( - "file://db/migrations/persistence", - cfg.Persistence.DSN, - ) - if err != nil { - return fmt.Errorf("failed to create migrator: %w", err) - } - version, dirty, err := m.Version() - if err != nil { - if err == migrate.ErrNilVersion { - fmt.Println("No migrations applied") - return nil - } - return fmt.Errorf("failed to get version: %w", err) - } - fmt.Printf("Version: %d, Dirty: %v\n", version, dirty) - return nil - }, - }, - }, - } -} -``` - ---- - -## Admin CLI - -### Available Commands - -```bash -# Database migrations -./messaging-be admin migrate up # Apply all pending -./messaging-be admin migrate down 1 # Rollback 1 migration -./messaging-be admin migrate status # Check status - -# User management -./messaging-be admin user list # List all users -./messaging-be admin user info # Show user details -./messaging-be admin user deactivate # Deactivate user -./messaging-be admin user activate # Reactivate user - -# Cache management -./messaging-be admin cache clear # Clear all cache -./messaging-be admin cache stats # Show cache stats - -# Health checks -./messaging-be admin health # Check all services -``` - -### Production Safety - -`migrate down` is disabled in production: - -```go -if cfg.App.Env == "production" { - return fmt.Errorf("migrate down is disabled in production environment") -} -``` - ---- - -## Health Endpoint - -### Endpoint: GET /healthz - -Returns 200 if healthy, 503 if degraded: - -```json -{ - "status": "ok", - "version": "1.0.0", - "commit": "abc123", - "checks": { - "database": true, - "cache": true - } -} -``` - -### Implementation - -```go -r.GET("/healthz", func(c *gin.Context) { - checks := map[string]bool{ - "database": checkDatabase(provider.PersistencePool), - "cache": checkCache(provider.CachePool), - } - - allHealthy := true - for _, ok := range checks { - if !ok { - allHealthy = false - break - } - } - - status := "ok" - if !allHealthy { - status = "degraded" - } - - response := gin.H{ - "status": status, - "version": buildinfo.Version, - "commit": buildinfo.Commit, - "checks": checks, - } - - if allHealthy { - c.JSON(200, response) - } else { - c.JSON(503, response) - } -}) -``` - ---- - -## Docker - -### Dockerfile (Multi-stage) - -```dockerfile -FROM golang:1.21-alpine AS builder - -WORKDIR /app -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build \ - -ldflags "-s -w \ - -X myapp/pkg/buildinfo.Version=$VERSION \ - -X myapp/pkg/buildinfo.Commit=$COMMIT" \ - -o messaging-be . - -FROM alpine:3.19 - -RUN apk --no-cache add ca-certificates tzdata - -WORKDIR /app -COPY --from=builder /app/messaging-be . -COPY --from=builder /app/db/migrations ./db/migrations - -EXPOSE 8080 - -CMD ["./messaging-be"] -``` - -### Build Args - -| Arg | Description | -|-----|-------------| -| `VERSION` | Version string (e.g., `1.0.0`) | -| `COMMIT` | Git commit SHA | - ---- - -## Docker Compose - -### docker-compose.yml - -```yaml -version: '3.8' - -services: - app: - build: .. - env_file: ../.env.local - ports: - - "8080:8080" - environment: - PERSISTENCE_DSN: ${PERSISTENCE_DSN} - KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL} - KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} -``` - -### .env.local (for local dev) - -```env -APP_ENV=local -APP_PORT=8080 - -PERSISTENCE_DSN=postgres://user:pass@host:5432/messaging?sslmode=disable -KEYCLOAK_JWKS_URL=https://auth.pharos.id/realms/production/protocol/openid-connect/certs -KEYCLOAK_CLIENT_ID=exodus -``` - ---- - -## GitLab CI/CD - -### .gitlab-ci.yml - -```yaml -stages: - - build - - test - - release - - deploy - -variables: - IMAGE_NAME: $CI_REGISTRY_IMAGE/messaging-be - -build: - stage: build - image: golang:1.21-alpine - before_script: - - apk add git make - script: - - make tidy - - make build - artifacts: - paths: - - bin/ - expire_in: 1 day - -test: - stage: test - image: golang:1.21-alpine - services: - - postgres:15-alpine - variables: - POSTGRES_DB: test - POSTGRES_USER: test - POSTGRES_PASSWORD: test - PERSISTENCE_DSN: postgres://test:test@postgres:5432/test?sslmode=disable - before_script: - - apk add git make - script: - - make migrate - - go test -race -coverprofile=coverage.out ./... - - go tool cover -func=coverage.out - coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/' - -release: - stage: release - image: docker:24.0-cli - services: - - docker:24.0-dind - script: - - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - - docker build --build-arg VERSION=$CI_COMMIT_SHA --build-arg COMMIT=$CI_COMMIT_SHORT_SHA -t $IMAGE_NAME:$CI_COMMIT_SHA -t $IMAGE_NAME:latest . - - docker push $IMAGE_NAME:$CI_COMMIT_SHA - - docker push $IMAGE_NAME:latest - rules: - - main - -deploy-production: - stage: deploy - image: bitnami/kubectl:latest - environment: - name: production - url: https://api.example.com - script: - - kubectl set image deployment/messaging-be server=$IMAGE_NAME:$CI_COMMIT_SHA - - kubectl rollout status deployment/messaging-be - rules: - - main - when: manual -``` - ---- - -## Operations - -### Build - -```bash -make build -``` - -### Run Server - -```bash -./bin/messaging-be -./bin/messaging-be server -./bin/messaging-be server -port=8081 -``` - -### Run Admin Commands - -```bash -./bin/messaging-be admin migrate up -./bin/messaging-be admin user list -./bin/messaging-be admin health -``` - -### Docker Operations - -```bash -# Build and run -docker-compose up -d - -# Run migrations -docker-compose exec app /app/messaging-be admin migrate up - -# Check logs -docker-compose logs -f app - -# Health check -curl http://localhost:8080/healthz -``` - -### Version - -```bash -./bin/messaging-be version -# Output: messaging-be version 1.0.0 (commit: abc123) -``` - -### Makefile - -```makefile -BINARY_NAME=messaging-be -VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") - -.PHONY: build clean run server admin migrate - -build: -\tgo build -ldflags "-s -w -X myapp/pkg/buildinfo.Version=$(VERSION) -X myapp/pkg/buildinfo.Commit=$(COMMIT)" -o bin/$(BINARY_NAME) . - -clean: -\trm -rf bin/ - -run: build -\t./bin/$(BINARY_NAME) - -server: build -\t./bin/$(BINARY_NAME) server - -admin: build -\t./bin/$(BINARY_NAME) admin - -migrate: build -\t./bin/$(BINARY_NAME) admin migrate up - -version: build -\t./bin/$(BINARY_NAME) version - -tidy: -\tgo mod tidy -\tgo mod verify -``` --- backend-dev/SKILL.md --- @@ -1,54 +1,83 @@ -# Go Hexagonal Architecture - Production Ready +# Go Hexagonal Architecture Production-ready Go backend template with hexagonal architecture, 12-factor compliance, and Keycloak OIDC integration. -## When to Use +## Table of Contents -- Building REST API services in Go requiring production readiness -- Projects requiring clean separation of concerns with hexagonal architecture -- Applications needing observability (logging, metrics, tracing) -- Services with PostgreSQL caching layer for high performance -- Systems requiring horizontal scaling with HA-safe operations +1. [Quick Start](#quick-start) +2. [Documentation](#documentation) +3. [Project Structure](#project-structure) +4. [Core Principles](#core-principles) +5. [Commands](#commands) +6. [Environment Variables](#environment-variables) --- ## Quick Start -```bash -# 1. Install dependencies -go mod download +### 1. Prerequisites + +- Go 1.21+ +- PostgreSQL 15+ +- OpenBao (for staging/production) or `.env` file (for local) + +### 2. Configuration + +**Local development** - create `.env` file: + +```env +APP_ENV=local +APP_MODE=debug +APP_PORT=8080 +APP_ALLOWED_ORIGINS=http://localhost:3000 + +PERSISTENCE_DSN=postgres://user:pass@localhost:5432/messaging?sslmode=disable +CACHE_DSN=postgres://user:pass@localhost:5432/messaging?sslmode=disable&search_path=cache -# 2. Configure environment -cp .env.example .env -# Edit .env with your settings +KEYCLOAK_JWKS_URL=https://auth.pharos.id/realms/production/protocol/openid-connect/certs +KEYCLOAK_CLIENT_ID=exodus -# 3. Build +LOG_LEVEL=debug +LOG_FORMAT=json +``` + +### 3. Build + +```bash make build +``` -# 4. Run server (default) +### 4. Run Migrations + +```bash +./bin/messaging-be migrate +``` + +### 5. Run Server + +```bash ./bin/messaging-be +``` -# 5. Or run admin commands -./bin/messaging-be admin migrate up -./bin/messaging-be admin user list +### 6. Verify -# 6. Verify +```bash curl http://localhost:8080/healthz ``` --- -## Documentation Index +## Documentation | File | Contents | |------|----------| -| [ARCHITECTURE.md](ARCHITECTURE.md) | Hexagonal patterns, DDD, SOLID principles, provider | -| [12-FACTOR.md](12-FACTOR.md) | 12-factor methodology compliance | -| [CONFIGURATION.md](CONFIGURATION.md) | OpenBao secrets, env loading, provider wiring | -| [AUTHENTICATION.md](AUTHENTICATION.md) | Keycloak JWT, resource server, auth middleware | -| [DATABASE.md](DATABASE.md) | PostgreSQL schema, migrations, caching | -| [DEPLOYMENT.md](DEPLOYMENT.md) | Docker, CI/CD, admin CLI, health endpoint | -| [CODING-STANDARDS.md](CODING-STANDARDS.md) | REST standards, SOLID, error handling, testing | +| [references/ARCHITECTURE.md](references/ARCHITECTURE.md) | Hexagonal patterns, DDD, provider | +| [references/12-FACTOR.md](references/12-FACTOR.md) | 12-factor methodology | +| [references/CONFIGURATION.md](references/CONFIGURATION.md) | OpenBao, env loading | +| [references/AUTHENTICATION.md](references/AUTHENTICATION.md) | Keycloak JWT | +| [references/DATABASE.md](references/DATABASE.md) | PostgreSQL, migrations | +| [references/DEPLOYMENT.md](references/DEPLOYMENT.md) | Docker, CI/CD, admin CLI | +| [references/CODING-STANDARDS.md](references/CODING-STANDARDS.md) | REST, SOLID, error handling | --- @@ -57,155 +86,174 @@ curl http://localhost:8080/healthz ``` . ├── cmd/ -│ ├── server/ -│ │ └── run.go # Server entry point -│ └── admin/ -│ └── run.go # Admin CLI entry point -├── config/ -│ ├── config.go -│ ├── model.go -│ └── loader.go # OpenBao/env loader +│ ├── server/ # HTTP server +│ └── migrate/ # Migration commands +├── config/ # Configuration loading ├── internal/ │ ├── core/ -│ │ ├── domain/ # Entities, value objects, events -│ │ ├── port/ # Inbound/outbound interfaces -│ │ └── usecase/ # Business logic +│ │ ├── domain/ # Domain entities, events +│ │ ├── port/ # Inbound/outbound interfaces +│ │ └── usecase/ # Business logic │ ├── adapter/ -│ │ ├── inbound/ # HTTP handlers -│ │ └── outbound/ # PostgreSQL, cache, keycloak -│ └── provider/ # Dependency provider (Option B) -├── middlewares/ # Auth, logging, metrics, tracing -├── pkg/ # Logger, metrics, response, grace -├── db/migrations/ # SQL migrations -├── deployments/ # Dockerfile, docker-compose, .gitlab-ci.yml -└── main.go # Bootstrapper +│ │ ├── inbound/ # HTTP handlers +│ │ └── outbound/ # DB, cache, keycloak adapters +│ └── provider/ # Dependency provider +├── middlewares/ # HTTP middleware (auth, logging) +├── pkg/ +│ ├── logger/ # Structured logging +│ ├── metrics/ # Prometheus metrics +│ ├── response/ # HTTP response utilities +│ ├── tracing/ # OpenTelemetry +│ └── buildinfo/ # Build information +├── db/migrations/ # Database migrations +└── deployments/ # Docker, CI/CD ``` --- ## Core Principles -- **SOLID Design** - - SRP: Single Responsibility - One usecase per operation - - DIP: Dependency Inversion - Depend on ports, not implementations - - See [ARCHITECTURE.md](ARCHITECTURE.md#solid-principles) for detailed examples +### SOLID Design -- **Hexagonal Architecture** - - Core domain has no external dependencies - - Ports define boundaries, adapters implement them - - See [ARCHITECTURE.md](ARCHITECTURE.md#hexagonal-architecture) +- **SRP**: Single Responsibility - One usecase per operation +- **DIP**: Dependency Inversion - Depend on ports (interfaces), not implementations +- See [references/ARCHITECTURE.md](references/ARCHITECTURE.md) for detailed examples -- **12-Factor Compliance** - - Environment-based config, no hardcoded values - - Stateless processes, graceful shutdown - - See [12-FACTOR.md](12-FACTOR.md) +### Hexagonal Architecture -- **Error Handling** - - NEVER skip error checking - - Always handle or return errors - - See [CODING-STANDARDS.md](CODING-STANDARDS.md#critical-rules) +- Domain contains business logic, no external dependencies +- Ports define interfaces (contracts) +- Adapters implement ports and handle external concerns +- Services use ports, not adapters ---- +### 12-Factor Compliance + +All 12 factors implemented. See [references/12-FACTOR.md](references/12-FACTOR.md). -## Key Environment Variables +### Domain-Driven Design -| Variable | Description | Required | -|----------|-------------|----------| -| `APP_ENV` | Environment (`local`, `staging`, `production`) | Yes | -| `PERSISTENCE_DSN` | PostgreSQL connection string | Yes | -| `KEYCLOAK_JWKS_URL` | Keycloak JWKS endpoint | Yes | -| `KEYCLOAK_CLIENT_ID` | Keycloak client ID for role extraction | Yes | -| `OPENBAO_ADDR` | OpenBao server (non-local only) | Staging/Prod | -| `OPENBAO_TOKEN` | OpenBao token (non-local only) | Staging/Prod | +- Entities: User, with NIP as primary key +- Value Objects: NIP, Email with validation +- Domain Events: UserCreated, UserUpdated +- See [references/ARCHITECTURE.md](references/ARCHITECTURE.md) -See [CONFIGURATION.md](CONFIGURATION.md#environment-variables) for full list. +### Critical Rules + +- **NEVER skip error checking** - Always handle or return errors +- **NEVER use panic** - Return errors to parent +- See [references/CODING-STANDARDS.md](references/CODING-STANDARDS.md) --- ## Commands ```bash -# Server (default) -./bin/messaging-be # Run server on :8080 -./bin/messaging-be server # Explicit server -./bin/messaging-be server -port=8081 - -# Admin commands -./bin/messaging-be admin migrate up # Run migrations -./bin/messaging-be admin migrate down 1 # Rollback (disabled in prod) -./bin/messaging-be admin user list # List users -./bin/messaging-be admin user deactivate # Deactivate user -./bin/messaging-be admin cache clear # Clear cache -./bin/messaging-be admin health # Health check - -# Other -./bin/messaging-be version # Show version +# Server (daemon mode - default) +./messaging-be +./messaging-be server + +# Migrations +./messaging-be migrate # Default: up +./messaging-be migrate up +./messaging-be migrate down 1 # Disabled in production + +# Version +./messaging-be version ``` -See [DEPLOYMENT.md](DEPLOYMENT.md#operations) for all commands. +### Docker Operations ---- +```bash +# Run container +docker run -p 8080:8080 messaging-be -## Key Dependencies +# Run migrations in container +docker exec ./messaging-be migrate +``` -| Package | Purpose | -|---------|---------| -| gin-gonic/gin | HTTP framework | -| jackc/pgx/v5 | PostgreSQL driver | -| golang-migrate/migrate | Database migrations | -| MicahParks/keyfunc/v3 | JWT validation | -| hashicorp/vault/v2 | OpenBao secrets | -| urfave/cli/v2 | Admin CLI | -| prometheus/client_golang | Metrics | -| go.opentelemetry.io/otel | Distributed tracing | +--- + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `APP_ENV` | Environment (local/staging/production) | `local` | +| `APP_MODE` | Gin mode (debug/release) | `release` | +| `APP_PORT` | HTTP server port | `8080` | +| `APP_ALLOWED_ORIGINS` | CORS origins | `*` | +| `PERSISTENCE_DSN` | PostgreSQL connection string | Required | +| `CACHE_DSN` | Cache DB connection string | Required | +| `KEYCLOAK_JWKS_URL` | Keycloak JWKS endpoint | Required | +| `KEYCLOAK_CLIENT_ID` | Keycloak client ID | Required | +| `LOG_LEVEL` | Logging level | `info` | +| `LOG_FORMAT` | Log format (json/text) | `json` | +| `OPENBAO_ADDR` | OpenBao server (non-local) | - | +| `OPENBAO_TOKEN` | OpenBao token (non-local) | - | +| `OPENBAO_SECRET_PATH` | Secret path (non-local) | - | + +### Configuration Strategy + +| Environment | Config Source | +|-------------|--------------| +| `local` | `.env` file | +| `staging` | OpenBao v2 KV | +| `production` | OpenBao v2 KV | + +See [references/CONFIGURATION.md](references/CONFIGURATION.md) for OpenBao setup. --- -## Health Endpoint +## Authentication -`GET /healthz` returns 200 if healthy, 503 if degraded: +Keycloak JWT authentication via keyfunc/v3 (Resource Server pattern). -```json -{ - "status": "ok", - "version": "1.0.0", - "commit": "abc123", - "checks": { - "database": true, - "cache": true - } -} -``` +### JWT Claims Mapping ---- +| JWT Claim | Field | +|-----------|-------| +| `nip` | Primary key | +| `name` | Full name | +| `email` | Email | +| `resource_access.exodus.roles` | Authorization roles | -## Authentication +### Protected Routes -Backend acts as OAuth2 Resource Server: -- Validates Bearer tokens from UI -- No client secret needed (uses JWKS) -- User auto-created/updated on login -- Roles extracted from `resource_access.{client_id}` +```go +// Use auth middleware +r.Use(middlewares.AuthMiddleware(authSvc)) -See [AUTHENTICATION.md](AUTHENTICATION.md) for details. +// Require specific role +admin.Use(middlewares.RequireRole(authSvc, "team-trade.admin")) +``` ---- +See [references/AUTHENTICATION.md](references/AUTHENTICATION.md). -## Build & Run +--- -```bash -# Build -make build +## Database -# Development -make run +PostgreSQL with pgx connection pooling and unlogged tables for caching. -# Docker -docker build -t messaging-be . -docker run -p 8080:8080 --env-file .env messaging-be +### Schema -# Docker Compose (just app, devs provide their own postgres/keycloak) -docker-compose up -d +```sql +CREATE TABLE users ( + nip VARCHAR(20) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255), + is_active BOOLEAN DEFAULT true, + last_login_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); ``` -See [DEPLOYMENT.md](DEPLOYMENT.md) for CI/CD and operations. +See [references/DATABASE.md](references/DATABASE.md) for full schema and migrations. + +--- + +## Next Steps + +1. [references/ARCHITECTURE.md](references/ARCHITECTURE.md) - Understand the architecture +2. [references/CONFIGURATION.md](references/CONFIGURATION.md) - Set up OpenBao for production +3. [references/DEPLOYMENT.md](references/DEPLOYMENT.md) - Deploy with Docker/CI-CD --- backend-dev/references/12-FACTOR.md --- @@ -1,36 +1,18 @@ -# 12-Factor Compliance - -- [Table of Contents](#table-of-contents) -- [Overview](#overview) -- [Factor I: Codebase](#factor-i-codebase) -- [Factor II: Dependencies](#factor-ii-dependencies) -- [Factor III: Config](#factor-iii-config) -- [Factor IV: Backing Services](#factor-iv-backing-services) -- [Factor V: Build, Release, Run](#factor-v-build-release-run) -- [Factor VI: Processes](#factor-vi-processes) -- [Factor VII: Port Binding](#factor-vii-port-binding) -- [Factor VIII: Concurrency](#factor-viii-concurrency) -- [Factor IX: Disposability](#factor-ix-disposability) -- [Factor X: Dev/Prod Parity](#factor-x-devprod-parity) -- [Factor XI: Logs](#factor-xi-logs) -- [Factor XII: Admin Processes](#factor-xii-admin-processes) - ---- - -## Table of Contents - -1. [Codebase](#factor-i-codebase) - One codebase per app -2. [Dependencies](#factor-ii-dependencies) - Explicit declaration -3. [Config](#factor-iii-config) - Environment-based configuration -4. [Backing Services](#factor-iv-backing-services) - Treat as attached resources -5. [Build, Release, Run](#factor-v-build-release-run) - Strict separation -6. [Processes](#factor-vi-processes) - Stateless and share-nothing -7. [Port Binding](#factor-vii-port-binding) - Self-contained service -8. [Concurrency](#factor-viii-concurrency) - Scale via process model -9. [Disposability](#factor-ix-disposability) - Fast startup, graceful shutdown -10. [Dev/Prod Parity](#factor-x-devprod-parity) - Keep environments similar -11. [Logs](#factor-xi-logs) - Treat logs as event streams -12. [Admin Processes](#factor-xii-admin-processes) - One-off admin tasks +# Table of Contents + +1. [Overview](#overview) +2. [Factor 1: Codebase](#factor-1-codebase) +3. [Factor 2: Dependencies](#factor-2-dependencies) +4. [Factor 3: Config](#factor-3-config) +5. [Factor 4: Backing Services](#factor-4-backing-services) +6. [Factor 5: Build, Release, Run](#factor-5-build-release-run) +7. [Factor 6: Processes](#factor-6-processes) +8. [Factor 7: Port Binding](#factor-7-port-binding) +9. [Factor 8: Concurrency](#factor-8-concurrency) +10. [Factor 9: Disposability](#factor-9-disposability) +11. [Factor 10: Dev/Prod Parity](#factor-10-devprod-parity) +12. [Factor 11: Logs](#factor-11-logs) +13. [Factor 12: Admin Processes](#factor-12-admin-processes) --- @@ -40,125 +22,111 @@ This template follows [12-Factor App](https://12factor.net/) methodology for pro --- -## Factor I: Codebase +## Factor 1: Codebase One codebase tracked in version control, multiple deploys. -**Practices:** - Git repository with main branch - Environment-specific configs via environment variables - Same codebase deploys to local, staging, production -**Git Workflow:** ```bash git clone https://gitlab.example.com/backend.git cd backend -git checkout -b feature/new-feature -# ... development ... -git commit -m "feat: add new feature" -git push origin feature/new-feature -# Create merge request to main +git checkout main ``` --- -## Factor II: Dependencies +## Factor 2: Dependencies Explicitly declare and isolate dependencies. -**go.mod:** ```go +// go.mod require ( github.com/gin-gonic/gin v1.12.0 github.com/jackc/pgx/v5 v5.5.0 - github.com/golang-migrate/migrate/v4 v4.19.0 + github.com/MicahParks/keyfunc/v3 v3.0.0 ) ``` -**Vendor directory (optional isolation):** +Use `go mod vendor` for full isolation: + ```bash go mod vendor ``` -**Key Principle:** Never rely on system-wide packages. +Never rely on system-wide packages. --- -## Factor III: Config +## Factor 3: Config Store config in environment variables. **Configuration Loading:** + ```go -func Load(ctx context.Context) (*Config, error) { - env := os.Getenv("APP_ENV") - if env == "" { - env = "local" - } +import "github.com/golobby/env/v2" - switch env { - case "local": +func Load() (*Config, error) { + env := os.Getenv("APP_ENV") + if env == "local" { dotenv.Load() - return loadFromEnv() - default: - return loadFromOpenBao(ctx) } + var cfg Config + if err := env.Parse(&cfg); err != nil { + return nil, err + } + return &cfg, nil } ``` -**Environment Variables:** -```env -APP_ENV=production -PERSISTENCE_DSN=postgres://user:pass@host:5432/dbname -KEYCLOAK_JWKS_URL=https://auth.example.com/realms/production/certs -``` +**Local:** Use `.env` file via golobby/dotenv +**Staging/Prod:** Use OpenBao v2 KV secrets -See [CONFIGURATION.md](CONFIGURATION.md) for OpenBao integration. +See [CONFIGURATION](CONFIGURATION.md) for details. --- -## Factor IV: Backing Services +## Factor 4: Backing Services -Treat backing services as attached resources. +Treat backing services as attached resources via config. -**Connection via config:** ```go -dsn := cfg.Persistence.DSN // PostgreSQL -jwksURL := cfg.Keycloak.JWKSURL // Keycloak +// Connection strings via config, not hardcoded +dsn := cfg.Persistence.DSN // PostgreSQL +jwksURL := cfg.Keycloak.JWKSURL // Keycloak tempoEndpoint := cfg.Tempo.Endpoint // OpenTelemetry ``` **Health checks:** + ```go r.GET("/healthz", func(c *gin.Context) { checks := map[string]bool{ - "database": pingDB(persistencePool), - "cache": pingCache(cachePool), + "database": pingDB(), + "cache": pingCache(), } - - allHealthy := true - for _, ok := range checks { + for name, ok := range checks { if !ok { - allHealthy = false - break + c.JSON(503, gin.H{"status": "unhealthy", "checks": checks}) + return } } - - if allHealthy { - c.JSON(200, gin.H{"status": "ok"}) - } else { - c.JSON(503, gin.H{"status": "degraded", "checks": checks}) - } + c.JSON(200, gin.H{"status": "ok"}) }) ``` --- -## Factor V: Build, Release, Run +## Factor 5: Build, Release, Run Strict separation of build, release, and run stages. **GitLab CI Pipeline:** + ```yaml stages: - build @@ -173,8 +141,6 @@ build: test: stage: test - services: - - postgres:15 script: - go test -race -coverprofile=coverage.out ./... coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/' @@ -193,65 +159,47 @@ deploy: - kubectl set image deployment/server server=$IMAGE_NAME:$CI_COMMIT_SHA environment: name: production - rules: - - main - when: manual ``` +See [DEPLOYMENT](DEPLOYMENT.md) for CI/CD details. + --- -## Factor VI: Processes +## Factor 6: Processes Stateless processes with no shared state. -**Share-nothing architecture:** ```go -// Good: User sessions in PostgreSQL, not memory -// Cache in PostgreSQL unlogged tables, not process memory +// Share-nothing architecture +// User sessions stored in PostgreSQL, not memory // File uploads to object storage, not local disk - -type userService struct { - userRepo outbound.UserRepository // PostgreSQL - cache outbound.CacheRepository // Cache layer -} +// Cache in PostgreSQL unlogged tables, not process memory ``` -**Bad:** Storing user data in global variables. +All persistent data stored in backing services (PostgreSQL). --- -## Factor VII: Port Binding +## Factor 7: Port Binding Self-contained HTTP service. ```go -// cmd/server/run.go -func Run(args []string) { - cfg, err := config.Load(context.Background()) - if err != nil { - fmt.Fprintf(os.Stderr, "Config error: %v\n", err) - os.Exit(1) - } - - port := cfg.App.Port - if !strings.HasPrefix(port, ":") { - port = ":" + port - } - - log.Printf("Starting server on %s", port) - if err := http.ListenAndServe(port, r.Handler()); err != nil { - log.Fatal(err) - } +port := cfg.App.Port +if !strings.HasPrefix(port, ":") { + port = ":" + port } +log.Fatal(http.ListenAndServe(port, r.Handler())) ``` +Export HTTP via port binding. No reverse proxy required, but compatible. + --- -## Factor VIII: Concurrency +## Factor 8: Concurrency Scale via process model. -**Worker pool pattern:** ```go type WorkerPool struct { workers int @@ -269,27 +217,16 @@ func (wp *WorkerPool) Start(ctx context.Context) { func (wp *WorkerPool) Submit(job Job) { wp.jobs <- job } - -func (wp *WorkerPool) worker(ctx context.Context, id int) { - defer wp.wg.Done() - for { - select { - case <-ctx.Done(): - return - case job := <-wp.jobs: - job.Execute() - } - } -} ``` +Scale horizontally by running multiple instances. + --- -## Factor IX: Disposability +## Factor 9: Disposability -Fast startup, graceful shutdown. +Fast startup and graceful shutdown. -**Graceful shutdown:** ```go func gracefulShutdown(sig os.Signal, server *http.Server) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -301,95 +238,73 @@ func gracefulShutdown(sig os.Signal, server *http.Server) { } } -// Signal handling -sig := make(chan os.Signal, 1) -signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) -go func() { - <-sig - gracefulShutdown(<-sig, server) -}() +// SIGTERM handling +signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) +<-sigChan +gracefulShutdown(<-sigChan, server) ``` +**Startup:** Connect to DB, load config, initialize services +**Shutdown:** Close connections, finish in-flight requests, release resources + --- -## Factor X: Dev/Prod Parity +## Factor 10: Dev/Prod Parity Use Docker Compose for local development. -**docker-compose.yml:** ```yaml version: '3.8' - services: app: build: . - env_file: .env.local + env_file: .env + depends_on: + - postgres ports: - "8080:8080" - environment: - PERSISTENCE_DSN: ${PERSISTENCE_DSN} - KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL} -# Dev provides their own PostgreSQL and Keycloak -# Use same PostgreSQL version locally as in production + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: messaging + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: ``` -**Key Principle:** Same PostgreSQL version in all environments. +Same PostgreSQL version in all environments. --- -## Factor XI: Logs +## Factor 11: Logs Structured logging to stdout only. ```go -// Use slog - Go 1.21+ standard library -log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) +import "log/slog" -log.Info("server started", - "port", 8080, - "env", "production", - "version", buildinfo.Version, -) +log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) +log.Info("server started", "port", 8080, "env", "production") ``` -**Key Principles:** -- Log to stdout/stderr only -- No log files in container -- Aggregate via external services (Loki, CloudWatch) +No log files in container. Aggregate via external services (Loki, CloudWatch). --- -## Factor XII: Admin Processes +## Factor 12: Admin Processes One-off admin tasks as CLI commands. -See [DEPLOYMENT.md](DEPLOYMENT.md#admin-cli) for detailed admin commands. - -**Commands:** ```bash -./messaging-be admin migrate up # Run migrations -./messaging-be admin migrate down 1 # Rollback -./messaging-be admin user list # List users -./messaging-be admin cache clear # Clear cache -./messaging-be admin health # Health check +./messaging-be migrate up +./messaging-be migrate down 1 +./messaging-be user list +./messaging-be health ``` ---- - -## Summary Table - -| Factor | Practice | -|--------|----------| -| I | Git workflow, one repo per app | -| II | go.mod with explicit versions | -| III | Environment variables, no hardcoded config | -| IV | PostgreSQL, Keycloak as resources | -| V | CI/CD pipeline with stages | -| VI | Stateless, share-nothing | -| VII | Self-contained HTTP service | -| VIII | Worker pools for concurrency | -| IX | Graceful shutdown, crash-only | -| X | Docker Compose for dev parity | -| XI | Structured logs to stdout | -| XII | CLI admin commands | +See [DEPLOYMENT](DEPLOYMENT.md) for admin CLI details. --- backend-dev/references/ARCHITECTURE.md --- @@ -1,75 +1,35 @@ -# Architecture - -- [Table of Contents](#table-of-contents) -- [Hexagonal Architecture](#hexagonal-architecture) -- [SOLID Principles](#solid-principles) -- [Domain Layer](#domain-layer) -- [Ports](#ports) -- [Services/Usecases](#servicesusecases) -- [Adapters](#adapters) -- [Provider Pattern](#provider-pattern) -- [Directory Structure](#directory-structure) +# Table of Contents ---- - -## Table of Contents - -1. [Hexagonal Architecture](#hexagonal-architecture) +1. [Overview](#overview) 2. [SOLID Principles](#solid-principles) -3. [Domain Layer](#domain-layer) -4. [Ports](#ports) -5. [Services/Usecases](#servicesusecases) -6. [Adapters](#adapters) -7. [Provider Pattern](#provider-pattern) -8. [Directory Structure](#directory-structure) +3. [Hexagonal Architecture](#hexagonal-architecture) +4. [Domain Layer](#domain-layer) +5. [Ports](#ports) +6. [Services](#services) +7. [Adapters](#adapters) +8. [Provider Pattern](#provider-pattern) +9. [Directory Structure](#directory-structure) --- -## Hexagonal Architecture +## Overview -``` - ┌─────────────────────────────────────┐ - │ Adapters │ - │ ┌─────────────┐ ┌──────────────┐ │ - │ │ Inbound │ │ Outbound │ │ - │ │ (Driving) │ │ (Driven) │ │ - │ │ │ │ │ │ - │ │ HTTP/REST │ │ PostgreSQL │ │ - │ │ gRPC │ │ Cache │ │ - │ │ CLI │ │ Keycloak │ │ - │ └──────┬──────┘ └──────┬───────┘ │ - └─────────┼─────────────────┼──────────┘ - │ │ - ▼ ▼ - ┌─────────────────────────────────────┐ - │ Ports │ - │ ┌─────────────┐ ┌──────────────┐ │ - │ │ Inbound │ │ Outbound │ │ - │ │ (Usecase) │ │ (Repository) │ │ - │ └─────────────┘ └──────────────┘ │ - └─────────────────┬───────────────────┘ - │ - ▼ - ┌─────────────────────────────────────┐ - │ Core/Domain │ - │ │ - │ ┌───────────┐ ┌───────────────┐ │ - │ │ Entity │ │ Value Object │ │ - │ └───────────┘ └───────────────┘ │ - │ │ - │ ┌─────────────────────────────────┐ │ - │ │ Business Logic │ │ - │ │ (Usecase Implementation) │ │ - │ └─────────────────────────────────┘ │ - └─────────────────────────────────────┘ -``` +Production-ready Go backend template using hexagonal (ports & adapters) architecture with DDD principles. + +### Tech Stack + +- **Gin** - HTTP web framework +- **PostgreSQL/pgx** - Database with connection pooling +- **OpenTelemetry** - Distributed tracing +- **Prometheus** - Metrics +- **Keycloak** - JWT authentication via keyfunc/v3 -### Key Principles +### When to Use -- **Independence**: Core domain has no external dependencies -- **Ports**: Interfaces define boundaries -- **Adapters**: Implementations outside the core -- **Testability**: Core can be tested without external dependencies +- Building REST API services in Go requiring production readiness +- Projects requiring clean separation of concerns +- Applications needing observability (logging, metrics, tracing) +- Services with PostgreSQL caching layer for high performance --- @@ -84,6 +44,7 @@ Each component has one reason to change. type UserService interface { GetByNIP(ctx context.Context, nip string) (*User, error) Create(ctx context.Context, user *User) error + List(ctx context.Context) ([]User, error) } ``` @@ -104,7 +65,7 @@ High-level modules depend on abstractions, not concrete implementations. **Good:** ```go type userService struct { - userRepo outbound.UserRepository + userRepo outbound.UserRepository // Interface, not concrete } func NewUserService(repo outbound.UserRepository) UserService { @@ -115,32 +76,61 @@ func NewUserService(repo outbound.UserRepository) UserService { **Bad:** ```go type userService struct { - db *sql.DB + db *sql.DB // Concrete PostgreSQL driver! } func NewUserService() *userService { return &userService{ - db: connectPostgres(), + db: connectPostgres(), // Hard-coded dependency } } ``` -**Test vs Production (LSP in action):** +**Test vs Production (LSP compliance):** ```go -mockRepo := &mockUserRepository{} -pgRepo := persistence.NewUserRepository(pool) +mockRepo := &mockUserRepository{} // Test +pgRepo := persistence.NewUserRepository(pool) // Production svc := usecase.NewUserService(mockRepo) // Works -svc := usecase.NewUserService(pgRepo) // Works - both satisfy interface +svc := usecase.NewUserService(pgRepo) // Works ``` --- +## Hexagonal Architecture + +``` + ┌─────────────────────────────────┐ + │ Application │ + │ │ + ┌─────────┐ │ ┌─────────────────────────┐ │ + │ Input │────────►│ Domain (Core) │◄──────┐ + │(REST/gRPC)│ │ │ Entities, Services │ │ + └─────────┘ │ │ Business Rules │ │ + │ └─────────────────────────┘ │ ┌─────────┐ + │ │ │◄──│ Output │ + │ ▼ │ │(DB/Cache│ + │ ┌─────────────────────────┐ │ │/Ext API)│ + │ │ Ports │ │ └─────────┘ + │ │ (Interfaces/Contracts) │ │ + │ └─────────────────────────┘ │ + └─────────────────────────────────┘ +``` + +**Key Principles:** + +1. Domain contains business logic, no external dependencies +2. Ports define interfaces (contracts) +3. Adapters implement ports and handle external concerns +4. Services use ports, not adapters + +--- + ## Domain Layer ### Entities -Pure business entities with no external dependencies. +Pure business entities with no external dependencies: ```go package domain @@ -155,13 +145,16 @@ type User struct { LastLoginAt time.Time `db:"last_login_at" json:"last_login_at"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Roles []string `db:"-" json:"roles,omitempty"` } ``` ### Value Objects ```go -package valueobject +package domain + +import "errors" type NIP string @@ -174,18 +167,6 @@ func (n NIP) Validate() error { } return nil } - -type Email string - -func (e Email) Validate() error { - if len(e) == 0 { - return nil - } - if !strings.Contains(string(e), "@") { - return errors.New("invalid email format") - } - return nil -} ``` ### Domain Events @@ -198,35 +179,27 @@ type EventType string const ( UserCreated EventType = "user.created" UserUpdated EventType = "user.updated" - UserDeactivated EventType = "user.deactivated" + UserDeactivated EventType = "user.deactivated" ) -type DomainEvent interface { - EventType() EventType - Timestamp() time.Time -} - type UserCreatedEvent struct { NIP string Name string Email string - timestamp time.Time -} - -func (e *UserCreatedEvent) EventType() EventType { - return UserCreated + Timestamp time.Time } -func (e *UserCreatedEvent) Timestamp() time.Time { - return e.timestamp -} +func (e *UserCreatedEvent) EventType() EventType { return UserCreated } +func (e *UserCreatedEvent) Timestamp() time.Time { return e.Timestamp } ``` --- ## Ports -### Inbound Ports (Usecase Interfaces) +### Inbound Ports (Service Interfaces) + +Define contracts for use cases: ```go package inbound @@ -241,20 +214,18 @@ type UserService interface { Create(ctx context.Context, user *domain.User) error List(ctx context.Context) ([]domain.User, error) } - -type AuthService interface { - Authenticate(ctx context.Context, token string) (*domain.User, error) - HasRole(user *domain.User, role string) bool -} ``` ### Outbound Ports (Repository Interfaces) +Define contracts for external dependencies: + ```go package outbound import ( "context" + "time" "myapp/internal/core/domain" ) @@ -270,19 +241,14 @@ type CacheRepository interface { Set(ctx context.Context, key string, value []byte, ttl time.Duration) error Delete(ctx context.Context, key string) error Exists(ctx context.Context, key string) (bool, error) - Clear(ctx context.Context) error -} - -type EventPublisher interface { - Publish(ctx context.Context, event event.DomainEvent) error } ``` --- -## Services/Usecases +## Services -Implement inbound ports, depend on outbound ports. +Implement inbound ports, depend on outbound ports: ```go package usecase @@ -304,28 +270,29 @@ import ( var tracer = otel.Tracer("usecase") type userService struct { - userRepo outbound.UserRepository - cache outbound.CacheRepository + userRepo outbound.UserRepository + cacheRepo outbound.CacheRepository } func NewUserService( userRepo outbound.UserRepository, - cache outbound.CacheRepository, + cacheRepo outbound.CacheRepository, ) inbound.UserService { return &userService{ - userRepo: userRepo, - cache: cache, + userRepo: userRepo, + cacheRepo: cacheRepo, } } func (s *userService) GetByNIP(ctx context.Context, nip string) (*domain.User, error) { ctx, span := tracer.Start(ctx, "UserService.GetByNIP", - attribute.String("user.nip", nip), + trace.WithAttributes(attribute.String("user.nip", nip)), ) defer span.End() + // Cache-aside pattern cacheKey := "user:" + nip - cached, err := s.cache.Get(ctx, cacheKey) + cached, err := s.cacheRepo.Get(ctx, cacheKey) if err == nil && cached != nil { span.AddEvent("cache_hit") var user domain.User @@ -341,10 +308,13 @@ func (s *userService) GetByNIP(ctx context.Context, nip string) (*domain.User, e return nil, errors.Wrap(err, "failed to get user") } - if user != nil { - if data, err := json.Marshal(user); err == nil { - _ = s.cache.Set(ctx, cacheKey, data, 5*time.Minute) - } + if user == nil { + return nil, nil + } + + // Cache result + if data, err := json.Marshal(user); err == nil { + _ = s.cacheRepo.Set(ctx, cacheKey, data, 5*time.Minute) } return user, nil @@ -355,7 +325,7 @@ func (s *userService) GetByNIP(ctx context.Context, nip string) (*domain.User, e ## Adapters -### Inbound Adapter (HTTP Handler) +### Inbound (Driving) Adapters ```go package v1 @@ -367,7 +337,6 @@ import ( "myapp/pkg/response" "github.com/gin-gonic/gin" - "github.com/pkg/errors" ) type UserHandler struct { @@ -383,7 +352,6 @@ func (h *UserHandler) GetByNIP(c *gin.Context) { defer resp.Render(c) nip := c.Param("nip") - user, err := h.svc.GetByNIP(c.Request.Context(), nip) if err != nil { resp.SetError(err, http.StatusInternalServerError) @@ -403,26 +371,23 @@ func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) { { users.GET("/:nip", h.GetByNIP) users.POST("/", h.Create) - users.GET("/", h.List) } } ``` -### Outbound Adapter (PostgreSQL Repository) +### Outbound (Driven) Adapters ```go package persistence import ( "context" - "encoding/json" "myapp/internal/core/domain" "myapp/internal/core/port/outbound" "github.com/jackc/pgx/v5" "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" ) type userRepository struct { @@ -434,9 +399,6 @@ func NewUserRepository(pool *Pool) outbound.UserRepository { } func (r *userRepository) GetByNIP(ctx context.Context, nip string) (*domain.User, error) { - ctx, span := tracer.Start(ctx, "UserRepository.GetByNIP") - defer span.End() - var user domain.User err := r.pool.QueryRow(ctx, `SELECT nip, name, email, is_active, last_login_at, created_at, updated_at @@ -446,43 +408,21 @@ func (r *userRepository) GetByNIP(ctx context.Context, nip string) (*domain.User &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt) if err == pgx.ErrNoRows { - span.AddEvent("user_not_found") return nil, nil } if err != nil { - span.RecordError(err) return nil, errors.Wrap(err, "failed to get user") } - span.SetAttributes(attribute.String("user.nip", user.NIP)) return &user, nil } - -func (r *userRepository) Create(ctx context.Context, user *domain.User) error { - ctx, span := tracer.Start(ctx, "UserRepository.Create") - defer span.End() - - err := r.pool.QueryRow(ctx, - `INSERT INTO users (nip, name, email, is_active, last_login_at) - VALUES ($1, $2, $3, $4, $5) - RETURNING created_at, updated_at`, - user.NIP, user.Name, user.Email, user.IsActive, user.LastLoginAt, - ).Scan(&user.CreatedAt, &user.UpdatedAt) - - if err != nil { - span.RecordError(err) - return errors.Wrap(err, "failed to create user") - } - - return nil -} ``` --- ## Provider Pattern -Dependency wiring via explicit Provider struct. +Explicit dependency wiring via Provider struct: ```go package provider @@ -497,20 +437,23 @@ import ( "myapp/internal/adapter/outbound/persistence" "myapp/internal/core/port/inbound" "myapp/internal/core/usecase" + "myapp/pkg/logger" + "myapp/pkg/metrics" + "myapp/pkg/tracing" ) type Provider struct { - Config *config.Config - Logger *logger.Logger - Metrics *metrics.Prometheus - Tracer *tracing.TracerProvider + Config *config.Config + Logger *logger.Logger + Metrics *metrics.Prometheus + Tracer *tracing.TracerProvider PersistencePool *persistence.Pool - CachePool *persistence.Pool - UserRepo outbound.UserRepository - CacheRepo outbound.CacheRepository + CachePool *persistence.Pool Keycloak *keycloak.Provider - AuthSvc inbound.AuthService - UserSvc inbound.UserService + UserRepo outbound.UserRepository + CacheRepo outbound.CacheRepository + AuthSvc inbound.AuthService + UserSvc inbound.UserService } func NewProvider(ctx context.Context, cfg *config.Config) (*Provider, error) { @@ -537,13 +480,14 @@ func NewProvider(ctx context.Context, cfg *config.Config) (*Provider, error) { p.UserRepo = persistence.NewUserRepository(p.PersistencePool) p.CacheRepo = cache.NewPostgresCacheRepository(p.CachePool) + p.AuthSvc = usecase.NewAuthService(p.Keycloak, p.UserRepo) p.UserSvc = usecase.NewUserService(p.UserRepo, p.CacheRepo) return p, nil } -func (p *Provider) Close(ctx context.Context) { +func (p *Provider) Close(ctx context.Context) error { if p.PersistencePool != nil { p.PersistencePool.Close() } @@ -553,6 +497,7 @@ func (p *Provider) Close(ctx context.Context) { if p.Tracer != nil { p.Tracer.Shutdown(ctx) } + return nil } ``` @@ -563,39 +508,33 @@ func (p *Provider) Close(ctx context.Context) { ``` . ├── cmd/ -│ ├── server/ +│ ├── server/ # HTTP server entry point +│ │ └── run.go +│ ├── migrate/ # Migration commands +│ │ └── run.go +│ ├── seed/ # Seeding commands │ │ └── run.go -│ └── admin/ +│ └── admin/ # Admin CLI commands │ └── run.go ├── config/ -│ ├── config.go -│ ├── model.go -│ └── loader.go +│ ├── config.go # Config loading +│ ├── model.go # Config structs +│ └── loader.go # OpenBao/env loader ├── internal/ │ ├── core/ -│ │ ├── domain/ -│ │ │ ├── user.go -│ │ │ ├── event/ -│ │ │ └── valueobject/ -│ │ ├── port/ -│ │ │ ├── inbound/ -│ │ │ └── outbound/ -│ │ └── usecase/ +│ │ ├── domain/ # Domain entities, events +│ │ ├── port/ # Inbound/outbound interfaces +│ │ └── usecase/ # Business logic │ ├── adapter/ -│ │ ├── inbound/ -│ │ │ └── rest/v1/ -│ │ └── outbound/ -│ │ ├── persistence/ -│ │ ├── cache/ -│ │ └── keycloak/ -│ └── provider/ -├── middlewares/ +│ │ ├── inbound/ # HTTP handlers +│ │ └── outbound/ # DB, cache, keycloak adapters +│ └── provider/ # Dependency provider +├── middlewares/ # HTTP middleware ├── pkg/ -│ ├── logger/ -│ ├── metrics/ -│ ├── response/ -│ ├── tracing/ -│ └── buildinfo/ -├── db/migrations/ -└── deployments/ +│ ├── logger/ # Structured logging +│ ├── metrics/ # Prometheus metrics +│ ├── response/ # HTTP response utilities +│ └── tracing/ # OpenTelemetry tracing +├── db/migrations/ # Database migrations +└── deployments/ # Docker, CI/CD configs ``` --- backend-dev/references/AUTHENTICATION.md --- @@ -1,37 +1,19 @@ -# Authentication - -- [Table of Contents](#table-of-contents) -- [Overview](#overview) -- [Architecture](#architecture) -- [Keycloak Integration](#keycloak-integration) -- [JWT Claims](#jwt-claims) -- [Token Validation](#token-validation) -- [Auth Middleware](#auth-middleware) -- [Role-Based Authorization](#role-based-authorization) - ---- - -## Table of Contents +# Table of Contents 1. [Overview](#overview) 2. [Architecture](#architecture) -3. [Keycloak Integration](#keycloak-integration) -4. [JWT Claims](#jwt-claims) -5. [Token Validation](#token-validation) -6. [Auth Middleware](#auth-middleware) +3. [JWT Claims Mapping](#jwt-claims-mapping) +4. [Keycloak Provider](#keycloak-provider) +5. [Auth Middleware](#auth-middleware) +6. [Auth Service](#auth-service) 7. [Role-Based Authorization](#role-based-authorization) +8. [User Auto-Creation](#user-auto-creation) --- ## Overview -The backend acts as an **OAuth2 Resource Server** - it validates Bearer tokens from the UI/browser without being an OIDC client. Token validation is done locally using JWKS fetched from Keycloak. - -**Key Features:** -- No client secret needed -- Local JWT validation via JWKS -- Automatic user creation/update on login -- Roles extracted from `resource_access` +Backend acts as **OAuth2 Resource Server** - validates Bearer tokens from UI/browser without needing to be an OIDC client. Token validation is done locally using JWKS fetched from Keycloak. --- @@ -50,18 +32,49 @@ The backend acts as an **OAuth2 Resource Server** - it validates Bearer tokens f └──────────────┘ ``` +**Key Design:** +- No client secret needed (Resource Server pattern) +- Local JWT validation via JWKS (no Keycloak call per request) +- User auto-created/updated on each authentication +- Roles from `resource_access.{client_id}.roles` + --- -## Keycloak Integration +## JWT Claims Mapping -### Dependencies +Based on your Keycloak setup: -```go -github.com/MicahParks/keyfunc/v3 v3.0.0 -github.com/golang-jwt/jwt/v5 v5.2.0 +| JWT Claim | Field | Description | +|-----------|-------|-------------| +| `nip` | `NIP` | Primary key (e.g., "p021050") | +| `name` | `Name` | Full name | +| `email` | `Email` | Email address | +| `resource_access.exodus.roles` | `Roles` | Authorization roles | + +**Example JWT Payload:** + +```json +{ + "sub": "4a098117-e595-411b-b8fc-4328fc14b0ba", + "nip": "p021050", + "name": "DR. CITRA ANGGREINI SEMBIRING", + "email": "ctasbr@gmail.com", + "resource_access": { + "exodus": { + "roles": ["exodus.user", "exodus.business-support"] + }, + "exodus-admin": { + "roles": ["team-trade.admin"] + } + } +} ``` -### Keycloak Provider +--- + +## Keycloak Provider + +Uses `keyfunc/v3` for JWKS fetching and JWT validation. ```go package keycloak @@ -135,45 +148,85 @@ func (p *Provider) ExtractRoles(claims *TokenClaims) []string { --- -## JWT Claims +## Auth Middleware -### Expected JWT Structure +```go +package middlewares -```json -{ - "exp": 1776127146, - "iat": 1775867946, - "iss": "https://auth.pharos.id/realms/production", - "sub": "4a098117-e595-411b-b8fc-4328fc14b0ba", - "nip": "p021050", - "name": "DR. CITRA ANGGREINI SEMBIRING", - "email": "ctasbr@gmail.com", - "resource_access": { - "exodus": { - "roles": [ - "exodus.business-support", - "exodus.default", - "exodus.user" - ] +import ( + "net/http" + "strings" + + "myapp/internal/core/domain" + "myapp/internal/core/port/inbound" + + "github.com/gin-gonic/gin" +) + +const UserContextKey = "authenticated_user" + +func AuthMiddleware(authSvc inbound.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, + gin.H{"error": "missing authorization header"}) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + c.AbortWithStatusJSON(http.StatusUnauthorized, + gin.H{"error": "invalid authorization format"}) + return + } + + user, err := authSvc.Authenticate(c.Request.Context(), parts[1]) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, + gin.H{"error": err.Error()}) + return + } + + c.Set(UserContextKey, user) + c.Next() } - } } -``` -### Claims Mapping +func RequireRole(authSvc inbound.AuthService, roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + user := GetUserFromContext(c) + if user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, + gin.H{"error": "unauthenticated"}) + return + } -| JWT Claim | Field | Description | -|-----------|-------|-------------| -| `nip` | `NIP` | Primary key (e.g., "p021050") | -| `name` | `Name` | Full name | -| `email` | `Email` | Email address | -| `resource_access.{client_id}.roles` | `Roles` | Authorization roles | + for _, role := range roles { + if authSvc.HasRole(user, role) { + c.Next() + return + } + } ---- + c.AbortWithStatusJSON(http.StatusForbidden, + gin.H{"error": "insufficient permissions"}) + } +} -## Token Validation +func GetUserFromContext(c *gin.Context) *domain.User { + if user, exists := c.Get(UserContextKey); exists { + if u, ok := user.(*domain.User); ok { + return u + } + } + return nil +} +``` + +--- -### Auth Service +## Auth Service ```go package usecase @@ -185,34 +238,25 @@ import ( "myapp/internal/core/domain" "myapp/internal/core/port/inbound" "myapp/internal/core/port/outbound" - "myapp/pkg/keycloak" "github.com/pkg/errors" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" ) -var tracer = otel.Tracer("usecase") - type authService struct { - keycloak *keycloak.Provider - userRepo outbound.UserRepository + keycloakProvider *keycloak.Provider + userRepo outbound.UserRepository } -func NewAuthService(keycloak *keycloak.Provider, userRepo outbound.UserRepository) inbound.AuthService { +func NewAuthService(keycloakProvider *keycloak.Provider, userRepo outbound.UserRepository) inbound.AuthService { return &authService{ - keycloak: keycloak, - userRepo: userRepo, + keycloakProvider: keycloakProvider, + userRepo: userRepo, } } func (s *authService) Authenticate(ctx context.Context, token string) (*domain.User, error) { - ctx, span := tracer.Start(ctx, "AuthService.Authenticate") - defer span.End() - - claims, err := s.keycloak.VerifyToken(token) + claims, err := s.keycloakProvider.VerifyToken(token) if err != nil { - span.RecordError(err) return nil, errors.Wrap(err, "token validation failed") } @@ -224,17 +268,15 @@ func (s *authService) Authenticate(ctx context.Context, token string) (*domain.U NIP: claims.NIP, Name: claims.Name, Email: claims.Email, - Roles: s.keycloak.ExtractRoles(claims), + Roles: s.keycloakProvider.ExtractRoles(claims), } if err := s.syncUser(ctx, user); err != nil { - span.RecordError(err) return nil, errors.Wrap(err, "failed to sync user") } existing, err := s.userRepo.GetByNIP(ctx, user.NIP) if err != nil { - span.RecordError(err) return nil, errors.Wrap(err, "failed to get user") } @@ -251,8 +293,6 @@ func (s *authService) Authenticate(ctx context.Context, token string) (*domain.U user.CreatedAt = existing.CreatedAt user.UpdatedAt = existing.UpdatedAt - span.SetAttributes(attribute.StringSlice("user.roles", user.Roles)) - return user, nil } @@ -288,131 +328,65 @@ func (s *authService) HasRole(user *domain.User, role string) bool { } return false } - -func (s *authService) HasAnyRole(user *domain.User, roles ...string) bool { - for _, role := range roles { - if s.HasRole(user, role) { - return true - } - } - return false -} ``` --- -## Auth Middleware - -```go -package middlewares - -import ( - "net/http" - "strings" - - "myapp/internal/core/domain" - "myapp/internal/core/port/inbound" - - "github.com/gin-gonic/gin" -) - -const UserContextKey = "authenticated_user" - -func AuthMiddleware(authSvc inbound.AuthService) gin.HandlerFunc { - return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, - gin.H{"error": "missing authorization header"}) - return - } - - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - c.AbortWithStatusJSON(http.StatusUnauthorized, - gin.H{"error": "invalid authorization format"}) - return - } - - user, err := authSvc.Authenticate(c.Request.Context(), parts[1]) - if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, - gin.H{"error": err.Error()}) - return - } +## Role-Based Authorization - c.Set(UserContextKey, user) - c.Next() - } -} +**Available roles** from `resource_access.{client_id}.roles`: -func RequireRole(authSvc inbound.AuthService, roles ...string) gin.HandlerFunc { - return func(c *gin.Context) { - user := GetUserFromContext(c) - if user == nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, - gin.H{"error": "unauthenticated"}) - return - } +| Client ID | Roles | +|-----------|-------| +| `exodus` | `exodus.user`, `exodus.business-support`, `exodus.default` | +| `exodus-admin` | `team-trade.admin` | +| `portal` | `portal.user` | - for _, role := range roles { - if authSvc.HasRole(user, role) { - c.Next() - return - } - } +**Usage:** - c.AbortWithStatusJSON(http.StatusForbidden, - gin.H{"error": "insufficient permissions"}) - } +```go +// Require specific role +admin := v1.Group("/admin") +admin.Use(middlewares.RequireRole(authSvc, "team-trade.admin")) +{ + admin.GET("/dashboard", adminHandler.GetDashboard) } -func GetUserFromContext(c *gin.Context) *domain.User { - if user, exists := c.Get(UserContextKey); exists { - if u, ok := user.(*domain.User); ok { - return u - } - } - return nil +// Require any of multiple roles +manager := v1.Group("/manager") +manager.Use(middlewares.RequireRole(authSvc, "project-manager", "owner")) +{ + manager.POST("/projects", projectHandler.Create) } ``` --- -## Role-Based Authorization - -### Usage Examples - -```go -// All authenticated users -v1 := r.Group("/v1") -v1.Use(middlewares.AuthMiddleware(authSvc)) +## User Auto-Creation -// Admin-only routes -admin := v1.Group("/admin") -admin.Use(middlewares.RequireRole(authSvc, "team-trade.admin")) +Users are automatically created/updated on successful authentication: -// Multiple allowed roles (any match grants access) -project := v1.Group("/projects") -project.Use(middlewares.RequireRole(authSvc, "project-manager", "owner")) +1. JWT verified via Keycloak JWKS +2. Claims extracted (nip, name, email, roles) +3. User synced to local PostgreSQL: + - **New user:** Created with `is_active=true` + - **Existing user:** Name/email updated, `last_login_at` refreshed +4. User record checked for `is_active` status +5. If `is_active=false`, authentication fails (401) -// Handler accessing authenticated user -func (h *UserHandler) GetProfile(c *gin.Context) { - user := middlewares.GetUserFromContext(c) +**Domain Model:** - c.JSON(200, gin.H{ - "user": user, - "roles": user.Roles, - }) +```go +type User struct { + NIP string `db:"nip" json:"nip"` + Name string `db:"name" json:"name"` + Email string `db:"email" json:"email"` + IsActive bool `db:"is_active" json:"is_active"` + LastLoginAt time.Time `db:"last_login_at" json:"last_login_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Roles []string `db:"-" json:"roles,omitempty"` } ``` -### Role Configuration - -Configure which client roles to extract via `KEYCLOAK_CLIENT_ID`: - -| Config | Roles Extracted | -|--------|-----------------| -| `KEYCLOAK_CLIENT_ID=exodus` | `["exodus.business-support", "exodus.default", "exodus.user"]` | -| `KEYCLOAK_CLIENT_ID=exodus-admin` | `["team-trade.admin"]` | -| `KEYCLOAK_CLIENT_ID=portal` | `["portal.user"]` | +**Note:** `Roles` is transient - not persisted, only from JWT. --- backend-dev/references/CODING-STANDARDS.md --- @@ -1,25 +1,14 @@ -# Coding Standards - -- [Table of Contents](#table-of-contents) -- [REST API Standards](#rest-api-standards) -- [Naming Conventions](#naming-conventions) -- [SOLID Guidelines](#solid-guidelines) -- [Critical Rules](#critical-rules) -- [Import Grouping](#import-grouping) -- [Error Handling](#error-handling) -- [Testing Patterns](#testing-patterns) - ---- - -## Table of Contents +# Table of Contents 1. [REST API Standards](#rest-api-standards) 2. [Naming Conventions](#naming-conventions) -3. [SOLID Guidelines](#solid-guidelines) -4. [Critical Rules](#critical-rules) -5. [Import Grouping](#import-grouping) -6. [Error Handling](#error-handling) -7. [Testing Patterns](#testing-patterns) +3. [HTTP Methods](#http-methods) +4. [Status Codes](#status-codes) +5. [Response Envelope](#response-envelope) +6. [SOLID Guidelines](#solid-guidelines) +7. [Critical Rules](#critical-rules) +8. [Error Handling](#error-handling) +9. [Testing Patterns](#testing-patterns) --- @@ -34,7 +23,33 @@ | Actions | `/users/123/activate` | `/activateUser` | | Plural | Always plural | Singular | -### HTTP Methods +### Query Parameters + +``` +GET /users?page=1&per_page=20 +GET /users?search=name:john&sort=-created_at +GET /users?filter[is_active]=true&fields=nip,name +``` + +--- + +## Naming Conventions + +| Element | Convention | Example | +|---------|-----------|---------| +| Packages | lowercase, single word | `domain`, `usecase`, `persistence` | +| Structs | PascalCase | `UserService`, `ProjectHandler` | +| Interfaces | PascalCase | `UserRepository`, `CacheRepository` | +| Functions | PascalCase (exported), camelCase (unexported) | `NewUserService`, `getByNIP` | +| Variables | camelCase | `projectID`, `isActive` | +| Constants | PascalCase | `MaxRetries`, `DefaultTimeout` | +| Database columns | snake_case | `project_id`, `created_at` | +| JSON fields | snake_case | `project_id`, `channel_type` | +| Environment vars | UPPER_SNAKE | `PERSISTENCE_DSN`, `LOG_LEVEL` | + +--- + +## HTTP Methods | Method | Usage | Response | |--------|-------|----------| @@ -44,7 +59,9 @@ | PATCH | Partial update | 200/204 + body | | DELETE | Remove resource | 204 No Content | -### Status Codes +--- + +## Status Codes | Code | Usage | |------|-------| @@ -58,8 +75,11 @@ | 409 | Conflict (duplicate, state violation) | | 422 | Semantic validation error | | 500 | Internal server error | +| 503 | Service unavailable (degraded health) | + +--- -### Response Envelope +## Response Envelope ```go type Response struct { @@ -76,8 +96,6 @@ type Meta struct { } ``` -### Request/Response Examples - **Create User (POST /users):** Request: @@ -133,22 +151,6 @@ Response (200): --- -## Naming Conventions - -| Element | Convention | Example | -|---------|------------|---------| -| Packages | lowercase, single word | domain, usecase, persistence | -| Structs | PascalCase | UserService, ProjectHandler | -| Interfaces | PascalCase | UserRepository, CacheRepository | -| Functions | PascalCase (exported), camelCase (unexported) | NewUserService, getByNIP | -| Variables | camelCase | projectID, isActive | -| Constants | PascalCase | MaxRetries, DefaultTimeout | -| DB columns | snake_case | project_id, created_at | -| JSON fields | snake_case | project_id, channel_type | -| Env vars | UPPER_SNAKE | PERSISTENCE_DSN, LOG_LEVEL | - ---- - ## SOLID Guidelines ### Keep Interfaces Small (ISP) @@ -172,29 +174,6 @@ type UserManager interface { ActivateUser() ExportUsers() ImportUsers() - // ... 10 more methods -} -``` - -### No Global State - -**Bad:** -```go -var db *sql.DB - -func GetUser() { - db.Query(...) // Hidden dependency -} -``` - -**Good:** -```go -type userRepository struct { - pool *pgxpool.Pool -} - -func (r *userRepository) GetUser() { - r.pool.Query(...) } ``` @@ -215,7 +194,7 @@ func NewProvider(cfg *Config) (*Provider, error) { ```go func NewProvider() *Provider { return &Provider{ - db: connectPostgres(), // Hidden, untestable + pool: createPool(), // Hidden dependency } } ``` @@ -228,30 +207,25 @@ func NewProvider() *Provider { **Bad:** ```go -// Silent failure - user created but we don't know if it worked -userRepo.Create(ctx, user) +userRepo.Create(ctx, user) // Ignored error! -// Ignored error - database might be down -rows, _ := db.Query(ctx, "SELECT * FROM users") - -// Deferred close without error check -defer file.Close() +rows, _ := db.Query(ctx, "SELECT * FROM users") // Silent failure ``` **Good:** ```go -// Always check errors immediately if err := userRepo.Create(ctx, user); err != nil { - return fmt.Errorf("failed to create user: %w", err) + return fmt.Errorf("create user: %w", err) } -// Handle or return rows, err := db.Query(ctx, "SELECT * FROM users") if err != nil { - return nil, fmt.Errorf("query failed: %w", err) + return nil, fmt.Errorf("query users: %w", err) } +``` -// Named return for deferred error check +**Named return for deferred close:** +```go func processFile() (err error) { f, err := os.Open("file.txt") if err != nil { @@ -262,85 +236,51 @@ func processFile() (err error) { err = cerr } }() - // ... process return nil } ``` -**Rule:** If you see `_` for error ignoring, it must have a comment explaining why it's safe. - ---- - -## Import Grouping - -Group imports with blank line between groups: - -```go -import ( - // Standard library - "context" - "encoding/json" - "fmt" - "time" - - // Third-party packages - "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5" - "github.com/pkg/errors" - - // Internal packages - "myapp/internal/core/domain" - "myapp/internal/core/port/inbound" - "myapp/internal/core/port/outbound" -) -``` - --- ## Error Handling -### Error Wrapping - -Use `github.com/pkg/errors` for error wrapping: +### Always Return Errors +**Bad:** ```go -func (r *userRepository) GetByNIP(ctx context.Context, nip string) (*domain.User, error) { - var user domain.User - err := r.pool.QueryRow(ctx, - "SELECT nip, name FROM users WHERE nip = $1", nip, - ).Scan(&user.NIP, &user.Name) - - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil // Not found = nil, nil - } +func GetConfig() *Config { + cfg, err := config.Load(ctx) if err != nil { - return nil, errors.Wrap(err, "failed to get user") + panic(err) // NEVER } - return &user, nil + return cfg } ``` -### Response Error Pattern - +**Good:** ```go -func (h *UserHandler) GetByNIP(c *gin.Context) { - resp := response.Response{} - defer resp.Render(c) - - nip := c.Param("nip") - - user, err := h.svc.GetByNIP(c.Request.Context(), nip) +func GetConfig() (*Config, error) { + cfg, err := config.Load(ctx) if err != nil { - resp.SetError(err, http.StatusInternalServerError) - return + return nil, fmt.Errorf("load config: %w", err) } - if user == nil { - resp.SetError(errors.New("user not found"), http.StatusNotFound) - return + return cfg, nil +} + +func main() { + cfg, err := GetConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } +} +``` + +### Error Wrapping - resp.Data = user - resp.StatusCode = http.StatusOK +```go +if err != nil { + return fmt.Errorf("get user: %w", err) } ``` @@ -348,72 +288,47 @@ func (h *UserHandler) GetByNIP(c *gin.Context) { ## Testing Patterns -### Unit Test with Mocks +### Table-Driven Tests ```go -package usecase - -import ( - "context" - "testing" - - "myapp/internal/core/domain" -) - -type mockUserRepository struct { - getByNIPFunc func(ctx context.Context, nip string) (*domain.User, error) - createFunc func(ctx context.Context, user *domain.User) error -} - -func (m *mockUserRepository) GetByNIP(ctx context.Context, nip string) (*domain.User, error) { - if m.getByNIPFunc != nil { - return m.getByNIPFunc(ctx, nip) - } - return nil, nil -} - -func (m *mockUserRepository) Create(ctx context.Context, user *domain.User) error { - if m.createFunc != nil { - return m.createFunc(ctx, user) - } - return nil -} - func TestUserService_GetByNIP(t *testing.T) { tests := []struct { name string nip string setupMock func(*mockUserRepository) expectError bool + expectNil bool }{ { name: "user found", - nip: "12345", - setupMock: func(mr *mockUserRepository) { - mr.getByNIPFunc = func(ctx context.Context, nip string) (*domain.User, error) { - return &domain.User{NIP: "12345", Name: "Test User"}, nil + nip: "p021050", + setupMock: func(m *mockUserRepository) { + m.getByNIPFunc = func(nip string) (*User, error) { + return &User{NIP: nip, Name: "Test"}, nil } }, expectError: false, + expectNil: false, }, { name: "user not found", - nip: "99999", - setupMock: func(mr *mockUserRepository) { - mr.getByNIPFunc = func(ctx context.Context, nip string) (*domain.User, error) { + nip: "nonexistent", + setupMock: func(m *mockUserRepository) { + m.getByNIPFunc = func(nip string) (*User, error) { return nil, nil } }, expectError: false, + expectNil: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - userRepo := &mockUserRepository{} - tt.setupMock(userRepo) + mockRepo := &mockUserRepository{} + tt.setupMock(mockRepo) - svc := NewUserService(userRepo, nil) + svc := NewUserService(mockRepo) user, err := svc.GetByNIP(context.Background(), tt.nip) if tt.expectError && err == nil { @@ -422,20 +337,57 @@ func TestUserService_GetByNIP(t *testing.T) { if !tt.expectError && err != nil { t.Errorf("unexpected error: %v", err) } - if user != nil && user.NIP != tt.nip { - t.Errorf("expected nip %s, got %s", tt.nip, user.NIP) + if tt.expectNil && user != nil { + t.Error("expected nil user, got value") } }) } } ``` -### Test Naming +### Mock Repository + +```go +type mockUserRepository struct { + getByNIPFunc func(nip string) (*User, error) + createFunc func(user *User) error +} +func (m *mockUserRepository) GetByNIP(ctx context.Context, nip string) (*User, error) { + if m.getByNIPFunc != nil { + return m.getByNIPFunc(nip) + } + return nil, nil +} + +func (m *mockUserRepository) Create(ctx context.Context, user *User) error { + if m.createFunc != nil { + return m.createFunc(user) + } + return nil +} ``` -Test{UnitOfWork}_{Scenario}_{ExpectedBehavior} -TestUserService_GetByNIP_UserFound_ReturnsUser -TestUserService_GetByNIP_UserNotFound_ReturnsNil -TestUserService_GetByNIP_DatabaseError_ReturnsError +--- + +## Import Grouping + +```go +import ( + // Standard library + "context" + "encoding/json" + "fmt" + "time" + + // Third-party packages + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + "github.com/pkg/errors" + + // Internal packages + "messaging-be/internal/core/domain" + "messaging-be/internal/core/port/inbound" + "messaging-be/internal/core/port/outbound" +) ``` --- backend-dev/references/CONFIGURATION.md --- @@ -1,124 +1,91 @@ -# Configuration +# Table of Contents -- [Table of Contents](#table-of-contents) -- [Configuration Strategy](#configuration-strategy) -- [Local Development](#local-development) -- [OpenBao Integration](#openbao-integration) -- [Config Loader](#config-loader) -- [Provider Pattern](#provider-pattern) -- [Environment Variables](#environment-variables) +1. [Overview](#overview) +2. [Configuration Strategy](#configuration-strategy) +3. [Local Development (.env)](#local-development-env) +4. [OpenBao v2 Integration](#openbao-v2-integration) +5. [Config Loader Implementation](#config-loader-implementation) +6. [Provider Pattern Wiring](#provider-pattern-wiring) +7. [Environment Variables Reference](#environment-variables-reference) --- -## Table of Contents +## Overview -1. [Configuration Strategy](#configuration-strategy) -2. [Local Development](#local-development) -3. [OpenBao Integration](#openbao-integration) -4. [Config Loader](#config-loader) -5. [Provider Pattern](#provider-pattern) -6. [Environment Variables](#environment-variables) +Configuration management following 12-Factor methodology: +- **Local dev:** `.env` file via golobby/dotenv +- **Staging/Prod:** OpenBao v2 KV secrets +- **No hardcoded values** in code --- ## Configuration Strategy -``` -APP_ENV determines config source: - -┌─────────────────────────────────────────────────────────┐ -│ APP_ENV=value │ -├──────────────┬──────────────────────────────────────────┤ -│ "local" │ "staging" │ -│ │ │ -│ .env file │ OpenBao v2 │ -│ (golobby) │ (KV secrets) │ -│ │ │ -│ Manual │ Automated │ -│ editing │ secret injection │ -└─────────────┴──────────────────────────────────────────┘ +```go +func Load(ctx context.Context) (*Config, error) { + env := os.Getenv("APP_ENV") + if env == "" { + env = "local" + } + + switch env { + case "local": + return loadFromEnvFile() + default: + return loadFromOpenBao(ctx) + } +} ``` -**Rules:** -- Never commit secrets to version control -- Local dev always uses `.env` file -- Staging/production uses OpenBao v2 -- Config struct validates required fields +| Environment | Config Source | Fallback | +|-------------|--------------|----------| +| `local` | `.env` file | - | +| `staging` | OpenBao | - | +| `production` | OpenBao | - | --- -## Local Development +## Local Development (.env) -### .env File - -Create `.env` in project root: +**`.env` file:** ```env -# Application APP_ENV=local -APP_MODE=release +APP_MODE=debug APP_PORT=8080 -APP_ALLOWED_ORIGINS=* +APP_ALLOWED_ORIGINS=http://localhost:3000 -# Database PERSISTENCE_DSN=postgres://user:pass@localhost:5432/messaging?sslmode=disable -PERSISTENCE_MAX_CONNS=25 -PERSISTENCE_MIN_CONNS=5 - -# Cache CACHE_DSN=postgres://user:pass@localhost:5432/messaging?sslmode=disable&search_path=cache -CACHE_MAX_CONNS=10 -CACHE_MIN_CONNS=2 -CACHE_DEFAULT_TTL=5m -CACHE_CLEANUP_INTERVAL=10m -# Logging -LOG_LEVEL=info -LOG_FORMAT=json -LOG_MASK_FIELDS=password,token,api_key,secret,authorization - -# Tracing -TEMPO_ENABLED=false -TEMPO_ENDPOINT=tempo:4318 - -# Keycloak (JWT Authentication) KEYCLOAK_JWKS_URL=https://auth.pharos.id/realms/production/protocol/openid-connect/certs KEYCLOAK_CLIENT_ID=exodus -# OpenBao (not used in local, but defined for completeness) -OPENBAO_ADDR=https://openbao.pharos.id -OPENBAO_TOKEN= -OPENBAO_MOUNT_PATH=secret -OPENBAO_SECRET_PATH=backend/local +LOG_LEVEL=debug +LOG_FORMAT=json ``` -### .env.example +**Loading:** -Share `.env.example` (without secrets) for other developers: - -```bash -cp .env .env.example -# Remove all secret values +```go +import "github.com/golobby/dotenv" + +func loadFromEnvFile() (*Config, error) { + if err := dotenv.Load(); err != nil { + // .env optional for local dev + if !os.IsNotExist(err) { + return nil, err + } + } + return loadFromEnv() +} ``` --- -## OpenBao Integration - -### OpenBao v2 KV - -Secrets stored at: `{mount_path}/data/{secret_path}` - -**Example:** -``` -secret/data/backend/production -├── PERSISTENCE_DSN=postgres://... -├── KEYCLOAK_JWKS_URL=https://... -├── KEYCLOAK_CLIENT_ID=exodus -└── ... other secrets -``` +## OpenBao v2 Integration -### Config Model +**OpenBao Config:** ```go type OpenBaoConfig struct { @@ -127,30 +94,41 @@ type OpenBaoConfig struct { MountPath string `env:"OPENBAO_MOUNT_PATH" default:"secret"` SecretPath string `env:"OPENBAO_SECRET_PATH" required:"true"` } +``` + +**Secret Path Structure:** + +``` +secret/ +└── data/ + └── backend/ + ├── local # Local secrets + ├── staging # Staging secrets + └── production # Production secrets +``` -type Config struct { - App AppConfig - Persistence PersistenceConfig - Cache CacheConfig - Log LogConfig - Tempo TempoConfig - Keycloak KeycloakConfig - OpenBao OpenBaoConfig +**Secret Content (KV v2):** + +```json +{ + "data": { + "PERSISTENCE_DSN": "postgres://...", + "KEYCLOAK_JWKS_URL": "https://...", + "KEYCLOAK_CLIENT_ID": "exodus" + } } ``` --- -## Config Loader +## Config Loader Implementation ```go package config import ( "context" - "fmt" "os" - "time" "github.com/golobby/dotenv" "github.com/golobby/env/v2" @@ -174,163 +152,120 @@ func Load(ctx context.Context) (*Config, error) { func loadFromEnv() (*Config, error) { var cfg Config if err := env.Parse(&cfg); err != nil { - return nil, fmt.Errorf("failed to parse config from env: %w", err) + return nil, err } return &cfg, nil } func loadFromOpenBao(ctx context.Context) (*Config, error) { - var cfg Config - if err := env.Parse(&cfg.OpenBao); err != nil { - return nil, fmt.Errorf("failed to parse OpenBao config: %w", err) + var vaultCfg OpenBaoConfig + if err := env.Parse(&vaultCfg); err != nil { + return nil, err } client, err := api.NewClient(&api.Config{ - Address: cfg.OpenBao.Addr, + Address: vaultCfg.Addr, }) if err != nil { - return nil, fmt.Errorf("failed to create OpenBao client: %w", err) + return nil, err } - client.SetToken(cfg.OpenBao.Token) + client.SetToken(vaultCfg.Token) - secret, err := client.KVv2(cfg.OpenBao.MountPath).Get(ctx, cfg.OpenBao.SecretPath) + // Path: {mount_path}/data/{secret_path} + secret, err := client.KVv2(vaultCfg.MountPath).Get(ctx, vaultCfg.SecretPath) if err != nil { - return nil, fmt.Errorf("failed to get secret from OpenBao: %w", err) + return nil, err } - data, ok := secret.Data["data"].(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid secret data format") + data := secret.Data["data"].(map[string]interface{}) + + // Parse base config from env + var cfg Config + if err := env.Parse(&cfg); err != nil { + return nil, err } - // Apply secrets to config - cfg.Persistence.DSN = getString(data, "PERSISTENCE_DSN", cfg.Persistence.DSN) - cfg.Keycloak.JWKSURL = getString(data, "KEYCLOAK_JWKS_URL", cfg.Keycloak.JWKSURL) - cfg.Keycloak.ClientID = getString(data, "KEYCLOAK_CLIENT_ID", cfg.Keycloak.ClientID) - // ... apply other secrets + // Apply secrets from OpenBao + if dsn, ok := data["PERSISTENCE_DSN"].(string); ok { + cfg.Persistence.DSN = dsn + } + if jwksURL, ok := data["KEYCLOAK_JWKS_URL"].(string); ok { + cfg.Keycloak.JWKSURL = jwksURL + } + if clientID, ok := data["KEYCLOAK_CLIENT_ID"].(string); ok { + cfg.Keycloak.ClientID = clientID + } return &cfg, nil } - -func getString(data map[string]interface{}, key, fallback string) string { - if v, ok := data[key].(string); ok && v != "" { - return v - } - return fallback -} ``` --- -## Provider Pattern +## Provider Pattern Wiring -Dependency wiring via explicit Provider struct. +Explicit dependency injection via Provider struct. ```go -package provider - -import ( - "context" - "fmt" - - "myapp/config" - "myapp/internal/adapter/outbound/cache" - "myapp/internal/adapter/outbound/keycloak" - "myapp/internal/adapter/outbound/persistence" - "myapp/internal/core/port/inbound" - "myapp/internal/core/usecase" - "myapp/pkg/logger" - "myapp/pkg/metrics" - "myapp/pkg/tracing" -) - type Provider struct { - Config *config.Config + Config *Config Logger *logger.Logger Metrics *metrics.Prometheus Tracer *tracing.TracerProvider PersistencePool *persistence.Pool CachePool *persistence.Pool + Keycloak *keycloak.Provider UserRepo outbound.UserRepository CacheRepo outbound.CacheRepository - Keycloak *keycloak.Provider AuthSvc inbound.AuthService UserSvc inbound.UserService } -func NewProvider(ctx context.Context, cfg *config.Config) (*Provider, error) { +func NewProvider(ctx context.Context, cfg *Config) (*Provider, error) { p := &Provider{Config: cfg} if err := p.initLogger(); err != nil { - return nil, fmt.Errorf("logger: %w", err) + return nil, err } if err := p.initMetrics(); err != nil { - return nil, fmt.Errorf("metrics: %w", err) - } - if err := p.initTracer(ctx); err != nil { - return nil, fmt.Errorf("tracer: %w", err) + return nil, err } if err := p.initPersistence(ctx); err != nil { - return nil, fmt.Errorf("persistence: %w", err) - } - if err := p.initCache(ctx); err != nil { - return nil, fmt.Errorf("cache: %w", err) + return nil, err } if err := p.initKeycloak(ctx); err != nil { - return nil, fmt.Errorf("keycloak: %w", err) + return nil, err } p.UserRepo = persistence.NewUserRepository(p.PersistencePool) p.CacheRepo = cache.NewPostgresCacheRepository(p.CachePool) p.AuthSvc = usecase.NewAuthService(p.Keycloak, p.UserRepo) - p.UserSvc = usecase.NewUserService(p.UserRepo, p.CacheRepo) return p, nil } - -func (p *Provider) Close(ctx context.Context) { - if p.PersistencePool != nil { - p.PersistencePool.Close() - } - if p.CachePool != nil { - p.CachePool.Close() - } - if p.Tracer != nil { - p.Tracer.Shutdown(ctx) - } -} ``` --- -## Environment Variables - -### Required Variables - -| Variable | Description | Local Default | -|----------|-------------|---------------| -| `APP_ENV` | Environment name | `local` | -| `PERSISTENCE_DSN` | PostgreSQL connection string | Required | -| `KEYCLOAK_JWKS_URL` | Keycloak JWKS endpoint | Required | -| `KEYCLOAK_CLIENT_ID` | Keycloak client ID | Required | - -### Optional Variables +## Environment Variables Reference | Variable | Description | Default | |----------|-------------|---------| +| `APP_ENV` | Environment (local/staging/production) | `local` | +| `APP_MODE` | Gin mode (debug/release) | `release` | | `APP_PORT` | HTTP server port | `8080` | -| `APP_MODE` | Gin mode (`release`, `debug`) | `release` | -| `APP_ALLOWED_ORIGINS` | CORS origins (comma-separated) | `*` | -| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | `info` | -| `LOG_FORMAT` | Log format (`json`, `text`) | `json` | +| `APP_ALLOWED_ORIGINS` | CORS origins | `*` | +| `PERSISTENCE_DSN` | PostgreSQL connection string | Required | +| `PERSISTENCE_MAX_CONNS` | Max DB connections | `25` | +| `PERSISTENCE_MIN_CONNS` | Min DB connections | `5` | +| `CACHE_DSN` | Cache DB connection string | Required | +| `KEYCLOAK_JWKS_URL` | Keycloak JWKS endpoint | Required | +| `KEYCLOAK_CLIENT_ID` | Keycloak client ID | Required | +| `LOG_LEVEL` | Logging level | `info` | +| `LOG_FORMAT` | Log format (json/text) | `json` | | `TEMPO_ENABLED` | Enable tracing | `false` | | `TEMPO_ENDPOINT` | Tempo OTLP endpoint | `tempo:4318` | - -### OpenBao Variables (Non-Local) - -| Variable | Description | -|----------|-------------| -| `OPENBAO_ADDR` | OpenBao server address | -| `OPENBAO_TOKEN` | OpenBao token | -| `OPENBAO_MOUNT_PATH` | KV v2 mount path | -| `OPENBAO_SECRET_PATH` | Secret path within mount | +| `OPENBAO_ADDR` | OpenBao server address | Required (non-local) | +| `OPENBAO_TOKEN` | OpenBao token | Required (non-local) | +| `OPENBAO_MOUNT_PATH` | KV v2 mount path | `secret` | +| `OPENBAO_SECRET_PATH` | Secret path | Required (non-local) | --- backend-dev/references/DATABASE.md --- @@ -1,23 +1,18 @@ -# Database +# Table of Contents -- [Table of Contents](#table-of-contents) -- [Schema Design](#schema-design) -- [Migrations](#migrations) -- [Repository Pattern](#repository-pattern) -- [Caching Layer](#caching-layer) -- [Health Checks](#health-checks) -- [Connection Pooling](#connection-pooling) +1. [Overview](#overview) +2. [Schema Design](#schema-design) +3. [Migrations](#migrations) +4. [Repository Pattern](#repository-pattern) +5. [Caching Layer](#caching-layer) +6. [Health Checks](#health-checks) +7. [Connection Pooling](#connection-pooling) --- -## Table of Contents +## Overview -1. [Schema Design](#schema-design) -2. [Migrations](#migrations) -3. [Repository Pattern](#repository-pattern) -4. [Caching Layer](#caching-layer) -5. [Health Checks](#health-checks) -6. [Connection Pooling](#connection-pooling) +PostgreSQL as primary data store with pgx for high-performance connection pooling. --- @@ -40,10 +35,49 @@ CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_is_active ON users(is_active); ``` -### Migration File +### Cache Schema (Unlogged Tables) ```sql --- db/migrations/persistence/0001_initial_schema.up.sql +CREATE SCHEMA IF NOT EXISTS cache; + +CREATE UNLOGGED TABLE cache.entries ( + key TEXT PRIMARY KEY, + value BYTEA NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_cache_expires ON cache.entries(expires_at) + WHERE expires_at IS NOT NULL; +``` + +**Why Unlogged Tables?** +- No Write-Ahead Log (WAL) overhead = 5-10x faster writes +- Data survives crashes but not unclean shutdowns +- Acceptable for cache (rebuilds from source on restart) +- Same PostgreSQL instance = simpler operations + +--- + +## Migrations + +Uses `golang-migrate` for database schema versioning. + +### Migration Files + +``` +db/migrations/persistence/ +├── 000001_create_users.up.sql +├── 000001_create_users.down.sql +├── 000002_add_last_login.up.sql +├── 000002_add_last_login.down.sql +└── ... +``` + +### Example Migration + +```sql +-- 000001_create_users.up.sql CREATE TABLE IF NOT EXISTS users ( nip VARCHAR(20) PRIMARY KEY, name VARCHAR(255) NOT NULL, @@ -57,121 +91,20 @@ CREATE TABLE IF NOT EXISTS users ( CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_is_active ON users(is_active); --- db/migrations/persistence/0001_initial_schema.down.sql +-- 000001_create_users.down.sql DROP TABLE IF EXISTS users; ``` ---- - -## Migrations - -### Using golang-migrate +### Running Migrations ```bash -# Install CLI -go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest +# Via admin CLI +./messaging-be migrate up +./messaging-be migrate down 1 -# Run migrations +# Via migrate CLI directly migrate -database "$PERSISTENCE_DSN" -path db/migrations/persistence up - -# Rollback migrate -database "$PERSISTENCE_DSN" -path db/migrations/persistence down 1 - -# Check status -migrate -database "$PERSISTENCE_DSN" -path db/migrations/persistence version -``` - -### Programmatic Migrations (Admin CLI) - -```go -package admin - -import ( - "fmt" - "strconv" - - "github.com/golang-migrate/migrate/v4" - _ "github.com/golang-migrate/migrate/v4/database/postgres" - _ "github.com/golang-migrate/migrate/v4/source/file" - "github.com/urfave/cli/v2" -) - -func migrateCommand(cfg *config.Config) *cli.Command { - return &cli.Command{ - Name: "migrate", - Usage: "Database migrations", - Subcommands: []*cli.Command{ - { - Name: "up", - Usage: "Apply all pending migrations", - Action: func(c *cli.Context) error { - m, err := migrate.New( - "file://db/migrations/persistence", - cfg.Persistence.DSN, - ) - if err != nil { - return fmt.Errorf("failed to create migrator: %w", err) - } - if err := m.Up(); err != nil && err != migrate.ErrNoChange { - return fmt.Errorf("migration failed: %w", err) - } - fmt.Println("Migrations applied successfully") - return nil - }, - }, - { - Name: "down", - Usage: "Rollback migrations", - Action: func(c *cli.Context) error { - if cfg.App.Env == "production" { - return fmt.Errorf("migrate down is disabled in production environment") - } - - steps := 1 - if c.Args().Len() > 0 { - steps, _ = strconv.Atoi(c.Args().First()) - } - - m, err := migrate.New( - "file://db/migrations/persistence", - cfg.Persistence.DSN, - ) - if err != nil { - return fmt.Errorf("failed to create migrator: %w", err) - } - if err := m.Steps(-steps); err != nil && err != migrate.ErrNoChange { - return fmt.Errorf("rollback failed: %w", err) - } - fmt.Printf("Rolled back %d migration(s)\n", steps) - return nil - }, - }, - { - Name: "status", - Usage: "Show migration status", - Action: func(c *cli.Context) error { - m, err := migrate.New( - "file://db/migrations/persistence", - cfg.Persistence.DSN, - ) - if err != nil { - return fmt.Errorf("failed to create migrator: %w", err) - } - version, dirty, err := m.Version() - if err != nil { - if err == migrate.ErrNilVersion { - fmt.Println("No migrations applied") - return nil - } - return fmt.Errorf("failed to get version: %w", err) - } - fmt.Printf("Version: %d, Dirty: %v\n", version, dirty) - return nil - }, - }, - }, - } -} ``` --- @@ -224,7 +157,7 @@ func (p *Pool) Close() { } ``` -### User Repository Implementation +### User Repository ```go package persistence @@ -237,7 +170,6 @@ import ( "github.com/jackc/pgx/v5" "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" ) type userRepository struct { @@ -249,9 +181,6 @@ func NewUserRepository(pool *Pool) outbound.UserRepository { } func (r *userRepository) GetByNIP(ctx context.Context, nip string) (*domain.User, error) { - ctx, span := tracer.Start(ctx, "UserRepository.GetByNIP") - defer span.End() - var user domain.User err := r.pool.QueryRow(ctx, `SELECT nip, name, email, is_active, last_login_at, created_at, updated_at @@ -261,22 +190,16 @@ func (r *userRepository) GetByNIP(ctx context.Context, nip string) (*domain.User &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt) if err == pgx.ErrNoRows { - span.AddEvent("user_not_found") return nil, nil } if err != nil { - span.RecordError(err) return nil, errors.Wrap(err, "failed to get user") } - span.SetAttributes(attribute.String("user.nip", user.NIP)) return &user, nil } func (r *userRepository) Create(ctx context.Context, user *domain.User) error { - ctx, span := tracer.Start(ctx, "UserRepository.Create") - defer span.End() - err := r.pool.QueryRow(ctx, `INSERT INTO users (nip, name, email, is_active, last_login_at) VALUES ($1, $2, $3, $4, $5) @@ -285,7 +208,6 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error { ).Scan(&user.CreatedAt, &user.UpdatedAt) if err != nil { - span.RecordError(err) return errors.Wrap(err, "failed to create user") } @@ -293,9 +215,6 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error { } func (r *userRepository) Update(ctx context.Context, user *domain.User) error { - ctx, span := tracer.Start(ctx, "UserRepository.Update") - defer span.End() - _, err := r.pool.Exec(ctx, `UPDATE users SET name = $1, email = $2, is_active = $3, last_login_at = $4, updated_at = NOW() @@ -303,68 +222,17 @@ func (r *userRepository) Update(ctx context.Context, user *domain.User) error { user.Name, user.Email, user.IsActive, user.LastLoginAt, user.NIP, ) if err != nil { - span.RecordError(err) return errors.Wrap(err, "failed to update user") } return nil } - -func (r *userRepository) List(ctx context.Context) ([]domain.User, error) { - ctx, span := tracer.Start(ctx, "UserRepository.List") - defer span.End() - - rows, err := r.pool.Query(ctx, - `SELECT nip, name, email, is_active, last_login_at, created_at, updated_at - FROM users ORDER BY created_at DESC`, - ) - if err != nil { - span.RecordError(err) - return nil, errors.Wrap(err, "failed to list users") - } - defer rows.Close() - - var users []domain.User - for rows.Next() { - var u domain.User - if err := rows.Scan(&u.NIP, &u.Name, &u.Email, &u.IsActive, - &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt); err != nil { - span.RecordError(err) - return nil, errors.Wrap(err, "failed to scan user") - } - users = append(users, u) - } - - span.SetAttributes(attribute.Int("users.count", len(users))) - return users, nil -} ``` --- ## Caching Layer -### Cache Schema - -```sql --- db/migrations/cache/0001_cache_schema.up.sql -CREATE SCHEMA IF NOT EXISTS cache; - -CREATE UNLOGGED TABLE cache.entries ( - key TEXT PRIMARY KEY, - value BYTEA NOT NULL, - expires_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - -CREATE INDEX idx_cache_expires ON cache.entries(expires_at) - WHERE expires_at IS NOT NULL; - --- db/migrations/cache/0001_cache_schema.down.sql -DROP TABLE IF EXISTS cache.entries; -DROP SCHEMA IF EXISTS cache; -``` - ### Cache Repository ```go @@ -380,15 +248,15 @@ import ( "github.com/pkg/errors" ) -type PostgresCacheRepository struct { - pool *Pool +type postgresCacheRepository struct { + pool *persistence.Pool } -func NewPostgresCacheRepository(pool *Pool) outbound.CacheRepository { - return &PostgresCacheRepository{pool: pool} +func NewPostgresCacheRepository(pool *persistence.Pool) outbound.CacheRepository { + return &postgresCacheRepository{pool: pool} } -func (r *PostgresCacheRepository) Get(ctx context.Context, key string) ([]byte, error) { +func (r *postgresCacheRepository) Get(ctx context.Context, key string) ([]byte, error) { var value []byte var expiresAt time.Time @@ -408,7 +276,7 @@ func (r *PostgresCacheRepository) Get(ctx context.Context, key string) ([]byte, return value, nil } -func (r *PostgresCacheRepository) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error { +func (r *postgresCacheRepository) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error { expiresAt := time.Now().Add(ttl) _, err := r.pool.Exec(ctx, @@ -424,86 +292,71 @@ func (r *PostgresCacheRepository) Set(ctx context.Context, key string, value []b return nil } -func (r *PostgresCacheRepository) Delete(ctx context.Context, key string) error { +func (r *postgresCacheRepository) Delete(ctx context.Context, key string) error { _, err := r.pool.Exec(ctx, `DELETE FROM cache.entries WHERE key = $1`, key) if err != nil { return errors.Wrap(err, "failed to delete cache") } return nil } +``` -func (r *PostgresCacheRepository) Exists(ctx context.Context, key string) (bool, error) { - var exists bool +### HA-Safe Cleanup + +```go +func (r *postgresCacheRepository) CleanupExpired(ctx context.Context) (int64, error) { + // Try to acquire advisory lock (HA-safe) + var acquired bool err := r.pool.QueryRow(ctx, - `SELECT EXISTS( - SELECT 1 FROM cache.entries - WHERE key = $1 AND (expires_at IS NULL OR expires_at > NOW()) - )`, - key, - ).Scan(&exists) + `SELECT pg_try_advisory_lock(hashtext('cache_cleanup'))`, + ).Scan(&acquired) + if err != nil { - return false, errors.Wrap(err, "failed to check cache existence") + return 0, errors.Wrap(err, "failed to acquire cleanup lock") + } + + if !acquired { + return 0, nil // Another instance is running cleanup } - return exists, nil -} -func (r *PostgresCacheRepository) Clear(ctx context.Context) error { - _, err := r.pool.Exec(ctx, `TRUNCATE cache.entries`) + defer func() { + _, _ = r.pool.Exec(ctx, `SELECT pg_advisory_unlock(hashtext('cache_cleanup'))`) + }() + + result, err := r.pool.Exec(ctx, `DELETE FROM cache.entries WHERE expires_at < NOW()`) if err != nil { - return errors.Wrap(err, "failed to clear cache") + return 0, errors.Wrap(err, "failed to cleanup expired entries") } - return nil + + return result.RowsAffected(), nil } ``` -### Why Unlogged Tables? - -- No Write-Ahead Log (WAL) overhead = faster writes -- Data survives crashes but not unclean shutdowns -- Acceptable for cache (rebuilds from source on restart) - --- ## Health Checks -### Database Health Check - -```go -func checkDatabase(pool *persistence.Pool) bool { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return pool.Ping(ctx) == nil -} - -func checkCache(pool *persistence.Pool) bool { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return pool.Ping(ctx) == nil -} -``` - -### Health Endpoint +### Health Endpoint with Version ```go r.GET("/healthz", func(c *gin.Context) { - checks := map[string]bool{ - "database": checkDatabase(provider.PersistencePool), - "cache": checkCache(provider.CachePool), - } + dbHealthy := checkDatabase(provider.PersistencePool) + cacheHealthy := checkCache(provider.CachePool) - allHealthy := true - for _, ok := range checks { - if !ok { - allHealthy = false - break - } + allHealthy := dbHealthy && cacheHealthy + status := "ok" + if !allHealthy { + status = "degraded" } response := gin.H{ - "status": "ok", + "status": status, "version": buildinfo.Version, "commit": buildinfo.Commit, - "checks": checks, + "checks": gin.H{ + "database": dbHealthy, + "cache": cacheHealthy, + }, } if allHealthy { @@ -512,41 +365,30 @@ r.GET("/healthz", func(c *gin.Context) { c.JSON(503, response) } }) + +func checkDatabase(pool *persistence.Pool) bool { + return pool.Ping(context.Background()) == nil +} ``` --- ## Connection Pooling -### Pool Configuration +**Pool Configuration:** -```go -config.MaxConns = 25 // Maximum connections -config.MinConns = 5 // Minimum connections -config.MaxConnLifetime = time.Hour // Connection lifetime -config.MaxConnIdleTime = 30 * time.Minute // Idle timeout -config.HealthCheckPeriod = time.Minute // Health check interval -``` +| Setting | Default | Description | +|---------|---------|-------------| +| `MaxConns` | 25 | Maximum connections | +| `MinConns` | 5 | Minimum idle connections | +| `MaxConnLifetime` | 1h | Maximum connection lifetime | +| `MaxConnIdleTime` | 30m | Maximum idle time | +| `HealthCheckPeriod` | 1m | Health check interval | -### Monitoring +**Recommended Settings:** -```go -import "github.com/prometheus/client_golang/prometheus" - -func RecordDBMetrics(pool *pgxpool.Pool) { - go func() { - for { - stats := pool.Stats() - prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "db_pool_connections", - Help: "Number of connections in the pool", - }, - []string{"state"}, - ).WithLabelValues("active").Set(float64(stats.TotalConns)) - - time.Sleep(15 * time.Second) - } - }() -} -``` +| Environment | MaxConns | MinConns | +|-------------|----------|----------| +| Development | 10 | 2 | +| Staging | 20 | 5 | +| Production | 50 | 10 | --- backend-dev/references/DEPLOYMENT.md --- @@ -0,0 +1,444 @@ +# Table of Contents + +1. [Overview](#overview) +2. [Application Bootstrapper](#application-bootstrapper) +3. [Admin CLI](#admin-cli) +4. [Production Safety](#production-safety) +5. [Health Endpoint](#health-endpoint) +6. [Docker](#docker) +7. [Docker Compose](#docker-compose) +8. [GitLab CI/CD](#gitlab-cicd) +9. [Operations](#operations) + +--- + +## Overview + +Deployment and operations guide including Docker, CI/CD, and admin CLI. + +--- + +## Application Bootstrapper + +Single binary with command dispatch: + +```bash +./messaging-be # Run as daemon (default) +./messaging-be server # Explicit daemon +./messaging-be migrate # Run migrations (default: up) +./messaging-be migrate up # Run migrations up +./messaging-be migrate down 1 # Rollback 1 migration +./messaging-be version # Show version +``` + +**main.go:** + +```go +package main + +import ( + "fmt" + "os" + + "messaging-be/cmd/admin" + "messaging-be/cmd/server" + "messaging-be/pkg/buildinfo" +) + +func main() { + if len(os.Args) < 2 { + if err := server.Run([]string{}); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + + cmdName := os.Args[1] + args := os.Args[2:] + + switch cmdName { + case "server": + if err := server.Run(args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + case "migrate": + if err := admin.Migrate(args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + case "version", "-v", "--version": + fmt.Printf("messaging-be version %s (commit: %s)\n", + buildinfo.Version, buildinfo.Commit) + default: + fmt.Printf("Unknown command: %s\n", cmdName) + printHelp() + os.Exit(1) + } +} + +func printHelp() { + fmt.Println("Usage: messaging-be [command]") + fmt.Println() + fmt.Println("Commands:") + fmt.Println(" server Run HTTP server (default)") + fmt.Println(" migrate [cmd] Database migrations (default: up)") + fmt.Println(" version Show version") +} +``` + +--- + +## Admin CLI + +### Migrate Command + +```go +package admin + +import ( + "fmt" + "os" + "strconv" + + "messaging-be/cmd" + "messaging-be/config" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +func Migrate(args []string) error { + cfg, err := cmd.GetConfig() + if err != nil { + return fmt.Errorf("config: %w", err) + } + + subcommand := "up" + if len(args) > 0 { + subcommand = args[0] + } + + switch subcommand { + case "up": + return runMigrateUp(cfg) + case "down": + if cfg.App.Env == "production" { + return fmt.Errorf("migrate down is disabled in production") + } + steps := 1 + if len(args) > 1 { + steps, _ = strconv.Atoi(args[1]) + } + return runMigrateDown(cfg, steps) + default: + return fmt.Errorf("unknown migrate command: %s", subcommand) + } +} + +func runMigrateUp(cfg *config.Config) error { + m, err := migrate.New( + "file://db/migrations/persistence", + cfg.Persistence.DSN, + ) + if err != nil { + return err + } + defer m.Close() + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return err + } + fmt.Println("Migrations applied successfully") + return nil +} + +func runMigrateDown(cfg *config.Config, steps int) error { + m, err := migrate.New( + "file://db/migrations/persistence", + cfg.Persistence.DSN, + ) + if err != nil { + return err + } + defer m.Close() + + if err := m.Steps(-steps); err != nil { + return err + } + fmt.Printf("Rolled back %d migration(s)\n", steps) + return nil +} +``` + +### User Commands + +```go +func UserCommand(args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: messaging-be user ") + } + + subcommand := args[0] + + switch subcommand { + case "list": + return listUsers() + case "deactivate": + if len(args) < 2 { + return fmt.Errorf("usage: messaging-be user deactivate ") + } + return deactivateUser(args[1]) + case "activate": + if len(args) < 2 { + return fmt.Errorf("usage: messaging-be user activate ") + } + return activateUser(args[1]) + default: + return fmt.Errorf("unknown user command: %s", subcommand) + } +} +``` + +--- + +## Production Safety + +**Migrate down is disabled in production:** + +```go +if cfg.App.Env == "production" { + return fmt.Errorf("migrate down is disabled in production") +} +``` + +**Environment detection:** + +```go +// APP_ENV values: +// - "local" - local development +// - "staging" - staging environment +// - "production" - production environment +``` + +--- + +## Health Endpoint + +Returns version and service health: + +```go +r.GET("/healthz", func(c *gin.Context) { + dbHealthy := checkDatabase(provider.PersistencePool) + cacheHealthy := checkCache(provider.CachePool) + + allHealthy := dbHealthy && cacheHealthy + status := "ok" + if !allHealthy { + status = "degraded" + } + + response := gin.H{ + "status": status, + "version": buildinfo.Version, + "commit": buildinfo.Commit, + "checks": gin.H{ + "database": dbHealthy, + "cache": cacheHealthy, + }, + } + + if allHealthy { + c.JSON(200, response) + } else { + c.JSON(503, response) + } +}) +``` + +**Response (healthy):** + +```json +{ + "status": "ok", + "version": "1.0.0", + "commit": "abc123", + "checks": { + "database": true, + "cache": true + } +} +``` + +**Response (degraded):** + +```json +{ + "status": "degraded", + "version": "1.0.0", + "commit": "abc123", + "checks": { + "database": true, + "cache": false + } +} +``` + +--- + +## Docker + +### Dockerfile + +```dockerfile +FROM golang:1.21-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags "-s -w -X messaging-be/pkg/buildinfo.Version=$VERSION -X messaging-be/pkg/buildinfo.Commit=$COMMIT" \ + -o messaging-be . + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates tzdata +WORKDIR /app +COPY --from=builder /app/messaging-be . +COPY --from=builder /app/db/migrations ./db/migrations +EXPOSE 8080 +CMD ["./messaging-be"] +``` + +--- + +## Docker Compose + +```yaml +version: '3.8' + +services: + app: + build: . + env_file: .env + ports: + - "8080:8080" + environment: + PERSISTENCE_DSN: ${PERSISTENCE_DSN} + KEYCLOAK_JWKS_URL: ${KEYCLOAK_JWKS_URL} + KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} +``` + +--- + +## GitLab CI/CD + +```yaml +stages: + - build + - test + - release + - deploy + +variables: + IMAGE_NAME: $CI_REGISTRY_IMAGE/messaging-be + +build: + stage: build + image: golang:1.21-alpine + before_script: + - apk add git make + script: + - make build + artifacts: + paths: + - bin/ + +test: + stage: test + image: golang:1.21-alpine + services: + - postgres:15-alpine + variables: + POSTGRES_DB: test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + before_script: + - apk add git make + - go install github.com/golang-migrate/migrate/v4/cmd/migrate@latest + script: + - migrate -database "$TEST_DATABASE_URL" -path db/migrations/persistence up + - go test -race -coverprofile=coverage.out ./... + - go tool cover -func=coverage.out + coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/' + +release: + stage: release + image: docker:24.0-cli + services: + - docker:24.0-dind + script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker build -t $IMAGE_NAME:$CI_COMMIT_SHA -t $IMAGE_NAME:latest . + - docker push $IMAGE_NAME:$CI_COMMIT_SHA + - docker push $IMAGE_NAME:latest + rules: + - main + +deploy-production: + stage: deploy + image: bitnami/kubectl:latest + environment: + name: production + script: + - kubectl set image deployment/messaging-be server=$IMAGE_NAME:$CI_COMMIT_SHA + - kubectl rollout status deployment/messaging-be + rules: + - main + when: manual +``` + +--- + +## Operations + +### Build + +```bash +make build +``` + +### Run Server + +```bash +./bin/messaging-be +``` + +### Run Migrations + +```bash +./bin/messaging-be migrate # Default: up +./bin/messaging-be migrate up +./bin/messaging-be migrate down 1 +``` + +### Docker Operations + +```bash +# Build and run +docker build -t messaging-be . +docker run -p 8080:8080 messaging-be + +# Run migrations in container +docker exec ./messaging-be migrate + +# View logs +docker logs -f +``` + +### Health Check + +```bash +curl http://localhost:8080/healthz +```