So, you've decided to write a REST API in Go. Maybe it's a small internal service. Maybe it's a side project that you secretly hope will pay rent one day. You open a fresh repo, type go mod init, and then immediately freeze, because you remember the last Go service you saw on Twitter.

That one had a cmd/ directory and an internal/ directory and a pkg/ directory. It had a domain layer and an application layer and an infrastructure layer and a transport layer. It had interfaces for everything, mocks for everything, and a 600-line wire_gen.go that nobody could explain. The actual business logic was maybe forty lines spread across nine files.

That's the trap. Go's standard library is genuinely close to enough for a serious REST API, and the ecosystem around it is small and well-mannered. But the moment people start writing one, the part of the brain that watched too many "clean architecture" talks takes over, and you end up with a service that takes a week to onboard somebody onto, for an API that has six endpoints.

This article is the version of that walk you actually want. We're going to build up a REST API in Go layer by layer, starting from raw net/http, adding routing when we feel it, looking at chi and Gin honestly, slotting validation in where it belongs, and writing middleware that you can read. No DDD ceremony, no services/handlers/use-cases/repositories/factories choreography. Just code.

We'll cover:

  • what net/http actually gives you (more than you think),
  • why chi is usually the right next step,
  • when Gin earns its keep, and when it doesn't,
  • validating request bodies without becoming a Java shop,
  • middleware patterns that compose cleanly,
  • error handling and consistent JSON responses,
  • and a project layout that doesn't grow tentacles.

Crack your knuckles. We're starting at zero.

net/http is more than a starter kit

The first instinct when you Google "Go REST API" is to immediately install a framework. Don't. Open the standard library and look at what you already have.

A working HTTP server in Go is this much code:

Go main.go
package main

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

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

    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })

    log.Println("listening on :8080")
    if err := http.ListenAndServe(":8080", mux); err != nil {
        log.Fatal(err)
    }
}

That's a real REST endpoint. It binds to a port, serves JSON, and the GET /health method-and-path pattern works because http.ServeMux got proper method-aware routing in Go 1.22. You no longer need a third-party router for the very basic cases.

Let's add a couple more endpoints to make this feel like an actual service. We'll do a tiny in-memory user store, because it's the most embarrassingly common example in HTTP demos and there's a reason for that. It forces you to deal with every shape of request you'll ever care about: list, get, create, update, delete.

Go main.go
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
    "sync"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

type Store struct {
    mu     sync.RWMutex
    users  map[int]User
    nextID int
}

func (s *Store) List() []User {
    s.mu.RLock()
    defer s.mu.RUnlock()
    out := make([]User, 0, len(s.users))
    for _, u := range s.users {
        out = append(out, u)
    }
    return out
}

func (s *Store) Create(name string) User {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.nextID++
    u := User{ID: s.nextID, Name: name}
    s.users[u.ID] = u
    return u
}

func main() {
    store := &Store{users: map[int]User{}}
    mux := http.NewServeMux()

    mux.HandleFunc("GET /users", func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, store.List())
    })

    mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
        var body struct {
            Name string `json:"name"`
        }
        if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
            return
        }
        u := store.Create(body.Name)
        writeJSON(w, http.StatusCreated, u)
    })

    mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(r.PathValue("id"))
        if err != nil {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad id"})
            return
        }
        store.mu.RLock()
        u, ok := store.users[id]
        store.mu.RUnlock()
        if !ok {
            writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
            return
        }
        writeJSON(w, http.StatusOK, u)
    })

    log.Println("listening on :8080")
    if err := http.ListenAndServe(":8080", mux); err != nil {
        log.Fatal(err)
    }
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(v)
}

Read that for a second. There's no magic. The method routing, the path parameter via r.PathValue("id"), the JSON helpers, all of it is standard library. You could ship this. It would work. Most internal services are not more complicated than this in the part that actually matters; the rest is plumbing.

Now, two honest caveats. The standard ServeMux has a few things it won't do well:

  • It doesn't compose subroutes nicely. If you want to mount /api/v1 and put a bunch of routes under it, you can do it, but the syntax is awkward and you end up writing your own helpers.
  • It doesn't support per-route middleware in any ergonomic way. You can wrap the whole mux, or wrap individual http.HandlerFunc values manually, but that gets tedious past a few routes.

That's where a router earns its place. Not because net/http is bad (it isn't), but because at the point you're juggling thirty routes and four middleware layers, you start writing the abstractions yourself, and you'll get them wrong twice before you settle on something that looks suspiciously like chi.

chi: net/http with the rough edges sanded off

The first proper router I'd reach for is go-chi/chi. It's small, it speaks the net/http Handler interface natively, and it doesn't ask you to learn a new programming model. Every chi route is just a handler. Every chi middleware is just a func(http.Handler) http.Handler. That compatibility means everything else in the Go ecosystem (httptest, httptrace, pprof, any middleware library) works without adapters.

Here's the same service in chi:

Go main.go
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
    "sync"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

func main() {
    store := &Store{users: map[int]User{}}

    r := chi.NewRouter()
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)

    r.Route("/users", func(r chi.Router) {
        r.Get("/", listUsers(store))
        r.Post("/", createUser(store))

        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", getUser(store))
            r.Put("/", updateUser(store))
            r.Delete("/", deleteUser(store))
        })
    })

    log.Println("listening on :8080")
    if err := http.ListenAndServe(":8080", r); err != nil {
        log.Fatal(err)
    }
}

func getUser(store *Store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad id"})
            return
        }
        store.mu.RLock()
        u, ok := store.users[id]
        store.mu.RUnlock()
        if !ok {
            writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
            return
        }
        writeJSON(w, http.StatusOK, u)
    }
}

Notice what changed and what didn't. The handlers still take (http.ResponseWriter, *http.Request). JSON still goes through encoding/json. Path parameters now come from chi.URLParam instead of r.PathValue, but the rest of the function is the standard library. The thing chi gave us is composition. r.Route("/users", ...) returns a sub-router, sub-routers can have their own middleware, and the code reads like a tree of the actual API surface.

That r.Use(middleware.Recoverer) is worth highlighting. It catches panics in any handler, returns a 500 to the client, and logs the stack trace. Without it, a single nil pointer in one endpoint kills the whole server. You almost always want it on. The bundled middleware package also gives you Timeout, Throttle, Compress, CleanPath, StripSlashes, and a dozen others, all real net/http middleware that you can use anywhere, not just inside chi.

The pattern of writing handlers as func(store *Store) http.HandlerFunc is the cleanest dependency-injection move you'll need for a long time. The closure captures whatever the handler needs (a store, a config, a logger) and returns the HandlerFunc chi wires up. No DI container. No constructor with twelve fields. The handler depends on whatever you closed over, full stop.

Diagram of how chi composes routers: a root chi.Router with RequestID, Logger, and Recoverer middleware, a /users sub-router with three child routes, and an /admin sub-router with its own AuthAdmin middleware and two child routes.

The other thing chi does well: subroutes can have their own middleware that only applies to them. Authenticated routes? Mount an /admin subrouter with r.Use(authMiddleware). Public health probes? Leave them on the root router. No if path == "/admin/..." branching inside one giant middleware function. The router is the place where "what runs for which routes" lives, and that keeps every individual piece of code small.

Gin: when the shortcut is worth the trade-off

Gin is the other framework people reach for, and honestly, it's fine. It's faster than chi in microbenchmarks, it has a built-in helper for almost every common thing (binding JSON, returning JSON, query params, cookies, file uploads, validation), and the syntax is shorter:

Go main.go
package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()

    r.GET("/users/:id", func(c *gin.Context) {
        id := c.Param("id")
        c.JSON(http.StatusOK, gin.H{"id": id, "name": "Ada"})
    })

    r.POST("/users", func(c *gin.Context) {
        var body struct {
            Name string `json:"name" binding:"required"`
        }
        if err := c.ShouldBindJSON(&body); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
            return
        }
        c.JSON(http.StatusCreated, gin.H{"name": body.Name})
    })

    _ = r.Run(":8080")
}

That's terse, and the terseness has costs. The handler signature isn't http.HandlerFunc. It's func(*gin.Context). That means any helper, middleware, or library written for net/http needs an adapter to plug into Gin. The ecosystem of native Gin middleware is large enough that you usually don't notice, but you've quietly stepped off the standard interface.

You also lose a bit of the explicit-feel that makes Go pleasant to debug. c.ShouldBindJSON reads the body, decodes JSON, runs validator tags, and returns one combined error. When something goes wrong, you have to peek at the error string to figure out which step failed. It's not a deal-breaker, but it's a different style.

Where Gin earns its keep:

  • You're prototyping fast and you don't want to write writeJSON helpers.
  • The team has used Gin before and knows its quirks.
  • You need its built-in binding and validation, and you don't want to wire up go-playground/validator yourself.

Where chi (or plain net/http) is the better call:

  • You want every handler to be a normal http.HandlerFunc so the rest of the Go world plugs in seamlessly.
  • You'll be writing custom middleware and you want it to be reusable outside this service.
  • You value keeping the dependency surface small, and chi has zero runtime dependencies.

There's no right answer here. I lean chi for production services because the interop story is worth more than the syntactic shortcut, but I've shipped Gin services that are perfectly happy. The mistake is treating the choice as religious. They're both fine. The thing that matters far more is what you do inside the handlers.

Validation: don't write if name == "" thirty times

Once you have an endpoint accepting JSON, the next problem is "the client sends garbage." You can catch some of it with the JSON decoder (a missing field stays at its zero value, a wrong type fails decoding), but most real validation is semantic. Name must be non-empty. Email must look like an email. Age must be between 0 and 130. Discount code must match a regex.

You have three reasonable options.

Option 1: Hand-rolled validation

For two or three fields, this is fine. Just write it:

Go
type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

func (r CreateUserRequest) Validate() error {
    if strings.TrimSpace(r.Name) == "" {
        return errors.New("name is required")
    }
    if !strings.Contains(r.Email, "@") {
        return errors.New("email is not valid")
    }
    if r.Age < 0 || r.Age > 130 {
        return errors.New("age must be between 0 and 130")
    }
    return nil
}

This scales worse than people expect, but it stays readable for a long time. The handler calls req.Validate() after decoding, and either returns a 400 with the message or moves on. No magic, no reflection. Easy to grep.

Option 2: go-playground/validator

For anything bigger than a toy API, go-playground/validator is the de facto choice. It's the same library Gin's binding: tag is built on, but you can use it standalone with chi or net/http:

Go validation.go
package main

import (
    "errors"
    "fmt"
    "strings"

    "github.com/go-playground/validator/v10"
)

var validate = validator.New(validator.WithRequiredStructEnabled())

type CreateUserRequest struct {
    Name  string `json:"name"  validate:"required,min=2,max=100"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age"   validate:"gte=0,lte=130"`
    Role  string `json:"role"  validate:"oneof=admin user guest"`
}

func validateStruct(v any) error {
    err := validate.Struct(v)
    if err == nil {
        return nil
    }
    var verrs validator.ValidationErrors
    if !errors.As(err, &verrs) {
        return err
    }
    msgs := make([]string, 0, len(verrs))
    for _, fe := range verrs {
        msgs = append(msgs, fmt.Sprintf("%s failed %q", fe.Field(), fe.Tag()))
    }
    return errors.New(strings.Join(msgs, "; "))
}

That's a reasonable setup. One package-level validate instance (the library caches reflection metadata, so you want a shared one). A small helper that turns the multi-error into a flat string. Tags on the struct that describe the rules in one place.

The handler now reads like this:

Go
func createUser(store *Store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req CreateUserRequest
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
            return
        }
        if err := validateStruct(req); err != nil {
            writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
            return
        }
        u := store.Create(req.Name)
        writeJSON(w, http.StatusCreated, u)
    }
}

The standard tags cover most validation needs: required, email, url, uuid, min, max, gte, lte, len, oneof, eqfield, nefield, and so on. When you need something custom, you register a function:

Go
validate.RegisterValidation("not_admin_email", func(fl validator.FieldLevel) bool {
    return !strings.HasSuffix(fl.Field().String(), "@admin.local")
})

And tag it: validate:"required,email,not_admin_email".

Option 3: Separate parse and validate steps

For more complicated request shapes (discriminated unions, polymorphic payloads, conditional rules), the cleanest thing is to decode into a plain DTO and then convert into a domain value with the rules in the conversion. That's mostly outside the scope of a "how to build a REST API" article, but it's worth knowing it exists: at some point validation becomes parsing, and the right move is to let a constructor return (DomainUser, error) instead of stacking more tags on a struct.

The decision tree I run in my head: small API → handwritten checks. Medium API → go-playground/validator. Anything where the request shape isn't really a struct → parse-into-domain functions.

Middleware that you can read

net/http middleware is the part of Go's web stack that took me longest to find pretty. The signature is the same in every router:

Go
func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // before
        next.ServeHTTP(w, r)
        // after
    })
}

You take a Handler, you return a new Handler that wraps it. That's it. The whole ecosystem agrees on this one shape, so middleware composes by simple function nesting.

A logging middleware that times the request:

Go middleware/logging.go
package middleware

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

type statusRecorder struct {
    http.ResponseWriter
    status int
    bytes  int
}

func (s *statusRecorder) WriteHeader(code int) {
    s.status = code
    s.ResponseWriter.WriteHeader(code)
}

func (s *statusRecorder) Write(b []byte) (int, error) {
    if s.status == 0 {
        s.status = http.StatusOK
    }
    n, err := s.ResponseWriter.Write(b)
    s.bytes += n
    return n, err
}

func Logging(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            rec := &statusRecorder{ResponseWriter: w}
            next.ServeHTTP(rec, r)
            logger.Info("http",
                "method", r.Method,
                "path", r.URL.Path,
                "status", rec.status,
                "bytes", rec.bytes,
                "duration_ms", time.Since(start).Milliseconds(),
            )
        })
    }
}

There's nothing here you couldn't have written yourself. The statusRecorder wraps http.ResponseWriter so we can read back the status code the handler chose. ResponseWriter doesn't expose it directly, so you have to intercept the WriteHeader call. Once you have that pattern in your toolbox, you can write a metrics middleware, a tracing middleware, a request-ID middleware, all with the same shape.

Authentication is the next one most teams need. A trivial bearer-token middleware:

Go middleware/auth.go
package middleware

import (
    "context"
    "net/http"
    "strings"
)

type ctxKey int

const userKey ctxKey = 1

type User struct {
    ID    string
    Email string
}

func RequireBearer(verify func(token string) (User, error)) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            h := r.Header.Get("Authorization")
            if !strings.HasPrefix(h, "Bearer ") {
                http.Error(w, "missing bearer token", http.StatusUnauthorized)
                return
            }
            token := strings.TrimPrefix(h, "Bearer ")
            u, err := verify(token)
            if err != nil {
                http.Error(w, "invalid token", http.StatusUnauthorized)
                return
            }
            ctx := context.WithValue(r.Context(), userKey, u)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func UserFrom(ctx context.Context) (User, bool) {
    u, ok := ctx.Value(userKey).(User)
    return u, ok
}

Two things to notice. First, the verify function is a parameter, not a global. That keeps the middleware testable: pass a fake verifier in tests, the real one in production. Second, the user goes through context.Context, with the key as an unexported type. That's the canonical way to pass per-request data through the chain without polluting function signatures. Handlers read it back with middleware.UserFrom(r.Context()).

Wiring middleware into chi is one line per middleware:

Go
r := chi.NewRouter()
r.Use(middleware.Logging(logger))

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireBearer(verifyToken))
    r.Get("/me", meHandler)
    r.Post("/posts", createPostHandler)
})

r.Get("/health", healthHandler) // public, no auth

The Group call creates a sub-router that inherits the parent's middleware but adds its own. Routes inside the group require auth; routes outside it don't. The shape of who-needs-what is right there in the routing tree, not buried in an if inside the auth middleware.

Errors and consistent JSON responses

Once you have a handful of endpoints, you'll feel the pull toward a single shape for error responses. Pick one early and stick to it. The exact format doesn't matter much. What matters is that every error in the API looks the same on the wire.

A reasonable shape:

JSON error response
{
  "error": {
    "code": "user_not_found",
    "message": "User 42 does not exist"
  }
}

A small helper plus a custom error type makes this consistent without ceremony:

Go apierr/apierr.go
package apierr

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

type Error struct {
    Status  int    `json:"-"`
    Code    string `json:"code"`
    Message string `json:"message"`
}

func (e *Error) Error() string { return e.Message }

func New(status int, code, message string) *Error {
    return &Error{Status: status, Code: code, Message: message}
}

var (
    ErrBadRequest   = New(http.StatusBadRequest, "bad_request", "Invalid request")
    ErrUnauthorized = New(http.StatusUnauthorized, "unauthorized", "Authentication required")
    ErrNotFound     = New(http.StatusNotFound, "not_found", "Resource not found")
    ErrInternal     = New(http.StatusInternalServerError, "internal", "Internal server error")
)

func Write(w http.ResponseWriter, err error) {
    var apiErr *Error
    if !errors.As(err, &apiErr) {
        apiErr = ErrInternal
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(apiErr.Status)
    _ = json.NewEncoder(w).Encode(struct {
        Error *Error `json:"error"`
    }{Error: apiErr})
}

Now a handler can return errors anywhere and end the function with a single apierr.Write:

Go
func getUser(store *Store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            apierr.Write(w, apierr.New(http.StatusBadRequest, "bad_id", "id must be an integer"))
            return
        }
        u, err := store.Find(id)
        if err != nil {
            if errors.Is(err, ErrUserNotFound) {
                apierr.Write(w, apierr.ErrNotFound)
                return
            }
            apierr.Write(w, err) // becomes 500
            return
        }
        writeJSON(w, http.StatusOK, u)
    }
}

The pattern scales because it's just errors.As doing the work. Domain errors that aren't *apierr.Error get coerced to 500. Domain errors that are decorated with apierr.New keep their status and code. You never lose information unless you choose to.

Some teams take this further with a func(w, r) error handler signature and a single wrapper that catches the returned error:

Go
type apiHandler func(w http.ResponseWriter, r *http.Request) error

func (h apiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := h(w, r); err != nil {
        apierr.Write(w, err)
    }
}

That removes the repeated if err != nil { apierr.Write(w, err); return }. It's a style choice. I've worked in both styles and both are fine. Pick one and don't mix them in the same service.

A project layout that doesn't grow tentacles

Here is where most Go REST API projects go off the rails. The community wrote a thousand "clean architecture" templates, and a generation of Go developers learned to start with this directory tree:

Text overengineered layout
.
├── cmd/server/main.go
├── internal/
│   ├── domain/
│   │   ├── user/
│   │   │   ├── user.go
│   │   │   └── repository.go
│   │   └── post/
│   │       ├── post.go
│   │       └── repository.go
│   ├── application/
│   │   ├── usecases/
│   │   │   ├── create_user.go
│   │   │   ├── get_user.go
│   │   │   └── list_users.go
│   │   └── ports/
│   ├── infrastructure/
│   │   ├── postgres/
│   │   │   └── user_repository.go
│   │   └── http/
│   │       ├── server.go
│   │       └── handlers/
│   └── interfaces/
└── pkg/

Twelve directories for an API that has six endpoints. Every "use case" is a struct with one method. Every repository has an interface and an implementation and a mock. You can't move a field without touching four files. The architecture is doing all the talking, and the actual feature is mute.

What I'd start with for a real REST service is closer to:

Text practical layout
.
├── cmd/server/main.go
├── internal/
│   ├── users/
│   │   ├── handlers.go
│   │   ├── store.go
│   │   └── types.go
│   ├── posts/
│   │   ├── handlers.go
│   │   ├── store.go
│   │   └── types.go
│   ├── apierr/
│   │   └── apierr.go
│   └── middleware/
│       ├── auth.go
│       └── logging.go
├── go.mod
└── go.sum

Group by feature, not by layer. internal/users/ contains the handlers, the store, and the types for users. If a new engineer joins and wants to add a "block user" endpoint, they open one directory and they're looking at every relevant piece. If you find later that the store needs an interface (because you're swapping Postgres for a fake in tests), add the interface in that directory at that moment, not preemptively.

cmd/server/main.go stays small. It builds the dependencies, wires the router, starts listening. Anything longer than 80 lines is usually a smell. Break out the router construction into a server package and call it from main:

Go cmd/server/main.go
package main

import (
    "log/slog"
    "net/http"
    "os"

    "github.com/you/yourservice/internal/server"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    h := server.New(logger)

    logger.Info("listening", "addr", ":8080")
    if err := http.ListenAndServe(":8080", h); err != nil {
        logger.Error("server stopped", "err", err)
        os.Exit(1)
    }
}

server.New returns an http.Handler. Inside, it builds the chi router, registers the middleware, mounts each feature's handlers. That's where users.Routes(r, store) and posts.Routes(r, store) get called. Each feature package exposes one Routes function that knows how to attach itself to a router. Nothing fancier than that is needed for a service of a few dozen endpoints.

Things you don't need yet

Here are abstractions that get added too early in almost every Go REST API I've reviewed. They're not wrong. They're just rarely worth their weight on day one.

  • A DI container. Constructor functions and closures cover dependency injection for the entire lifetime of most services. Wire and Dig exist for genuinely large codebases; you'll know when you've got one.
  • Custom logger interfaces. log/slog is in the standard library now. Use it directly. If you need to swap it out later, that swap is one find-and-replace.
  • A "service" layer between handlers and the store. If the handler is doing real business logic, extract a function. If it isn't, the service layer is just forwarding calls and adding line count.
  • Generic CRUD helpers. Every API thinks it has CRUD. Every API actually has weird per-endpoint quirks that the generic helper can't express. Write each handler. They're short.
  • An OpenAPI generator from your code. If you have a spec, generate the server from the spec. If you don't, you don't need one yet. Write a small handwritten openapi.yaml when an external consumer asks for it.
  • A custom router. chi is one dependency and 0 maintenance. Don't reinvent it.

The general rule: every layer of abstraction is paying a cost in code-reading time. The cost is real and constant. The benefit only kicks in when the abstraction is used, not when you imagine you'll use it.

Side-by-side comparison: on the left, an overengineered Go project tree with twelve nodes spread across cmd, internal/domain, internal/application, internal/infrastructure, and internal/interfaces; on the right, a practical tree with six nodes (cmd/server, internal/users, internal/posts, internal/apierr, internal/middleware), with an arrow pointing right labeled what you actually need to start.

A worked example: the full users service

Let's pull it together. Here's what a complete, honest users service looks like: chi, validator, slog, the apierr package, all the things we've talked about, none of the things we've talked about avoiding.

Go internal/users/types.go
package users

import "time"

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    CreatedAt time.Time `json:"created_at"`
}

type CreateRequest struct {
    Name  string `json:"name"  validate:"required,min=2,max=100"`
    Email string `json:"email" validate:"required,email"`
}

type UpdateRequest struct {
    Name string `json:"name" validate:"required,min=2,max=100"`
}
Go internal/users/store.go
package users

import (
    "errors"
    "sync"
    "time"
)

var ErrNotFound = errors.New("user not found")

type Store struct {
    mu     sync.RWMutex
    users  map[int]User
    nextID int
}

func NewStore() *Store {
    return &Store{users: map[int]User{}}
}

func (s *Store) List() []User {
    s.mu.RLock()
    defer s.mu.RUnlock()
    out := make([]User, 0, len(s.users))
    for _, u := range s.users {
        out = append(out, u)
    }
    return out
}

func (s *Store) Find(id int) (User, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    u, ok := s.users[id]
    if !ok {
        return User{}, ErrNotFound
    }
    return u, nil
}

func (s *Store) Create(name, email string) User {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.nextID++
    u := User{
        ID:        s.nextID,
        Name:      name,
        Email:     email,
        CreatedAt: time.Now(),
    }
    s.users[u.ID] = u
    return u
}

func (s *Store) Update(id int, name string) (User, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    u, ok := s.users[id]
    if !ok {
        return User{}, ErrNotFound
    }
    u.Name = name
    s.users[id] = u
    return u, nil
}

func (s *Store) Delete(id int) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    if _, ok := s.users[id]; !ok {
        return ErrNotFound
    }
    delete(s.users, id)
    return nil
}
Go internal/users/handlers.go
package users

import (
    "encoding/json"
    "errors"
    "net/http"
    "strconv"

    "github.com/go-chi/chi/v5"
    "github.com/go-playground/validator/v10"

    "github.com/you/yourservice/internal/apierr"
)

var validate = validator.New(validator.WithRequiredStructEnabled())

func Routes(r chi.Router, store *Store) {
    r.Route("/users", func(r chi.Router) {
        r.Get("/", list(store))
        r.Post("/", create(store))
        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", get(store))
            r.Put("/", update(store))
            r.Delete("/", del(store))
        })
    })
}

func list(store *Store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, store.List())
    }
}

func create(store *Store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req CreateRequest
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            apierr.Write(w, apierr.New(http.StatusBadRequest, "bad_json", "Request body is not valid JSON"))
            return
        }
        if err := validate.Struct(req); err != nil {
            apierr.Write(w, apierr.New(http.StatusUnprocessableEntity, "validation_failed", err.Error()))
            return
        }
        u := store.Create(req.Name, req.Email)
        writeJSON(w, http.StatusCreated, u)
    }
}

func get(store *Store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            apierr.Write(w, apierr.New(http.StatusBadRequest, "bad_id", "id must be an integer"))
            return
        }
        u, err := store.Find(id)
        if errors.Is(err, ErrNotFound) {
            apierr.Write(w, apierr.ErrNotFound)
            return
        }
        if err != nil {
            apierr.Write(w, err)
            return
        }
        writeJSON(w, http.StatusOK, u)
    }
}

func update(store *Store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            apierr.Write(w, apierr.New(http.StatusBadRequest, "bad_id", "id must be an integer"))
            return
        }
        var req UpdateRequest
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            apierr.Write(w, apierr.New(http.StatusBadRequest, "bad_json", "Request body is not valid JSON"))
            return
        }
        if err := validate.Struct(req); err != nil {
            apierr.Write(w, apierr.New(http.StatusUnprocessableEntity, "validation_failed", err.Error()))
            return
        }
        u, err := store.Update(id, req.Name)
        if errors.Is(err, ErrNotFound) {
            apierr.Write(w, apierr.ErrNotFound)
            return
        }
        if err != nil {
            apierr.Write(w, err)
            return
        }
        writeJSON(w, http.StatusOK, u)
    }
}

func del(store *Store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            apierr.Write(w, apierr.New(http.StatusBadRequest, "bad_id", "id must be an integer"))
            return
        }
        if err := store.Delete(id); errors.Is(err, ErrNotFound) {
            apierr.Write(w, apierr.ErrNotFound)
            return
        } else if err != nil {
            apierr.Write(w, err)
            return
        }
        w.WriteHeader(http.StatusNoContent)
    }
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(v)
}
Go internal/server/server.go
package server

import (
    "log/slog"
    "net/http"

    "github.com/go-chi/chi/v5"
    chimw "github.com/go-chi/chi/v5/middleware"

    "github.com/you/yourservice/internal/middleware"
    "github.com/you/yourservice/internal/users"
)

func New(logger *slog.Logger) http.Handler {
    r := chi.NewRouter()
    r.Use(chimw.RequestID)
    r.Use(chimw.RealIP)
    r.Use(middleware.Logging(logger))
    r.Use(chimw.Recoverer)

    r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte(`{"status":"ok"}`))
    })

    store := users.NewStore()
    users.Routes(r, store)

    return r
}

Count the files. Five. Count the dependencies. chi, validator, slog (stdlib). Count the indirection. Handler closes over store, store mutates a map. That's the whole service. You can replace the in-memory store with a Postgres-backed one by writing a new users.Store whose methods hit the database. The handlers don't change at all.

You will absolutely need more than this in a real production service. You'll want graceful shutdown, request timeouts, a database connection pool, structured logging fields, metrics, tracing, OpenAPI documentation, and maybe two more middlewares your security team requires. Add each one when there's a reason to add it. The shape we have right now is the floor, not the ceiling, and the floor is more than enough for an enormous number of services.

The honest summary

Building a REST API in Go is not the place to be clever. The standard library is closer to enough than the internet wants you to believe. chi adds the routing ergonomics most teams want, without dragging in a framework. Gin is a fine alternative when you like its shortcuts more than you value the net/http interop. go-playground/validator covers structural validation; handwritten checks cover the rest. Middleware composes by simple function nesting and a context key. Errors flow through a small apierr package, and JSON responses look the same shape every time.

Most of the things people add on top of that (DI containers, layered architectures, generic CRUD frameworks) are answers to problems you might have in five years, paid for in code you have to read every day until then. Write the small version first. Add the next layer when it stops being a hypothesis and starts being a real pain. That's the difference between a Go API that's pleasant to maintain and one that you'll be apologising for at conferences.