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.
11a
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.
context.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.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
Dependency injection in Go needs no framework. It is passing arguments to constructors, in one place, at startup.
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()) }
NewAppointmentService(repo, clock) โ the service can never exist in a half-built state, because there is no setter to forget.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.11c
Two small files that stop the same decision being made in forty handlers.
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)) }
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 }
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.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..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.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.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.
(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
main โ no framework required.23505 โ ErrSlotTaken โ 409), translating once per layer.writeError maps sentinels to statuses with errors.Is; anything unclassified is your fault, logged, and returned as a generic 500.os.Getenv.