Stage 7 · Master
Phase 2 — Organization Service
Service Bootstrap
Upgrade the Phase 1 placeholder to a real Gin engine backed by a live PostgreSQL connection pool — the last piece of scaffolding before organization-specific code begins.
From a Bare net/http Mux to a Real HTTP Engine
Phase 1's internal/server package built the smallest possible net/http.Server so the Folder Structure and Dependency Injection lessons could prove their points without a framework dependency. Every lesson from here through the end of this course builds real organization behavior, and that behavior needs route parameters, middleware chaining, and structured JSON binding — which is exactly what Gin exists to provide without writing that plumbing by hand.
Why Gin, Not net/http Alone or a Heavier Framework
net/http alone would mean hand-rolling route-parameter parsing, JSON binding, and middleware composition for every handler this course writes from the Handler Layer lesson onward — solvable, but a distraction from the topics those lessons actually teach. A heavier framework with built-in ORM or code generation would hide the SQL and repository decisions Phase 2 deliberately keeps explicit. Gin sits at the point on that spectrum this course needs: routing and binding conveniences, nothing that replaces a decision this course wants to teach directly.
Connecting the Database Pool at Boot, Not on the First Request
internal/db owns constructing and verifying the connection pool exactly once, at startup — not lazily on a service's first query. A pool that is merely constructed (pgxpool.New) but never pinged can hide a bad DSN or an unreachable database until the first real request arrives, at which point the failure surfaces as a confusing error deep inside a handler instead of a clear failure at boot.
// Package db owns constructing and verifying this service's PostgreSQL
// connection pool. Nothing outside cmd/api/main.go calls Connect directly —
// every other package receives the already-verified *pgxpool.Pool.
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Connect parses dsn, constructs a pool, and pings it with a bounded
// timeout before returning. A DSN that is syntactically invalid fails at
// pgxpool.New; a DSN that is well-formed but unreachable fails at Ping —
// both fail here, at boot, never inside a request handler.
func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("db: parsing connection string: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("db: pinging database: %w", err)
}
return pool, nil
}
Extending Config and the Composition Root
type Config struct {
Env string `env:"ORGANIZATION_ENV" env-default:"development"`
Port int `env:"ORGANIZATION_PORT" env-default:"8080"`
LogLevel string `env:"ORGANIZATION_LOG_LEVEL" env-default:"info"`
+ DatabaseURL string `env:"ORGANIZATION_DATABASE_URL" env-required:"true"`
}
Load itself stays unchanged: pkg/config's cleanenv wrapper sees env-required and fails startup when no database URL exists. Defaults remain declared beside the fields they govern instead of being scattered through parsing code.
-func New(addr string) *http.Server {
- mux := http.NewServeMux()
- mux.HandleFunc("/ping", pingHandler)
+func New(addr string, handler http.Handler) *http.Server {
return &http.Server{
Addr: addr,
- Handler: mux,
+ Handler: handler,
}
}A one-parameter change that Phase 1 predicted out loud: the dependency-injection lesson argued that a constructor's signature is its real interface, and that a new dependency should surface as a compile error at every call site. This is that prediction coming true. The server package now knows nothing about routing, which is why it never changes again for the rest of the course.
import (
+ "context"
"fmt"
+ "net/http"
"os"
"time"
+ "github.com/gin-gonic/gin"
"github.com/hoa-platform/backend/pkg/logging"
+ "github.com/hoa-platform/backend/services/organization/internal/db"
...
)
logger := logging.New("organization", cfg.Env, cfg.LogLevel)
+ connectCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ pool, err := db.Connect(connectCtx, cfg.DatabaseURL)
+ cancel()
+ if err != nil {
+ logger.Error().Err(err).Msg("database connection failed")
+ os.Exit(1)
+ }
+ defer pool.Close()
+ logger.Info().Msg("database connected")
+
+ engine := gin.New()
+ engine.Use(gin.Recovery())
+ // Temporary smoke route only — real routes and /healthz, /readyz arrive
+ // in the REST API Design and Health Checks lessons.
+ engine.GET("/ping", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"message": "pong"})
+ })
+
- srv := server.New(fmt.Sprintf(":%d", cfg.Port))
+ srv := server.New(fmt.Sprintf(":%d", cfg.Port), engine)
logger.Info().Str("addr", srv.Addr).Msg("listening")The shape Phase 1 established does not move: load config, build the logger, construct dependencies, hand them to a server, run it. Everything added here slots between existing lines rather than reorganising them — which is the practical payoff of having decided the composition root's structure before there was anything real to compose. Note the connect timeout is cancelled immediately after Connect returns, not deferred: it bounds the dial, not the pool's lifetime.
ORGANIZATION_ENV=development
ORGANIZATION_PORT=8080
ORGANIZATION_LOG_LEVEL=debug
ORGANIZATION_DATABASE_URL=postgres://hoa:hoa_dev_password@localhost:5432/hoa_organization?sslmode=disable
Two Distinct Failure Modes, Two Distinct Errors
A DSN with a typo in its scheme (postgress:// instead of postgres://) fails inside pgxpool.New, before any network activity — a parse error. A DSN that is syntactically correct but points at a database that is not running (Postgres stopped, wrong port) fails at pool.Ping instead — a connection error. Both are wrapped distinctly in Connect so the log line always tells you which category of problem to look for.
{"level":"error","service":"organization","env":"development","time":"...","error":"db: pinging database: failed to connect to `host=localhost user=hoa database=hoa_organization`: dial error (dial tcp [::1]:5432: connect: connection refused)","message":"database connection failed"}
Confirming Boot Succeeds End to End
Applied exercise
Confirm the pool actually closes on shutdown, not just the HTTP listener
server.Run already handles graceful HTTP shutdown from Phase 1. The database pool's defer pool.Close() in main is easy to add and easy to silently break.
- Add a temporary log line inside a deferred function, after pool.Close(), confirming it ran (for example logger.Info().Msg("pool closed")).
- Start the service, send SIGINT, and confirm both "shut down cleanly" and your temporary "pool closed" line appear, in the correct order relative to each other.
- Explain in a short note why pool.Close() is deferred in main rather than called inside db.Connect or inside server.Run.
- Remove the temporary log line.
Deliverable
A short note recording the order of the two shutdown log lines and the reasoning for where defer pool.Close() lives.
Completion checks
- The note correctly identifies that main is the composition root and therefore the only place that legitimately owns the pool's entire lifecycle.
- The temporary log line is removed before finishing.
Why does db.Connect call pool.Ping immediately after pgxpool.New, instead of letting the first real query discover connectivity problems?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.