Skip to content
JavaGo
Part VI · Building Services

JSON & HTTP APIs

encoding/json in place of Jackson, net/http routing in place of Spring MVC, request limits and error mapping, middleware, and calling other services safely.

Section 24 of 31 8 min read

The first thing most Java backend developers build in Go is an HTTP API. The standard library covers the essentials: encoding/json for serialisation and net/http for both servers and clients. Many production Go services use no web framework at all.

encoding/json: struct tags instead of annotations

Java — Jackson annotations
public class User {
    @JsonProperty("user_id")
    private Long userId;

    @JsonIgnore
    private String passwordHash;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private String nickname;

    private Instant createdAt; // needs JavaTimeModule
}
Go — struct tags
type User struct {
    UserID       int64     `json:"user_id"`
    PasswordHash string    `json:"-"`                  // never serialised
    Nickname     string    `json:"nickname,omitempty"` // omit when ""
    CreatedAt    time.Time `json:"created_at"`         // RFC 3339
}

// Only exported (capitalised) fields are serialised.
// No ObjectMapper, no modules to register.
Java — ObjectMapper
ObjectMapper mapper = new ObjectMapper();

String json = mapper.writeValueAsString(user);
User back = mapper.readValue(json, User.class);
Go — Marshal and Unmarshal
data, err := json.Marshal(user) // []byte
if err != nil { ... }

var back User
if err := json.Unmarshal(data, &back); err != nil { ... }
// Unknown JSON fields are ignored by default, like
// FAIL_ON_UNKNOWN_PROPERTIES = false.

Routing with net/http (Go 1.22+)

Java — Spring @RestController
@RestController
@RequestMapping("/api/messages")
class MessageController {
    @GetMapping("/{id}")
    Message get(@PathVariable String id) { ... }

    @PostMapping
    ResponseEntity<Message> create(
            @RequestBody @Valid NewMessage in) { ... }

    @DeleteMapping("/{id}")
    void delete(@PathVariable String id) { ... }
}
Go — ServeMux patterns
mux := http.NewServeMux()

// "METHOD /path/{wildcard}", matched most-specific first
mux.HandleFunc("GET /api/messages/{id}", h.Get)
mux.HandleFunc("POST /api/messages", h.Create)
mux.HandleFunc("DELETE /api/messages/{id}", h.Delete)

func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // the {id} segment
    ...
}

// A request with the wrong method gets 405 with an Allow
// header automatically. GET patterns also answer HEAD.

Reading requests and writing responses

small helpers every Go API ends up with
// Spring does these for you; in Go they are ~20 lines you own.
func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status) // headers must be set BEFORE this call
    if err := json.NewEncoder(w).Encode(v); err != nil {
        // Too late to change the status: the client already has it
        slog.Error("write response", "err", err)
    }
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, map[string]string{"error": msg})
}

func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // reject bodies > 1 MB
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // typo'd fields become 400s, not silent drops
    if err := dec.Decode(dst); err != nil {
        return fmt.Errorf("invalid JSON: %w", err)
    }
    return nil
}
mapping domain errors to status codes
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
    msg, err := h.messages.Find(r.Context(), r.PathValue("id"))
    switch {
    case err == nil:
        writeJSON(w, http.StatusOK, msg)
    case errors.Is(err, message.ErrNotFound):
        writeError(w, http.StatusNotFound, "message not found")
    case errors.Is(err, context.DeadlineExceeded):
        writeError(w, http.StatusGatewayTimeout, "timed out")
    default:
        // Log the details, return a generic message: never leak internals
        slog.ErrorContext(r.Context(), "get message", "err", err)
        writeError(w, http.StatusInternalServerError, "internal error")
    }
}

Middleware

Java — servlet Filter
@Component
public class TimingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req,
            HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        long start = System.nanoTime();
        chain.doFilter(req, res);
        long ms = (System.nanoTime() - start) / 1_000_000;
        log.info("{} {} {}ms",
            req.getMethod(), req.getRequestURI(), ms);
    }
}
Go — a function wrapping a Handler
func withTiming(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        slog.InfoContext(r.Context(), "request",
            "method", r.Method,
            "path", r.URL.Path,
            "duration", time.Since(start))
    })
}

// Compose by wrapping: the outermost runs first
handler := withRecovery(withTiming(withAuth(mux)))

One request's path

Errors become status codes

Fig. 24.1A request passes through each middleware in the order they are nested, then ServeMux matches the method and path pattern and calls the handler. The handler's switch plays the role of @ControllerAdvice, turning domain errors into status codes.

Server timeouts

the zero value means "no timeout at all"
srv := &http.Server{
    Addr:              ":8080",
    Handler:           handler,
    ReadHeaderTimeout: 5 * time.Second,  // slow-header (Slowloris) protection
    ReadTimeout:       15 * time.Second, // whole request, including body
    WriteTimeout:      30 * time.Second, // until the response is written
    IdleTimeout:       2 * time.Minute,  // keep-alive connections
}
// http.ListenAndServe(":8080", h) uses a server with NO timeouts.
// Fine for a demo, not for production.

Calling other services

Java — java.net.http.HttpClient
HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(2))
    .build();

HttpRequest req = HttpRequest.newBuilder(URI.create(url))
    .timeout(Duration.ofSeconds(5))
    .build();

HttpResponse<String> res =
    client.send(req, HttpResponse.BodyHandlers.ofString());
Go — http.Client
// Create once and reuse: it pools connections
var client = &http.Client{Timeout: 10 * time.Second}

func fetchUser(ctx context.Context, url string) (*User, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }
    resp, err := client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetch user: %w", err)
    }
    defer resp.Body.Close() // always, or the connection leaks

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("fetch user: unexpected status %s", resp.Status)
    }
    var u User
    if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
        return nil, fmt.Errorf("decode user: %w", err)
    }
    return &u, nil
}

Three http.Client mistakes to avoid

http.DefaultClient has no timeout. http.Get(url) uses it, and a hung server hangs your goroutine forever. Always use a client with a Timeout, and pass a context with NewRequestWithContext.

Non-2xx responses are not errors. client.Do only returns an error for transport failures; a 500 is a successful round trip. Check resp.StatusCode yourself.

Always close the body, even when you don’t read it. An unclosed body keeps the connection out of the pool.

↑ ↓ to navigateEnter to openEsc to close