A JSON endpoint in 40 lines, no framework
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
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())
}
@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.
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 your.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.Unmarshalon a body decodes. Encode errors after a 200 header are unrecoverable by design — you log them and move on. That is whyWriteHeaderordering 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
- Run it:
go run ./cmd/server(or paste into amain.go), thencurl -s localhost:8080/api/invoices. - Add
GET /api/invoices/{id}returning 404 when the id is unknown, usingr.PathValue("id"). No third-party router. - 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. - Break the JSON tag on purpose and confirm the response field name changes — feel how the compiler stays silent.
stored in this browser only