How I write HTTP services in Go after 13 years
Go developers are debating how to structure HTTP services so they’re both testable and maintainable, focusing on patterns like minimizing logic in `main`, passing dependencies explicitly into handlers, and avoiding oversized “god” config or server structs. Many favor small, composable functions and explicit dependency injection (sometimes via libraries like Uber’s fx), while others warn against over‑engineering and prefer simpler, more script‑like mains for bespoke services. A recurring theme is using types and parsing (e.g., “parse, don’t validate”) to encode invariants and reduce bugs, rather than relying on ad hoc validation scattered throughout the code.
Structure of main and testability
- Many like a tiny
main()that delegates to arun()function so errors can be returned and most logic becomes testable and reusable. - Others prefer a “flat script” style
main.gofor internal services, keeping top-level control flow visible rather than pushed into helpers. - Consensus: keep untestable/bootstrap code small, but not at the expense of readability;
main()itself cannot be called from tests, which motivates indirection.
Handlers, dependencies, and DI
- Strong support for explicit dependencies: handlers should take what they need via arguments or per-handler structs, not hidden on a giant server struct.
- Some want “brutal clarity” even if it means many parameters; others bundle deps in context/env structs or handler structs to avoid huge signatures.
- There’s tension between simplicity and coupling: big “god” server structs and constructors with many args smell like over-coupling.
Configuration and options patterns
- Passing a mutable global config object around is widely criticized; it couples subsystems and can cause subtle ordering bugs.
- Preferred patterns: immutable or “frozen” config; copying config into internal fields; per-package config structs; and “functional options” or option structs for constructors.
- Concern: too-clever options patterns add boilerplate and hurt discoverability; simple config structs often win long term.
Validation vs “parse, don’t validate” and types
- Many advocate wrapping primitives (e.g.,
Username) in dedicated types constructed via parsing/validation to make illegal states unrepresentable. - Debate about Go’s zero values: they undermine guarantees when structs can be instantiated without going through constructors.
- Some see this pattern as overengineering or cumbersome in Go; others see it as essential to avoid “stringly typed” bugs.
Testing strategies
- Several favor end-to-end-style tests that spin up a test server with fake dependencies, exercising HTTP and middleware paths.
- Others focus on unit-testing handler logic by injecting dependencies and using small helper functions.
Spec-first and code generation
- Some participants like OpenAPI-first workflows with generators (e.g., ogen, oapi-codegen), to avoid repetitive validation and routing code.
- Others find OpenAPI authoring tedious but note that tooling and LLM-assisted authoring help, and that IDLs (including gRPC/gateway) can be productive.
Dependency injection frameworks
- Lightweight DI frameworks (e.g., fx) are praised for handling complex initialization/shutdown graphs.
- Skeptics argue DI frameworks add indirection; they prefer explicit construction in
main, claiming acyclic graphs and manual teardown remain manageable.