Project: vilbert/skills Commit: fb35b9956c487080839edcfe6a92c7daf379604d Message: Update backend-dev skill with 12-factor compliance and production features Major additions: - Add 1 --- backend-dev/SKILL.md --- @@ -19,32 +19,38 @@ Go backend service template using hexagonal (ports & adapters) architecture with ├── cmd/ │ ├── server/ # Application entry point │ │ └── main.go # Server initialization and wiring -│ └── migrate/ # Database migration command +│ ├── migrate/ # Database migration command +│ │ └── main.go +│ └── admin/ # Admin CLI (Factor 12) │ └── main.go ├── config/ # Configuration models and loading │ ├── config.go # Config initialization -│ └── model.go # Config struct definitions +│ ├── model.go # Config struct definitions +│ └── loader.go # OpenBao/env loader ├── internal/ │ ├── core/ # Business logic (hexagon center) │ │ ├── domain/ # Domain entities and value objects +│ │ │ ├── user.go +│ │ │ └── event/ # Domain events (DDD) │ │ ├── port/ │ │ │ ├── inbound/ # Service interfaces (usecases) │ │ │ └── outbound/ # Repository/provider interfaces │ │ └── usecase/ # Business logic implementations -│ └── adapter/ # External adapters -│ ├── inbound/ # Driving adapters (HTTP handlers) -│ │ └── rest/v1/ -│ └── outbound/ # Driven adapters (DB, external APIs) -│ ├── persistence/ # Main data store (pgx/pgxpool) -│ │ ├── pool.go -│ │ └── user_repository.go -│ └── cache/ # Cache layer (pgx, unlogged tables) -│ ├── postgres.go -│ └── cleanup.go +│ ├── adapter/ # External adapters +│ │ ├── inbound/ # Driving adapters (HTTP handlers) +│ │ │ └── rest/v1/ +│ │ └── outbound/ # Driven adapters (DB, external APIs) +│ │ ├── persistence/ # Main data store (pgx/pgxpool) +│ │ ├── cache/ # Cache layer (pgx, unlogged tables) +│ │ ├── keycloak/ # Keycloak JWT provider +│ │ └── openbao/ # OpenBao v2 client +│ └── provider/ # Dependency provider (Option B) +│ └── provider.go ├── middlewares/ # HTTP middleware │ ├── logging.go # Request/response logging │ ├── metrics.go # Prometheus metrics -│ └── tracing.go # OpenTelemetry tracing +│ ├── tracing.go # OpenTelemetry tracing +│ └── auth.go # JWT authentication ├── pkg/ # Shared packages │ ├── logger/ # Structured logging (slog) │ │ ├── logger.go @@ -60,6 +66,10 @@ Go backend service template using hexagonal (ports & adapters) architecture with ├── db/migrations/ # Database migrations │ ├── persistence/ # Main schema migrations │ └── cache/ # Cache schema migrations +├── deployments/ # Deployment configs (Factor 5, 10) +│ ├── Dockerfile +│ ├── docker-compose.yml +│ └── .gitlab-ci.yml └── main.go # Application bootstrap ``` @@ -90,6 +100,7 @@ Go backend service template using hexagonal (ports & adapters) architecture with - **golobby/env/v2** - Environment variable loading - **golobby/dotenv** - .env file support +- **hashicorp/vault** - OpenBao/OpenVault secrets client (KV v2) ### Utilities @@ -106,6 +117,235 @@ Go backend service template using hexagonal (ports & adapters) architecture with --- +## 12-Factor Compliance + +This template follows [12-Factor App](https://12factor.net/) methodology for production-ready deployments. + +### Factor 1: Codebase + +One codebase tracked in version control, multiple deploys: + +- Git repository with main branch +- Environment-specific configs via environment variables +- Same codebase deploys to local, staging, production + +### Factor 2: Dependencies + +Explicitly declare and isolate dependencies: + +```go +// go.mod +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/jackc/pgx/v5 v5.5.0 +) +``` + +Never rely on system-wide packages. Use `go mod vendor` for full isolation. + +### Factor 3: Config + +Store config in environment variables (Factor 3). See [Configuration](#configuration) for details: + +- Local dev: `.env` file via golobby/dotenv +- Staging/prod: OpenBao v2 KV secrets +- No config hardcoded in code + +### Factor 4: Backing Services + +Treat backing services as attached resources: + +```go +// Connection strings via config, not hardcoded +dsn := cfg.Persistence.DSN // PostgreSQL +jwksURL := cfg.Keycloak.JWKSURL // Keycloak +tempoEndpoint := cfg.Tempo.Endpoint // OpenTelemetry +``` + +Health checks for all backing services: + +```go +// Health check endpoint +r.GET("/healthz", func(c *gin.Context) { + checks := map[string]bool{ + "database": pingDB(), + "cache": pingCache(), + } + for name, ok := range checks { + if !ok { + c.JSON(503, gin.H{"status": "unhealthy", "checks": checks}) + return + } + } + c.JSON(200, gin.H{"status": "ok"}) +}) +``` + +### Factor 5: Build, Release, Run + +Strict separation of build, release, and run stages: + +```yaml +# .gitlab-ci.yml +stages: + - build + - test + - release + - deploy + +build: + stage: build + script: + - go build -ldflags "-X main.version=$CI_COMMIT_SHA" -o bin/server ./cmd/server + +test: + stage: test + script: + - go test -race -coverprofile=coverage.out ./... + coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/' + +release: + stage: release + script: + - docker build -t $IMAGE_NAME:$CI_COMMIT_SHA . + - docker push $IMAGE_NAME:$CI_COMMIT_SHA + rules: + - main + +deploy: + stage: deploy + script: + - kubectl set image deployment/server server=$IMAGE_NAME:$CI_COMMIT_SHA + environment: + name: production + rules: + - main +``` + +### Factor 6: Processes + +Stateless processes with no shared state: + +```go +// Share-nothing architecture +// User sessions stored in PostgreSQL, not memory +// File uploads to object storage, not local disk +// Cache in PostgreSQL unlogged tables, not process memory +``` + +### Factor 7: Port Binding + +Self-contained HTTP service: + +```go +// cmd/server/main.go +port := cfg.App.Port +if !strings.HasPrefix(port, ":") { + port = ":" + port +} +log.Fatal(http.ListenAndServe(port, r.Handler())) +``` + +### Factor 8: Concurrency + +Scale via process model: + +```go +// Worker pool for background jobs +type WorkerPool struct { + workers int + jobs chan Job + wg sync.WaitGroup +} + +func (wp *WorkerPool) Start(ctx context.Context) { + for i := 0; i < wp.workers; i++ { + wp.wg.Add(1) + go wp.worker(ctx, i) + } +} + +func (wp *WorkerPool) Submit(job Job) { + wp.jobs <- job +} +``` + +### Factor 9: Disposability + +Fast startup and graceful shutdown (Factor 9): + +```go +// Graceful shutdown with SIGTERM +func gracefulShutdown(sig os.Signal, server *http.Server) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + log.Printf("Received %s, shutting down gracefully...", sig) + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("Server shutdown failed: %v", err) + } +} +``` + +### Factor 10: Dev/Prod Parity + +Use Docker Compose for local development: + +```yaml +# deployments/docker-compose.yml +version: '3.8' +services: + app: + build: . + env_file: .env + depends_on: + postgres: + condition: service_healthy + keycloak: + condition: service_started + ports: + - "8080:8080" + + postgres: + image: postgres:15 + environment: + POSTGRES_DB: messaging + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d messaging"] + interval: 5s + timeout: 5s + retries: 5 + + keycloak: + image: quay.io/keycloak/keycloak:24.0 + command: start-dev + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + ports: + - "8081:8080" +``` + +### Factor 11: Logs + +Structured logging to stdout only: + +```go +// Use slog - Go 1.21+ standard library +log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) +log.Info("server started", "port", 8080, "env", "production") +``` + +No log files in container. Aggregate via external services (Loki, CloudWatch). + +### Factor 12: Admin Processes + +One-off admin tasks as CLI commands. See [Admin CLI](#admin-cli) section. + +--- + ## Architecture Patterns ### 1. Domain (Core/Entities) @@ -2023,6 +2263,224 @@ func getEnv(key, defaultVal string) string { --- +## Provider Pattern (Option B) + +Use a Provider struct to wire all dependencies explicitly: + +**internal/provider/provider.go:** + +```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 + + // Infrastructure + Logger *logger.Logger + Metrics *metrics.Prometheus + Tracer *tracing.TracerProvider + + // Persistence + PersistencePool *persistence.Pool + CachePool *persistence.Pool + + // Repositories + UserRepo outbound.UserRepository + CacheRepo outbound.CacheRepository + + // External Services + Keycloak *keycloak.Provider + + // Services + AuthSvc inbound.AuthService + UserSvc inbound.UserService +} + +func NewProvider(ctx context.Context, cfg *config.Config) (*Provider, error) { + p := &Provider{Config: cfg} + + // 1. Infrastructure + if err := p.initLogger(); err != nil { + return nil, fmt.Errorf("logger: %w", 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) + } + + // 2. Persistence + 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) + } + + // 3. External Services + if err := p.initKeycloak(ctx); err != nil { + return nil, fmt.Errorf("keycloak: %w", err) + } + + // 4. Repositories + p.UserRepo = persistence.NewUserRepository(p.PersistencePool) + p.CacheRepo = cache.NewPostgresCacheRepository(p.CachePool) + + // 5. Services + p.AuthSvc = usecase.NewAuthService(p.Keycloak, p.UserRepo) + p.UserSvc = usecase.NewUserService(p.UserRepo, p.CacheRepo) + + return p, nil +} + +func (p *Provider) initLogger() error { + buildInfo := logger.BuildInfo{ + Version: p.Config.Build.Version, + Commit: p.Config.Build.Commit, + } + logger.Init(p.Config.Log.Level, p.Config.Log.Format, p.Config.Log.MaskFields, buildInfo) + return nil +} + +func (p *Provider) initMetrics() error { + p.Metrics = metrics.NewPrometheus() + return nil +} + +func (p *Provider) initTracer(ctx context.Context) error { + if !p.Config.Tempo.Enabled { + return nil + } + + tp, err := tracing.NewTracerProvider( + ctx, + p.Config.Tempo.Endpoint, + "backend", + p.Config.Build.Version, + ) + if err != nil { + return err + } + p.Tracer = tp + return nil +} + +func (p *Provider) initPersistence(ctx context.Context) error { + pool, err := persistence.NewPool( + ctx, + p.Config.Persistence.DSN, + p.Config.Persistence.MaxConns, + p.Config.Persistence.MinConns, + ) + if err != nil { + return err + } + p.PersistencePool = pool + return nil +} + +func (p *Provider) initCache(ctx context.Context) error { + pool, err := persistence.NewPool( + ctx, + p.Config.Cache.DSN, + p.Config.Cache.MaxConns, + p.Config.Cache.MinConns, + ) + if err != nil { + return err + } + p.CachePool = pool + return nil +} + +func (p *Provider) initKeycloak(ctx context.Context) error { + prov, err := keycloak.NewProvider(ctx, p.Config.Keycloak.JWKSURL, p.Config.Keycloak.ClientID) + if err != nil { + return err + } + p.Keycloak = prov + return 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) + } +} +``` + +**Usage in cmd/server/main.go:** + +```go +func Run(ctx context.Context) error { + cfg, err := config.Load(ctx) + if err != nil { + return fmt.Errorf("config: %w", err) + } + + provider, err := provider.NewProvider(ctx, cfg) + if err != nil { + return fmt.Errorf("provider: %w", err) + } + defer provider.Close(ctx) + + // Setup Gin with provider components + gin.SetMode(cfg.App.Mode) + r := gin.New() + r.Use(gin.Recovery()) + + // CORS + r.Use(cors.New(cors.Config{ + AllowOrigins: strings.Split(cfg.App.AllowedOrigins, ","), + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Request-ID", "X-Trace-ID"}, + ExposeHeaders: []string{"Content-Length", "X-Request-ID"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + // Middlewares + r.Use(middlewares.TracingMiddleware("backend")) + r.Use(middlewares.PrometheusMiddleware(provider.Metrics)) + r.Use(middlewares.RequestLogger(logger.Default(), provider.Metrics)) + r.Use(middlewares.AuthMiddleware(provider.AuthSvc)) + + // Routes + v1 := r.Group("/v1") + v1.GET("/profile", userHandler.GetProfile) + + return grace.Serve(cfg.App.Port, r.Handler()) +} +``` + +--- + ## Configuration **config/model.go:** @@ -2067,6 +2525,13 @@ type KeycloakConfig struct { ClientID string `env:"KEYCLOAK_CLIENT_ID" required:"true"` } +type OpenBaoConfig struct { + Addr string `env:"OPENBAO_ADDR" required:"true"` + Token string `env:"OPENBAO_TOKEN" required:"true"` + MountPath string `env:"OPENBAO_MOUNT_PATH" default:"secret"` + SecretPath string `env:"OPENBAO_SECRET_PATH" required:"true"` // e.g., "backend/production" +} + type Config struct { App AppConfig Persistence PersistenceConfig @@ -2074,6 +2539,7 @@ type Config struct { Log LogConfig Tempo TempoConfig Keycloak KeycloakConfig + OpenBao OpenBaoConfig } ``` @@ -2081,6 +2547,7 @@ type Config struct { ```env # Application +APP_ENV=local # local, staging, production APP_MODE=release APP_PORT=8080 APP_ALLOWED_ORIGINS=* @@ -2111,12 +2578,94 @@ TEMPO_SAMPLE_RATE=0.1 KEYCLOAK_JWKS_URL=https://auth.pharos.id/realms/production/protocol/openid-connect/certs KEYCLOAK_CLIENT_ID=exodus +# OpenBao (secrets) - Only used when APP_ENV != local +OPENBAO_ADDR=https://openbao.pharos.id +OPENBAO_TOKEN=your-token-here +OPENBAO_MOUNT_PATH=secret +OPENBAO_SECRET_PATH=backend/production + # Build Info VERSION=1.0.0 COMMIT=$(git rev-parse --short HEAD) GO_VERSION=$(go version) ``` +**config/loader.go (OpenBao + Env Fallback):** + +```go +package config + +import ( + "context" + "os" + + "github.com/hashicorp/vault/v2/api" + "github.com/golobby/env/v2" + "github.com/golobby/dotenv" +) + +func Load(ctx context.Context) (*Config, error) { + env := os.Getenv("APP_ENV") + if env == "" { + env = "local" + } + + if env == "local" { + // Local dev: use .env file + dotenv.Load() + return loadFromEnv() + } + + // Staging/production: use OpenBao v2 + return loadFromOpenBao(ctx) +} + +func loadFromEnv() (*Config, error) { + var cfg Config + if err := env.Parse(&cfg); err != nil { + 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, err + } + + // Fetch secrets from OpenBao v2 KV + client, err := api.NewClient(&api.Config{ + Address: cfg.OpenBao.Addr, + }) + if err != nil { + return nil, err + } + client.SetToken(cfg.OpenBao.Token) + + // Path: {mount_path}/data/{secret_path} + secret, err := client.KVv2(cfg.OpenBao.MountPath).Get(ctx, cfg.OpenBao.SecretPath) + if err != nil { + return nil, err + } + + // Merge secrets into config + data := secret.Data["data"].(map[string]interface{}) + + // Apply OpenBao secrets to config fields + // This maps secrets to config struct fields + 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 + } + // ... map other secrets + + return &cfg, nil +} +``` + --- ## Database Migrations @@ -2607,6 +3156,525 @@ admin.Use(middlewares.RequireRole(authSvc, "team-trade.admin")) --- +## Admin CLI (Factor 12) + +One-off administrative processes as CLI commands. + +**cmd/admin/main.go:** + +```go +package main + +import ( + "context" + "fmt" + "os" + + "myapp/config" + "myapp/internal/provider" + + "github.com/urfave/cli/v2" +) + +func main() { + 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) + } + + pvd, err := provider.NewProvider(ctx, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize provider: %v\n", err) + os.Exit(1) + } + defer pvd.Close(ctx) + + app := &cli.App{ + Name: "admin", + Usage: "Administrative commands", + Commands: []*cli.Command{ + migrateCommand(pvd), + seedCommand(pvd), + userCommand(pvd), + cacheCommand(pvd), + healthCommand(pvd), + }, + } + + if err := app.Run(os.Args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} +``` + +**Available Commands:** + +```bash +# Database migrations +admin migrate up # Run all pending migrations +admin migrate down 1 # Rollback 1 migration +admin migrate status # Check migration status + +# Data seeding +admin seed # Seed development data +admin seed --env=staging # Seed staging (with confirmation) + +# User management +admin user list # List all users +admin user deactivate # Deactivate user +admin user activate # Reactivate user +admin user info # Show user details + +# Cache management +admin cache clear # Clear all cache entries +admin cache stats # Show cache statistics + +# Health checks +admin health # Check all services +admin health --json # JSON output for monitoring +``` + +**CLI Command Implementations:** + +```go +func migrateCommand(pvd *provider.Provider) *cli.Command { + return &cli.Command{ + Name: "migrate", + Usage: "Database migration commands", + Subcommands: []*cli.Command{ + { + Name: "up", + Usage: "Run all pending migrations", + Action: func(c *cli.Context) error { + return runMigrationsUp(c.Context, pvd) + }, + }, + { + Name: "down", + Usage: "Rollback migrations", + Args: true, + Action: func(c *cli.Context) error { + steps := 1 + if args := c.Args(); args.Len() > 0 { + steps, _ = strconv.Atoi(args.First()) + } + return runMigrationsDown(c.Context, pvd, steps) + }, + }, + { + Name: "status", + Usage: "Check migration status", + Action: func(c *cli.Context) error { + return printMigrationStatus(c.Context, pvd) + }, + }, + }, + } +} + +func userCommand(pvd *provider.Provider) *cli.Command { + return &cli.Command{ + Name: "user", + Usage: "User management commands", + Subcommands: []*cli.Command{ + { + Name: "list", + Usage: "List all users", + Action: func(c *cli.Context) error { + return listUsers(c.Context, pvd) + }, + }, + { + Name: "deactivate", + Usage: "Deactivate a user", + Args: true, + Action: func(c *cli.Context) error { + return deactivateUser(c.Context, pvd, c.Args().First()) + }, + }, + { + Name: "activate", + Usage: "Activate a user", + Args: true, + Action: func(c *cli.Context) error { + return activateUser(c.Context, pvd, c.Args().First()) + }, + }, + }, + } +} +``` + +--- + +## REST Standards + +### URL Naming Conventions + +| Pattern | Correct | Incorrect | +|---------|---------|-----------| +| Resources | `/users` | `/getUsers` | +| Nested | `/users/123/orders` | `/getUserOrders` | +| Actions | `/users/123/activate` | `/activateUser` | +| Plural | Always plural | Singular | + +### HTTP Methods + +| Method | Usage | Response | +|--------|-------|----------| +| GET | Retrieve resource(s) | 200 + body | +| POST | Create new resource | 201 + body | +| PUT | Full replace | 200/204 + body | +| PATCH | Partial update | 200/204 + body | +| DELETE | Remove resource | 204 No Content | + +### Status Codes + +| Code | Usage | +|------|-------| +| 200 | GET success, PUT/PATCH success | +| 201 | POST create success | +| 204 | DELETE success, no body | +| 400 | Malformed request, validation error | +| 401 | Authentication required | +| 403 | Authenticated but forbidden | +| 404 | Resource not found | +| 409 | Conflict (duplicate, state violation) | +| 422 | Semantic validation error | +| 500 | Internal server error | + +### Response Envelope + +```go +type Response struct { + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +type Meta struct { + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` + Total int `json:"total,omitempty"` + NextPage int `json:"next_page,omitempty"` +} +``` + +### Request/Response Examples + +**Create User (POST /users):** + +Request: +```json +{ + "name": "John Doe", + "email": "john@example.com" +} +``` + +Response (201): +```json +{ + "data": { + "nip": "p021050", + "name": "John Doe", + "email": "john@example.com", + "is_active": true, + "created_at": "2024-01-15T10:00:00Z" + } +} +``` + +**List Users (GET /users):** + +Response (200): +```json +{ + "data": [ + {"nip": "p021050", "name": "John Doe", ...}, + {"nip": "p021051", "name": "Jane Doe", ...} + ], + "meta": { + "page": 1, + "per_page": 20, + "total": 2 + } +} +``` + +**Validation Error (400):** + +```json +{ + "error": "validation failed", + "meta": { + "fields": { + "email": "invalid email format" + } + } +} +``` + +--- + +## DDD Value Objects and Domain Events + +### Value Objects + +```go +// internal/core/domain/valueobject/nip.go +package valueobject + +type NIP string + +func (n NIP) String() string { + return string(n) +} + +func (n NIP) Validate() error { + if len(n) == 0 { + return errors.New("nip is required") + } + if len(n) > 20 { + return errors.New("nip must not exceed 20 characters") + } + return nil +} +``` + +```go +// internal/core/domain/valueobject/email.go +package valueobject + +type Email string + +func (e Email) String() string { + return string(e) +} + +func (e Email) Validate() error { + if len(e) == 0 { + return nil // Email can be empty + } + if !strings.Contains(string(e), "@") { + return errors.New("invalid email format") + } + return nil +} +``` + +### Domain Events + +```go +// internal/core/domain/event/event.go +package event + +type EventType string + +const ( + UserCreated EventType = "user.created" + UserUpdated EventType = "user.updated" + 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 +} + +func (e *UserCreatedEvent) Timestamp() time.Time { + return e.timestamp +} +``` + +**Event Publisher Interface:** + +```go +// internal/core/port/outbound/event.go +package outbound + +import ( + "context" + "myapp/internal/core/domain/event" +) + +type EventPublisher interface { + Publish(ctx context.Context, evt event.DomainEvent) error +} +``` + +--- + +## Docker and Docker Compose (Factor 10) + +### Dockerfile + +```dockerfile +# deployments/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" -o server ./cmd/server + +FROM alpine:3.19 + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /app +COPY --from=builder /app/server . +COPY --from=builder /app/db/migrations ./db/migrations + +EXPOSE 8080 + +ENV APP_ENV=production + +CMD ["./server"] +``` + +### Docker Compose + +```yaml +# deployments/docker-compose.yml +version: '3.8' + +services: + app: + build: .. + env_file: ../.env.local + depends_on: + postgres: + condition: service_healthy + keycloak: + condition: service_started + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 30s + timeout: 10s + retries: 3 + + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: messaging + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d messaging"] + interval: 10s + timeout: 5s + retries: 5 + + keycloak: + image: quay.io/keycloak/keycloak:24.0 + command: start-dev + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + ports: + - "8081:8080" + +volumes: + postgres_data: +``` + +--- + +## GitLab CI/CD (Factor 5) + +```yaml +# deployments/.gitlab-ci.yml +stages: + - build + - test + - release + - deploy + +variables: + IMAGE_NAME: $CI_REGISTRY_IMAGE/backend + +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 + before_script: + - apk add git make + - go install github.com/俚语/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 + url: https://api.example.com + script: + - kubectl set image deployment/backend server=$IMAGE_NAME:$CI_COMMIT_SHA + - kubectl rollout status deployment/backend + rules: + - main + when: manual +``` + +--- + ## go.mod Dependencies ```go @@ -2627,6 +3695,7 @@ require ( // Configuration github.com/golobby/env/v2 v2.2.4 github.com/golobby/dotenv v1.3.2 + github.com/hashicorp/vault/v2 v2.0.0 // Observability github.com/prometheus/client_golang v1.18.0 @@ -2638,6 +3707,9 @@ require ( github.com/MicahParks/keyfunc/v3 v3.0.0 github.com/golang-jwt/jwt/v5 v5.2.0 + // CLI + github.com/urfave/cli/v2 v2.27.0 + // Utilities github.com/pkg/errors v0.9.1 github.com/google/uuid v1.6.0