gopherpath
0%

A JSON endpoint in 40 lines, no framework

40 min · lesson 2 of 2 · #http · #net-http · #spring

Spring Boot is a runtime that reads annotations off your bytecode and assembles a servlet pipeline at startup. Go's net/http is 40 lines of library code you call directly. Nothing is discovered, nothing is proxied, nothing is reflected.

Count the lines. Then count the magic each version depends on.

The service

Go
package main

import (
	"encoding/json"
	"log/slog"
	"net/http"
	"time"
)

type Invoice struct {
	ID      string    `json:"id"`
	Amount  int64     `json:"amount_cents"`
	Issued  time.Time `json:"issued"`
}

func listInvoices(w http.ResponseWriter, r *http.Request) {
	invoices := []Invoice{{ID: "INV-1", Amount: 9900, Issued: time.Now()}}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	if err := json.NewEncoder(w).Encode(invoices); err != nil {
		slog.Error("encode response", "err", err)
	}
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /api/invoices", listInvoices)

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
	slog.Info("listening", "addr", srv.Addr)
	slog.Error("server stopped", "err", srv.ListenAndServe())
}
Java
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
    record Invoice(String id, long amountCents, Instant issued) {}

    @GetMapping
    public List<Invoice> list() {
        return List.of(new Invoice("INV-1", 9900, Instant.now()));
    }
}

// plus: @SpringBootApplication class, application.yml, spring-boot-starter-web,
// and a dispatcher servlet that maps @GetMapping by reflection at startup.
TypeScript
import { Hono } from 'hono';

type Invoice = { id: string; amountCents: number; issued: string };

const app = new Hono();
app.get('/api/invoices', (c) =>
  c.json<Invoice[]>([{ id: 'INV-1', amountCents: 9900, issued: new Date().toISOString() }]),
);
export default app;

What actually differs

Concern Spring Boot Go net/http
Routing @RequestMapping scanned at startup "GET /api/invoices" pattern registered by hand
Mapping @RestController + @ResponseBody you write the handler; you set the header
DI container + proxies pass arguments to a constructor
Startup ~1.5–4 s of classpath scanning ~2 ms, it is just a ListenAndServe
Memory 200–400 MB baseline ~10 MB
Failure mode bean wiring error at startup nil pointer at compile-adjacent obviousness

Two Go details worth internalising now, because they are the ones reviewers look for:

  • Method+path patterns are part of the mux since Go 1.22. "GET /api/invoices/{id}" gives you r.PathValue("id"). You do not need chi, gin, or gorilla for 90% of services. Start with the stdlib; add a router only when you can name the exact thing it fixes.
  • json.NewEncoder(w).Encode(v) streams; json.Unmarshal on a body decodes. Encode errors after a 200 header are unrecoverable by design — you log them and move on. That is why WriteHeader ordering matters.

Trap: struct tags are stringly-typed. json:"amountCents" silently renders a field nobody expects and no compiler will tell you. This is the one place where Go gives up type safety, and it is why contract tests exist (module 07).

Checkpoint

  1. Run it: go run ./cmd/server (or paste into a main.go), then curl -s localhost:8080/api/invoices.
  2. Add GET /api/invoices/{id} returning 404 when the id is unknown, using r.PathValue("id"). No third-party router.
  3. Add a middleware that logs method, path, and duration, written as func(http.Handler) http.Handler. You will reuse it in every module after this.
  4. Break the JSON tag on purpose and confirm the response field name changes — feel how the compiler stays silent.
stored in this browser only