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

1
2
3
4
5
6
7
8
9
go-Api/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   └── handler/
│       └── health.go
├── go.mod
└── go.sum

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

1
2
3
4
cmd/
├── server/    → HTTP server binary
├── worker/    → background job binary (future expansion)
└── migrate/   → DB migration binary (future expansion)

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

1
2
3
4
internal/
├── handler/      → equivalent to .NET Controller
├── service/      → equivalent to .NET Service (to be added)
└── repository/   → equivalent to .NET Repository (to be added)

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:

1
internal class UserService { }  // only accessible within the same assembly

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

1
2
3
4
5
6
7
8
9
mkdir my-project && cd my-project

go mod init github.com/yourname/my-project   # generates go.mod

# create directory structure, write code

go get github.com/go-chi/chi/v5              # add dependency, updates go.mod, generates go.sum

go mod tidy                                  # remove unused dependencies, add missing ones

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package handler

import (
    "encoding/json"
    "net/http"
)

func Health(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

cmd/server/main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
    "log"
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/lou/go-api/internal/handler"
)

func main() {
    r := chi.NewRouter()

    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)

    r.Get("/healthz", handler.Health)

    log.Println("server starting on :8080")
    if err := http.ListenAndServe(":8080", r); err != nil {
        log.Fatalf("server failed: %v", err)
    }
}

/healthz Explained

A standard endpoint in the industry, used for:

  • Docker: HEALTHCHECK instruction 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:

1
func(http.ResponseWriter, *http.Request)

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

1
2
3
4
5
// Go
map[string]string{"status": "ok"}

// C# equivalent
new Dictionary<string, string> { { "status", "ok" } }

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(), not github.com/go-chi/chi/v5.NewRouter()

When name conflicts arise, you can add aliases:

1
2
3
4
import (
    chiMW "github.com/go-chi/chi/v5/middleware"
    myMW  "github.com/lou/go-api/internal/middleware"
)

Comparing Function Handlers and Class-Based Controllers

1
2
3
4
5
6
// Go: handler is just a function
func Health(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
1
2
3
4
5
6
7
8
9
// .NET: requires class + attribute
[ApiController]
[Route("api/[controller]")]
public class HealthController {
    [HttpGet]
    public IActionResult Get() {
        return Ok(new { status = "ok" });
    }
}

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

1
2
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
  • Logger: automatically logs every incoming request; compared to .NET’s app.UseHttpLogging()
  • Recoverer: if a handler panics, prevents the entire server from crashing and automatically returns a 500; compared to .NET’s app.UseExceptionHandler()

Local Testing

1
2
3
4
5
6
7
8
# start server
go run ./cmd/server/

# test in another terminal
curl http://localhost:8080/healthz

# expected response
{"status": "ok"}

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