Stage 7 · Master
Project Foundations & Workspace Layout
12-Factor Config & Structured Logging
The listen address is hardcoded and log.Println doesn't say which request failed. Two small, real problems — two small, focused packages.
Two Problems With What We Have
Two real gaps exist in the code from the last two lessons. First: :8080 is hardcoded in main.go. That's fine for a laptop, but the moment this runs anywhere else — a container, CI, a teammate's machine with 8080 already taken — it becomes a problem with no way to fix it besides editing source. Second: log.Println("fieldwork-api listening on :8080") is a plain string with no level, no timestamp field, and nothing that would let you filter or search it once there's more than one log line being produced. Both problems are small right now. Both get expensive to retrofit once the codebase is bigger. This lesson fixes both, and only both — nothing more.
The relevant idea from the 12-Factor App methodology is just this: config that varies between environments (ports, database URLs, log verbosity) should come from the environment, not be baked into source. That's the whole scope of what we're borrowing from it right now.
Reading Config From the Environment
There are exactly two settings worth externalizing today: the listen address, and the log level. That's it — not a speculative Config struct with fields for a database URL or a JWT secret that don't exist yet in this codebase. Those fields get added to this struct in the lessons that introduce the things they configure, not now.
internal/config/config.gocreateResponsibility: Reads HTTP_LISTEN_ADDR and LOG_LEVEL from the environment, with sane local defaults.
Why now: main.go now has two things that vary by environment. That's the exact trigger — same test as internal/httpserver: a concern that main.go shouldn't own directly gets its own package.
internal/logging/logging.gocreateResponsibility: Builds a single, consistently-configured Zap logger for the process.
Why now: log.Println can't be filtered by level or searched by field. Zap is a well-established, fast structured logger — reaching for it now that logging is worth doing properly, not before.
internal/httpserver/server.gomodifyResponsibility: Now also logs each request's method, path, and status using the injected logger.
Why now: This is the first place a request actually happens — the natural place to prove the logger is wired correctly end to end.
Connects to: Receives the *zap.Logger built in cmd/api/main.go, rather than constructing its own.
cmd/api/main.gomodifyResponsibility: Loads config, builds the logger, passes both into httpserver.New(...).
Why now: main is still just wiring — it now wires two more concrete things together, but its job hasn't changed.
go.modmodifyResponsibility: Adds the go.uber.org/zap dependency.
Why now: First third-party dependency this codebase has needed — added exactly when the first thing that requires it (structured logging) shows up.
Structured Logging With Zap
internal/httpserver now takes a logger as a constructor argument instead of reaching for a global. That's a small but deliberate choice: it means httpserver never has to know how the logger was built, and it means tests (Module 7) can hand it a no-op logger without any global state to reset between tests.
Wiring It Into main
What We Are Not Doing Yet
No request-ID middleware, no tenant_id log field, no shared 'log envelope' contract across services. Those all require things that don't exist yet: tenant_id needs multi-tenancy (Module 5), a request ID that survives a downstream call needs a second service to call (Module 6). Adding those fields now would mean inventing values with nothing behind them — which is worse than not having the field, because it looks real in the logs when it isn't.
| Log field | Added now? | What would justify it |
|---|---|---|
| method, path, status | Yes | The one HTTP request path that exists today |
| request_id | No | Module 6, once a request can cross a service boundary and needs to be traced across it |
| tenant_id | No | Module 5, once tenants exist as a concept in this codebase |
| service, version | No | Module 6, once a second service exists and log lines need to say which one they came from |
The Decision
- internal/config reads exactly the two settings this service currently has: listen address and log level.
- internal/logging wraps Zap behind one New(level) constructor — no global logger, no package-level state.
- httpserver takes the logger as a parameter, keeping it independent of how the logger was built.
- Fields like request_id and tenant_id are deferred to the modules that actually give them meaning — a log field with no real value behind it is worse than no field at all.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.