Session 11 ยท ๐Ÿ”€ Wiring

Dependency Injection, Request Flow & Config

Four layers that do not know about each other have to be connected somewhere. That somewhere is one function, run once at startup โ€” and following a single request through what it built is the clearest way to see whether the design works.

โœ“
11aconstructor injection
โœ“
11bone request, end to end
โœ“
11cerrors in one place
โœ“
11dconfig from the environment

11a

One request, all the way down and back

A patient books a slot another patient just took. Walk it with Next โ€” every layer from Sessions 05 to 10, in the order it actually runs.

โ†“Going down, the types get narrower. JSON becomes a request struct, becomes a model, becomes SQL. Each layer translates into the next layer's vocabulary and hands off.
โ†‘Coming back up, errors get wider. A driver code becomes a package sentinel, becomes an HTTP status. Each layer translates once, in the direction it owns.
ctxcontext.Context is the one thing passed unchanged through every layer. It carries cancellation, so a client that hangs up actually stops the database query instead of leaving it running.
โœ“No layer skips another. The handler never touches gorm, the repository never sees an http.Request. That is what makes each one testable on its own.

This trace is the answer to "where does my code go?" Anything that parses or renders HTTP belongs in the top box, anything that decides whether something is allowed belongs in the second, anything that writes SQL belongs in the third. There is no fourth option.

11b

The composition root: everything is wired once

Dependency injection in Go needs no framework. It is passing arguments to constructors, in one place, at startup.

cmd/api/main.go โ€” the only file that knows how the parts fit together
func main() {
    cfg, err := config.Load()              // 1. read the environment, or refuse to start
    if err != nil {
        log.Fatalf("config: %v", err)
    }

    db, err := postgres.Open(cfg.DatabaseURL) // 2. infrastructure
    if err != nil {
        log.Fatalf("database: %v", err)
    }

    // 3. build inward-out: repository โ†’ service โ†’ handler
    apptRepo := postgres.NewAppointmentRepo(db)
    apptSvc  := service.NewAppointmentService(apptRepo, service.SystemClock{})
    apptAPI  := httpx.NewAppointmentHandler(apptSvc)

    r := chi.NewRouter()
    r.Use(httpx.RequestID, httpx.Recoverer)
    r.Post("/appointments", apptAPI.Book)

    srv := &http.Server{Addr: cfg.Addr, Handler: r,
                        ReadTimeout: 5 * time.Second}
    log.Fatal(srv.ListenAndServe())
}
โœ“Each constructor takes what it needs and returns something usable. NewAppointmentService(repo, clock) โ€” the service can never exist in a half-built state, because there is no setter to forget.
โœ“Swapping an implementation is a one-line edit here, and nothing else changes. That is the entire payoff of Session 10's interfaces, and it only shows up at the wiring point.
โœ—A package-level var db *gorm.DB is the alternative, and it is worse. Every package that touches it becomes untestable in isolation, initialisation order turns implicit, and two tests can never use different databases.
โ†’You do not need wire, fx or dig. They generate or reflect this function for you. At this size the handwritten version is shorter than the configuration the tools would need.

11c

Errors decided in one place, config read in one place

Two small files that stop the same decision being made in forty handlers.

internal/transport/http/errors.go โ€” the only place a status code is chosen
var statusFor = map[error]int{
    service.ErrPastSlot:   http.StatusBadRequest,   // 400
    service.ErrNotFound:   http.StatusNotFound,     // 404
    service.ErrSlotTaken:  http.StatusConflict,     // 409  โ† session 03 โ†’ 09 โ†’ here
    service.ErrDoctorGone: http.StatusUnprocessableEntity,
}

func writeError(w http.ResponseWriter, r *http.Request, err error) {
    for sentinel, code := range statusFor {
        if errors.Is(err, sentinel) {          // Is, so wrapping still works
            writeJSON(w, code, errBody{Error: err.Error()})
            return
        }
    }
    // unclassified โ†’ it is OUR fault, and it gets logged with the request id
    log.Error("unhandled", "err", err, "request_id", RequestIDFrom(r.Context()))
    writeJSON(w, http.StatusInternalServerError, errBody{Error: "internal error"})
}

// every handler is now this short
func (h *AppointmentHandler) Book(w http.ResponseWriter, r *http.Request) {
    var req bookRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, r, ErrBadJSON); return
    }
    a := req.toModel()
    if err := h.svc.Book(r.Context(), &a); err != nil {
        writeError(w, r, err); return
    }
    writeJSON(w, http.StatusCreated, fromModel(a))
}
internal/config/config.go โ€” read once, validated, or the process does not start
type Config struct {
    Addr        string
    DatabaseURL string
    JWTSecret   string
}

func Load() (*Config, error) {
    c := &Config{
        Addr:        env("ADDR", ":8080"),        // optional, has a default
        DatabaseURL: os.Getenv("DATABASE_URL"),   // required
        JWTSecret:   os.Getenv("JWT_SECRET"),     // required
    }
    var missing []string
    if c.DatabaseURL == "" { missing = append(missing, "DATABASE_URL") }
    if c.JWTSecret == ""   { missing = append(missing, "JWT_SECRET") }
    if len(c.JWTSecret) < 32 && c.JWTSecret != "" {
        return nil, errors.New("JWT_SECRET must be at least 32 bytes")
    }
    if len(missing) > 0 {
        return nil, fmt.Errorf("missing required env: %s", strings.Join(missing, ", "))
    }
    return c, nil
}

func env(key, def string) string {
    if v := os.Getenv(key); v != "" { return v }
    return def
}
โœ“Fail at startup, not at 3am. A missing DATABASE_URL should stop the process immediately with a message naming the variable โ€” not surface as a nil-pointer panic on the first request that needs it.
โœ“Only config calls os.Getenv. Everything else receives finished values. That is what makes the rest of the code testable without setting environment variables in tests.
โ†’Commit .env.example, never .env. And if a real one is ever committed, deleting it is not enough โ€” it stays in the git history, so the secret must be rotated.
โ†’The map lookup uses errors.Is, not equality, so a service that wraps its error with %w still maps to the right status. Equality would silently degrade every wrapped error to a 500.
Mistake

Returning the raw error text to the client for unclassified failures. pq: relation "appointments" does not exist tells an attacker your schema and your driver. Classified errors carry a message you wrote on purpose; everything else is "internal error" plus a log line with the request id.

TASK

Wire the whole thing and prove the boundaries hold

(a) Write main.go as a composition root that builds config โ†’ db โ†’ repo โ†’ service โ†’ handler, with no package-level variables. (b) Add ErrSlotTaken to the status map and show, with a test, that a booking conflict returns 409 and not 500 โ€” including when the service wraps the error with %w. (c) Delete DATABASE_URL from your environment and confirm the process refuses to start with a message naming the variable. (d) Run go list -deps ./internal/service and confirm gorm.io/gorm does not appear.

Recap

Session 11 in four lines

1Dependency injection in Go is constructors taking arguments, called once in main โ€” no framework required.
2A request narrows on the way down (JSON โ†’ model โ†’ SQL) and widens on the way back (23505 โ†’ ErrSlotTaken โ†’ 409), translating once per layer.
3One writeError maps sentinels to statuses with errors.Is; anything unclassified is your fault, logged, and returned as a generic 500.
4Config is read once at startup, validated, and refuses to boot when required values are missing โ€” and only that package calls os.Getenv.