Go for
Java Developers
Section 22

JSON & HTTP APIs

The first thing most Java backend developers build in Go is an HTTP API. This section covers the two things you reach for immediately: encoding/json for serialisation and net/http for routing — both in the standard library, no dependencies required.

encoding/json — struct tags replace annotations

Java — Jackson annotations
// Jackson reads annotations at runtime via reflection
@JsonProperty("user_id")
private Long userId;

@JsonIgnore
private String passwordHash;

@JsonInclude(JsonInclude.Include.NON_NULL)
private String nickname;
Go — struct tags
// Go struct tags — parsed at compile time, evaluated at runtime
type User struct {
    UserID       int64  `json:"user_id"`
    PasswordHash string `json:"-"`          // always omit
    Nickname     string `json:"nickname,omitempty"` // omit if zero value
    CreatedAt    time.Time `json:"created_at"`
}

// Tags are the only config — no annotation processor, no ObjectMapper setup.
omitempty omits the field when it holds the zero value: "" for strings, 0 for numbers, false for booleans, nil for pointers/slices/maps. Use a pointer (*string) if you need to distinguish between "field absent" and "field is zero".

Marshal and Unmarshal

Java — ObjectMapper
ObjectMapper mapper = new ObjectMapper();

// Serialise
String json = mapper.writeValueAsString(user);

// Deserialise
User user = mapper.readValue(jsonString, User.class);
Go — encoding/json
// Serialise (struct → JSON bytes)
data, err := json.Marshal(user)
if err != nil { ... }
// data = []byte(`{"user_id":1,"created_at":"..."}`)

// Deserialise (JSON bytes → struct)
var user User
if err := json.Unmarshal(data, &user); err != nil { ... }

Streaming encode/decode for HTTP

For HTTP request/response bodies, use the streaming encoder/decoder — it reads and writes directly from an io.Reader/io.Writer without buffering the full payload into a []byte first.

decode request body, encode response
func (h *Handler) CreateMessage(w http.ResponseWriter, r *http.Request) {
    var req CreateMessageRequest
    // Decode directly from r.Body — no intermediate []byte
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }

    msg, err := h.service.Create(r.Context(), req)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    // Encode directly into w — no intermediate []byte
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(msg)
}

net/http — the Handler interface

Java — Spring @RestController
@RestController
@RequestMapping("/api")
public class MessageController {

    @GetMapping("/messages/{id}")
    public ResponseEntity<Message> get(@PathVariable Long id) {
        // Spring handles: routing, JSON serialisation,
        // status code, Content-Type header
        return ResponseEntity.ok(service.findById(id));
    }
}
Go — http.Handler interface
// http.Handler is a single-method interface:
// type Handler interface { ServeHTTP(ResponseWriter, *Request) }

// HandlerFunc is a function adapter — same as implementing the interface
type MessageHandler struct{ service MessageService }

func (h *MessageHandler) Get(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")  // Go 1.22+ stdlib path variable

    msg, err := h.service.FindByID(r.Context(), id)
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(msg)
}

Routing with net/http (Go 1.22+)

Go 1.22 added method and wildcard routing to net/http.ServeMux. For many services the standard library is now sufficient — no Gin or chi required.

Go 1.22+ stdlib routing
handler := &MessageHandler{service: NewMessageService()}

mux := http.NewServeMux()

// "METHOD /path/{variable}" — Go 1.22+
mux.HandleFunc("GET /api/messages/{id}", handler.Get)
mux.HandleFunc("POST /api/messages", handler.Create)
mux.HandleFunc("DELETE /api/messages/{id}", handler.Delete)

// r.PathValue("id") retrieves the wildcard value inside handlers

srv := &http.Server{
    Addr:         ":8080",
    Handler:      mux,
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
}
srv.ListenAndServe()
Always set ReadTimeout and WriteTimeout on http.Server. The zero value means no timeout — a slow client can hold a goroutine open indefinitely. A common production default is 5s read, 10–30s write depending on expected response times.

Middleware — wrapping handlers

Java — Spring @Component filter / HandlerInterceptor
@Component
public class LoggingFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res,
                         FilterChain chain) throws IOException {
        long start = System.currentTimeMillis();
        chain.doFilter(req, res);
        log.info("{}ms", System.currentTimeMillis() - start);
    }
}
Go — handler wrapping
// Middleware is just a function: takes a Handler, returns a Handler
func withLogging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)  // call the inner handler
        slog.Info("request", "method", r.Method, "path", r.URL.Path,
            "duration", time.Since(start))
    })
}

// Stack middleware by nesting:
mux.Handle("/api/", withAuth(withLogging(apiHandler)))
Why Go does thisGo middleware is plain functions — no framework registration, no annotation scanning, no bean wiring. You compose them by wrapping: withAuth(withLogging(handler)). The innermost function is called last. This is the same pattern as Java's FilterChain, but without any framework machinery.

Full minimal API example

complete HTTP API — stdlib only, no dependencies
package main

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

type Message struct {
    ID   string `json:"id"`
    Body string `json:"body"`
}

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /api/messages/{id}", func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id")
        msg := Message{ID: id, Body: "hello"}
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(msg)
    })

    mux.HandleFunc("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
        var msg Message
        if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
            http.Error(w, "bad request", http.StatusBadRequest)
            return
        }
        // ... persist msg
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(msg)
    })

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }
    slog.Info("listening", "addr", srv.Addr)
    srv.ListenAndServe()
}
encoding/json/v2 is available as an experiment in Go 1.25+ (GOEXPERIMENT=jsonv2) with better performance and stricter semantics. As of Go 1.26 it is still experimental and not subject to the compatibility promise. Stick with encoding/json for production code until v2 stabilises.