Building a Minimal HTTP Server in Go: Project Structure and Chi Router
DevOps Learning Notes
Build a minimal Go HTTP server from scratch that follows North American industry standards, covering project structure and chi router usage
My background is as a .NET Engineer, so these notes use .NET analogies to help clarify Go’s design choices
If you also come from a .NET background, these comparisons should be helpful
Project Structure
Overall Layout
|
|
The cmd/ Directory
Go requires every executable program to have a package main and a main() function. cmd/ is a Go community convention for organizing the entry points of binaries this repo will produce
|
|
What is a binary? It is the single executable file produced after Go compilation, which runs directly without any runtime. Compared to .NET’s MyApp.dll (which requires a runtime), a Go binary is fully self-contained. Only files with package main produce a binary; code in internal/ is compiled into it, not standalone
main.go is only responsible for reading configuration, wiring up components, and starting the server — no business logic here
Compared to .NET:
| Go | .NET |
|---|---|
cmd/server/main.go |
Program.cs |
The internal/ Directory
Go’s language-level encapsulation mechanism. Packages inside internal/ can only be imported by code within the same module; external modules cannot use them at all
|
|
internal/ itself is not a Service or a Repository; it is just an encapsulation boundary — how things are layered inside is up to you
.NET has a similar concept:
|
|
Go enforces the same thing through directory naming — the compiler directly blocks external imports
go.mod and go.sum
Purpose of These Files
| File | .NET Equivalent | Purpose |
|---|---|---|
go.mod |
*.csproj |
Defines module name, Go version, and dependencies |
go.sum |
packages.lock.json |
Hash of each dependency, ensuring packages have not been tampered with |
go.sum is auto-generated; you never write it by hand. Both files must be committed to git, so every team member gets exactly the same package versions and content
Setup Process
|
|
Selecting a Routing Library
In the Go backend industry, nearly everyone adds a lightweight router:
| Option | Characteristics |
|---|---|
chi |
Lightweight, fully compatible with net/http, widely used in industry |
gin |
Feature-rich, also very mainstream |
| Standard library | Fine for practice; industry almost always adds a router |
chi is a thin wrapper over net/http, closest to Go idioms, and matches real-world industry practice
Implementation
internal/handler/health.go
|
|
cmd/server/main.go
|
|
/healthz Explained
A standard endpoint in the industry, used for:
- Docker:
HEALTHCHECKinstruction periodically hits this endpoint to confirm the container is alive - Kubernetes: liveness probe / readiness probe
- Load balancer: confirms the instance can receive traffic
The response is just 200 OK + {"status": "ok"}
Go-Specific Questions
Why r Uses Reference Semantics (*http.Request)
Go passes parameters by value by default, copying the entire data. http.Request is a large struct containing headers, body, URL, context, and more; passing a pointer only copies an 8-byte memory address instead of duplicating the data
Additionally, middleware may modify the request (e.g., adding context), and using a pointer ensures those modifications are visible to all subsequent handlers
http.ResponseWriter has no * because it is already an interface — interfaces in Go are internally pointers, so no extra indirection is needed
Why No Unused-Variable Error for r?
Go does not allow declaring a variable and then not using it, but function parameters are an exception. r *http.Request must be present because chi requires the handler’s function signature to match this format:
|
|
This is a contract, similar to a .NET interface. chi does not care whether you use r; it only checks that the function signature matches
map[string]string Syntax
Go’s map syntax: map[KeyType]ValueType
|
|
When serialized to JSON, this becomes {"status": "ok"}
Must Every File Declare Imports?
Go has no global imports; every file must declare its own dependencies. This is an intentional design choice so that each file’s dependencies are immediately visible
- Standard library uses short paths:
"net/http","encoding/json" - Third-party packages use full paths:
"github.com/go-chi/chi/v5" - When using a package, you only use the last segment as the package name:
chi.NewRouter(), notgithub.com/go-chi/chi/v5.NewRouter()
When name conflicts arise, you can add aliases:
|
|
Comparing Function Handlers and Class-Based Controllers
|
|
|
|
Go has no classes; a handler is just a function matching a specific signature. Route configuration is managed centrally in main.go, not scattered across controllers via attributes
Request Pipeline Interceptors
|
|
Logger: automatically logs every incoming request; compared to .NET’sapp.UseHttpLogging()Recoverer: if a handler panics, prevents the entire server from crashing and automatically returns a 500; compared to .NET’sapp.UseExceptionHandler()
Local Testing
|
|
References
- Go Documentation — official documentation, covering language specification and standard library
- Go Modules Reference — complete reference for go.mod, go.sum, and dependency management
- Standard Go Project Layout — community-curated Go project structure conventions, origins of
cmd/,internal/, and other directories - chi router — lightweight HTTP router, fully compatible with
net/http