В Go веб-слой строится на стандартной библиотеке net/http, а не на фреймворке. net/http даёт всё необходимое — интерфейс http.Handler, сервер, маршрутизацию, — но его штатный роутер скромен. Поэтому в UCP-стеке берут chi: тонкий роутер поверх net/http, полностью с ним совместимый.

net/http как основа

Всё в Go-вебе вращается вокруг одного интерфейса: http.Handler с методом ServeHTTP(w, r). Обработчики, middleware, роутеры — всё это http.Handler. Это и есть причина, по которой chi совместим со стандартной библиотекой: он не заменяет net/http, а надстраивается над ним.

Почему chi

Штатный http.ServeMux умеет базовую маршрутизацию, но не даёт удобных параметров пути, групп и middleware-цепочек из коробки. chi добавляет это, оставаясь «обычным» http.Handler — никакой своей экосистемы, никакого vendor lock-in. Роутер chi можно отдать в http.ListenAndServe как есть.

import "github.com/go-chi/chi/v5"

func newRouter(createProduct *CreateProductHandler) http.Handler {
    r := chi.NewRouter()

    r.Get("/products/{id}", createProduct.getOne)
    r.Post("/products", createProduct.create)

    return r
}

Параметры пути

Параметр объявляется в шаблоне фигурными скобками и достаётся через chi.URLParam.

func (h *ProductHandler) getOne(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    // ...
}

Преобразование id из строки в число и его проверку делают явно (Go не приводит типы за тебя) — это часть обработки запроса и валидации.

Группы и подроутеры

Маршруты одного домена группируют — общий префикс, общие middleware. chi даёт Route (вложенная группа) и Mount (подключение отдельного роутера).

func newRouter(products *ProductHandler, orders *OrderHandler) http.Handler {
    r := chi.NewRouter()
    r.Use(middleware.RequestID, middleware.Recoverer)

    r.Route("/products", func(r chi.Router) {
        r.Get("/", products.list)
        r.Post("/", products.create)
        r.Get("/{id}", products.getOne)
    })

    r.Mount("/orders", orders.Routes())

    return r
}

Route собирает группу прямо здесь; Mount подключает роутер, собранный в пакете домена (orders.Routes() возвращает свой chi.Router). Это держит маршруты домена рядом с его кодом — та же раскладка по доменам, что и в структуре проекта. Middleware навешивается на роутер или группу через Use.

Тонкий обработчик UCP

Обработчик в UCP остаётся тонким: разобрать запрос, вызвать Handler сценария, написать ответ. Бизнес-логики в нём нет.

func (h *ProductHandler) create(w http.ResponseWriter, r *http.Request) {
    var req CreateProductRequest
    if err := decodeJSON(r, &req); err != nil {
        writeError(w, http.StatusBadRequest, err)
        return
    }
    product, err := h.createProduct.Handle(r.Context(), req.toCommand())
    if err != nil {
        writeError(w, mapError(err), err)
        return
    }
    writeJSON(w, http.StatusCreated, productResponse(product))
}

Обработчик знает про HTTP, но не про правила: сценарий — в createProduct.Handle, собранном проводкой в main. Это та же граница «контроллер тонкий», что в Spring MVC, только без аннотаций. Дальше идут декодирование и кодирование JSON — и весь явный конвейер, который продукт-инженер держит под контролем, потому что видит его целиком.