---
title: "Subrouters и структура большого сервиса"
description: "Chi subrouter и структура проекта go: r.Route возвращает подроутер, Mount против Route, пакет handlers с Routes(), middleware на ветке и тихий дубль маршрута."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/19-subrouters-struktura/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

# Subrouters и структура большого сервиса

*Курс «Chi: роутер для Go HTTP-сервисов», урок 19 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/19-subrouters-struktura/*

Chi subrouter — это r.Route, который строит поддерево, наполняет его через функцию и возвращает chi.Router: на тридцати строках регистрации маршрутов приходит пора резать. Разберём структуру проекта go: Mount против Route по исходникам, пакет handlers с Routes(), middleware только на ветку через Group и живое место с тихим дублем, съевшим чужой хендлер.

Роутер сервиса доставки начинался скромно: r.Get("/healthz", ...) и пара хендлеров прямо в main.go. Через полгода файл разросся до 240 строк: /api/users, /api/orders, /api/payments, /api/admin — всё вперемешку, сверху стек из десяти middleware. Merge-конфликты в main.go стали ежедневным ритуалом: два разработчика добавляют хендлеры в один файл, git развозит их по разным углам, и кто-то дольше решает конфликт, чем пишет логику. Поиск «а где у нас ручка отмены заказа» — это Ctrl+F по 240 строкам, а переезд POST /orders/refund в пакет заказов — час осторожного копипаста с молитвой, что ничего не отвалится. Сигнал, что пора резать, простой: один r.Route распух на 30 строк, в нём больше трёх уровней вложенности, и файл трогает больше двух человек. Инструмент для разреза в chi есть — r.Route, который возвращает подроутер, — но перед ним надо договориться о раскладке, иначе вместо свалки в одном файле получится свалка в пяти.

### Chi subrouter: r.Route возвращает chi.Router

Сигнатура: r.Route(pattern string, fn func(r chi.Router)) — chi создаёт пустой подроутер, передаёт его в fn и монтирует на префикс. В [уроке про Route и Group](/courses/chi/lessons/07-groups-routes/) подроутеры строились в одном файле — здесь важно то, что Route ещё и возвращает значение. По исходникам chi v5.0.8 (mux.go) это три строки: subRouter := NewRouter(), fn(subRouter), mx.Mount(pattern, subRouter) — и return subRouter. Возвращённое значение имеет тип chi.Router: готовый, уже смонтированный подроутер, которым можно распорядиться — отдать функции-сборщику, навесить ещё middleware через .Use, вернуть из конструктора пакета. Второй факт из тех же исходников: при fn == nil chi паникует «attempting to Route() a nil subrouter» — забытая функция ловится на старте, а не в проде. И третий: Route — это не отдельный механизм, а сахар над Mount; чем именно они различаются, смотрим в исходниках подробнее.

### Режем /api на подроутеры

```go
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)

r.Route("/api", func(r chi.Router) {
        r.Route("/users", func(r chi.Router) {
                r.Get("/", listUsers)      // GET /api/users
                r.Get("/{id}", getUser)    // GET /api/users/{id}
        })
        r.Route("/orders", func(r chi.Router) {
                r.Get("/", listOrders)     // GET /api/orders
                r.Post("/", createOrder)   // POST /api/orders
        })
})
```

Параметр r во вложенных функциях затеняет внешний: внутри r.Get регистрирует маршрут уже на подроутере, поэтому путь / — это /api/users, а не корень сервиса. Классическая ловушка копипаста — перепутать вложенный r с внешним: маршрут уедет мимо префикса и тихо ответит 404. Общие middleware стоят на корне и просвечивают в обе ветки; веточные добавляются внутри Route тем же r.Use — про это отдельный блок ниже.

### Проверка: что возвращает Route

**Вопрос:** usersBranch := r.Route("/users", func(r chi.Router) { r.Get("/", list) }). Какой тип у usersBranch?

- ✅ chi.Router: Route возвращает собранный подроутер, уже смонтированный на /users
- Типа нет: Route ничего не возвращает
- *http.ServeMux: внутри chi живёт стандартный роутер
- error: Route сообщает о конфликтах путей через возвращаемую ошибку

По mux.go v5.0.8: subRouter := NewRouter(); fn(subRouter); mx.Mount(pattern, subRouter); return subRouter. Возврат — chi.Router, и им можно пользоваться: добавить Use, вернуть из функции-сборщика. А вот Mount возврата не имеет — он принимает готовый http.Handler и ничего не отдаёт обратно.

### Mount против Route: разница по исходникам chi

r.Mount(pattern string, handler http.Handler) — нижний уровень: он монтирует на префикс любой http.Handler. Свой chi.Mux, стандартный http.ServeMux, файловый сервер — в уроке про статику ([Mount и статика](/courses/chi/lessons/08-mount-statica/)) так подключался http.FileServer. Возврата у Mount нет — монтируемое поддерево уже собрано до вызова. r.Route — обёртка над ним: создаёт новый Mux, прогоняет через fn, зовёт Mount и возвращает результат. Три поведения Mount из исходников стоит знать. Первое: регистрация на занятый путь паникует — «attempting to Mount() a handler on an existing path»: chi проверяет дерево через findPattern до вставки. Второе: Mount вешает хендлер и на pattern, и на pattern+"/" — хвост со слэшем не теряется. Третье: если у смонтированного подроутера нет своего NotFound и MethodNotAllowed, Mount копирует родительские — единый формат ошибок из урока 15 доезжает в поддерево бесплатно.

### Структура проекта go: пакет handlers и Routes()

Раскладка, которая держит большие сервисы: на каждую сущность — свой пакет. handlers/users с listUsers, getUser и зависимостью-Store; handlers/orders с заказами и своим Store; main остаётся дирижёром. Контракт пакета — одна функция: Routes(r chi.Router) регистрирует маршруты сущности на переданное дерево, а зависимости принимает параметрами. Вариант второй — конструктор New() chi.Router: пакет строит собственное дерево со своим стеком, а main монтирует его через Mount. Routes проще и держит общий стек в одном месте; New даёт пакету автономность в middleware — полезно, когда модуль переезжает между сервисами. Файлы внутри пакета тоже раскладываются: routes.go с одной функцией-контрактом, handlers.go с хендлерами, model.go с типами. Структура проекта go здесь сводится к правилу «пакет не знает своего префикса»: /users вешает main, и переезд сущности на другой путь — правка одной строки в точке сборки, а не поиск по всему пакету.

### Пакет handlers/users: Routes поверх chi.Router

```go
package users

import (
        "net/http"

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

type Store interface {
        ByID(id string) (*User, error)
        List() ([]User, error)
}

// контракт пакета: префикс вешает вызывающий, пакет знает только свой хвост
func Routes(r chi.Router, store Store) {
        r.Get("/", list(store))    // GET /users
        r.Get("/{id}", get(store)) // GET /users/{id}
        r.Post("/", create(store)) // POST /users
}

// хендлеры-замыкания: зависимости не гуляют по глобальным переменным
func get(store Store) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                u, err := store.ByID(chi.URLParam(r, "id"))
                if err != nil {
                        http.Error(w, "нет пользователя", http.StatusNotFound)
                        return
                }
                render(w, u)
        }
}
```

Routes принимает общий chi.Router и не знает, на каком префиксе его смонтировали: пакет нельзя сломать переездом пути. Хендлеры — замыкания над Store: зависимости переданы явно, глобальных переменных нет, тест подсовывает фейковый Store и проверяет пакет изолированно. Такой расклад проверяется локальным Go командой go test ./handlers/users — пакет компилируется и тестируется без остального сервиса.

### Проверка: Mount или Route для пакета

**Вопрос:** Пакет admin построил собственный chi-роутер adminMux со своим стеком. Как подключить его в main?

- ✅ r.Mount("/admin", adminMux): Mount принимает любой http.Handler
- r.Route("/admin", adminMux): Route монтирует готовый роутер
- adminMux.Routes(r): вызов метода смонтирует поддерево
- r.Use(adminMux): middleware-стек и роутер — одно и то же

Для готового хендлера — Mount: он берёт любой http.Handler, включая чужой *chi.Mux. Route требует функцию-конструктор fn func(r chi.Router), а не готовое дерево. Метода Routes для монтирования нет — есть Routes() []Route, отдающий список маршрутов для интроспекции, и Use — для middleware, а не для поддеревьев.

### Приём → когда

| Приём | Что делает | Когда уместен |
| --- | --- | --- |
| r.Get / r.Post в main | маршрут прямо в корневое дерево | маленький сервис до десятка ручек, одна команда |
| r.Route("/api", fn) | поддерево с префиксом, своё Use внутри | группировка версий API и сущностей в одном файле |
| r.Mount("/users", usersMux) | монтирует готовый http.Handler на префикс | пакет собрал свой mux, подключается сторонний хендлер, статика |
| r.Group(func(r chi.Router){ r.Use(...) }) | ветка без префикса — только middleware | приватная зона в том же файле: авторизация на часть ручек |
| users.Routes(r, store) | пакет регистрирует маршруты на чужое дерево | раскладка по пакетам handlers/* с общим стеком в main |
| users.New() + r.Mount | пакет владеет своим деревом целиком | модуль с собственным стеком, переиспользуемый между сервисами |

### Что ответит chi на /api/users?

```go
r := chi.NewRouter()

r.Get("/api/users", legacyList) // пакет profiles
r.Get("/api/users", newList)    // пакет users: тот же путь и метод

// запрос: GET /api/users → ?
```

**Что выведет программа?**

- Панику при регистрации: путь занят
- ✅ Ответит newList: последняя регистрация тихо перезаписала legacyList
- Ответит legacyList: первая регистрация сильнее
- 405 Method Not Allowed: метод конфликтует

Дубликаты обычных Get/Post chi не проверяет: tree.InsertRoute доходит до setEndpoint — комментарий в исходниках говорит «insert or update» — и молча подменяет хендлер. Паника бывает только при повторном Mount на занятый префикс. Отвечает всегда последний зарегистрированный: legacyList мёртв с момента старта, и ни одного лога об этом не было.

> **Два пакета, один /api/users.** Пакет profiles и пакет users зарегистрировали GET /api/users — profiles на строке выше. chi молча оставил последний: хендлер profiles не отвечал никогда, с момента первого запуска. Отзывы о пользователях не приходили неделю: тесты обоих пакетов были зелёные, потому что каждый звал свой хендлер напрямую, без общего роутера. Защита — единственная точка сборки, где видны все монтирования, и «маршруто-тест»: прогон запроса по каждому пути смонтированной ветки со сверкой, что отвечает именно тот пакет. Перестановка двух строк в main не должна менять прод.

### Middleware на ветку: Group и Use

При резке на пакеты всплывает старый вопрос из урока 07: как дать одной ветке свой middleware, не заливая его на весь сервис. Ответ — r.Group(func(r chi.Router) { r.Use(...) }): по исходникам Group возвращает mx.With() — inline-роутер, который разделяет с родителем то же самое дерево, но несёт собственный стек. Внутри Group можно и Use, и маршруты, и даже Route — префикса Group не добавляет, это чистая middleware-зона. Гибрид для раскладки по пакетам: r.Group(func(r chi.Router) { r.Use(RequireAdmin); admin.Routes(r, store) }) — авторизация из урока про [собственные middleware](/courses/chi/lessons/12-own-middleware/) стоит на ветке, а не в каждом хендлере пакета. Порядок имеет значение: Use внутри Route действует на всё поддерево, а маршрут, зарегистрированный до Use в том же файле, его не получит — chi применяет middleware, уже добавленные на момент регистрации.

### Group с Use: приватная ветка

```go
r := chi.NewRouter()
r.Use(middleware.Logger)

r.Group(func(r chi.Router) {
        r.Use(RequireAdmin) // только на ветку ниже
        r.Route("/admin", func(r chi.Router) {
                r.Get("/stats", stats)  // GET /admin/stats
                r.Post("/ban", banUser) // POST /admin/ban
        })
})

r.Get("/users", listUsers) // без RequireAdmin
```

Group вернул inline-роутер: дерево то же самое, префикса нет — только свой Use. RequireAdmin действует на всё внутри функции и не касается /users. Logger остался на корне, снаружи группы, поэтому в его строке лога виден честный 401, который RequireAdmin отдаст чужому. Проверьте это локальным Go: curl без заголовка на /admin/stats — 401 и в ответе, и в логе, а /users проходит без авторизации.

> **Повторный Mount — паника, и это благо.** Mount на префикс, который уже есть в дереве, роняет процесс при регистрации: «chi: attempting to Mount() a handler on an existing path». Это дешёвая ошибка — сервис не поднялся, CI красный, видно сразу. Тихий вариант хуже: повторный Get на тот же путь перезаписывает хендлер молча, и от него спасает только маршруто-тест из истории выше. Ещё один бонус Mount из исходников: подроутер без собственного NotFound наследует родительский — ваш JSON-404 работает и внутри смонтированного пакета без правок.

### Спор: роутер в main против Routes() в каждом пакете

Партия центра: все маршруты в main — вся карта сервиса на одном экране, ничего не спрятано, дубль виден глазами. Минусы вы уже видели в начале урока: файл растёт, конфликты в git, main тянет импорты всех пакетов и превращается в узкое место ревью. Партия децентрализации: Routes() в каждом пакете — пакеты самодостаточны, конфликты редки, main тонкий; минус — карта размазана, «где ручка X» решается обходом пакетов, а тихий дубль возможен, если два пакета повесили Routes на один префикс. Граница у меня такая: до десятка ручек — роутер в main, игра не стоит свеч; дальше — Routes() в пакете сущности; модуль, который живёт в нескольких сервисах, — свой New() и Mount. Золотая середина — Routes() в пакетах плюс файл сборки, где каждое монтирование занимает одну строку: карта возвращается, конфликты остаются внутри пакетов, где им и место.

### Чек-лист резки большого роутера

- Route возвращает chi.Router: подроутер можно вернуть из функции-сборщика и передать пакету.
- Один пакет — одна сущность: handlers/users, handlers/orders; зависимости — параметрами Routes(), не глобали.
- Mount — для готовых http.Handler, Route — для построения поддерева на месте, Group — только middleware без префикса.
- Повторный Get на тот же путь перезаписывает хендлер молча; повторный Mount паникует на старте — «existing path».
- Общий стек — на корне в main, веточный — r.Use внутри Route/Group; порядок Use фиксировать до регистрации маршрутов.
- Маршруто-тест после резки: запрос по каждому пути ветки должен отвечать тем пакетом, что заявлен.

### Соберите ветку с авторизацией

Восстановите три вызова: поддерево с префиксом, middleware на ветку и регистрацию маршрута.

```go
r.___("/users", func(r chi.Router) {
        r.___(RequireAuth)      // middleware только на эту ветку
        r.___("/{id}", getUser) // GET /users/{id}
})
```

Подсказка: Поддерево с префиксом строит r.Route, middleware на ветку добавляет r.Use, а регистрацию GET-маршрута делает r.Get.

Правильные ответы в пропусках: `Route`, `Use`, `Get`

### Соберите routes.go пакета

Расставьте строки контракта пакета handlers в правильном порядке: от функции до закрытия ветки.

Правильный порядок строк:

1. `func Routes(r chi.Router, notes noteStore) {`
2. `r.Route("/notes", func(r chi.Router) {`
3. `r.Get("/", listNotes(notes))`
4. `r.Post("/", createNote(notes))`
5. `})`
6. `}`

### Карточки: subrouters и Mount

- **r.Route(pattern, fn)** — Строит под-Mux: subRouter := NewRouter(), fn(subRouter), Mount(pattern, subRouter) — и возвращает chi.Router. При fn == nil паникует «attempting to Route() a nil subrouter».
- **r.Mount(pattern, handler)** — Монтирует любой http.Handler на префикс, ничего не возвращает. Регистрирует pattern и pattern+"/"; на занятый путь паникует; копирует подроутеру родительские NotFound и MethodNotAllowed.
- **r.Group(fn)** — Возвращает mx.With() — inline-роутер на том же дереве без префикса. Use внутри Group действует только на ветку; внутри можно вкладывать Route.
- **chi.Router** — Интерфейс роутера: Get/Post/Route/Group/Use/NotFound. Реализация — *chi.Mux, которая реализует http.Handler, поэтому подроутер можно подать и в Mount, и в http.Server.
- **Паттерн Routes()** — Пакет отдаёт func Routes(r chi.Router, deps...) — префикс вешает вызывающий, пакет не знает внешней раскладки. Зависимости — параметрами, хендлеры — замыкания над Store.
- **Дубль маршрута** — Повторный Get/Post на тот же путь и метод молча перезаписывает хендлер (setEndpoint «insert or update»); повторный Mount на занятый префикс паникует. От тихого дубля — только маршруто-тест.

main сжался до пары десятков строк: общий стек, монтирования по строке на сущность, единые NotFound и MethodNotAllowed. Пакеты handlers самостоятельны, конфликты в git переехали из main внутрь сущностей, а тихий дубль прикрыт маршруто-тестом. Побочный эффект разреза оказался приятнее ожиданий: хендлеры-замыкания с явными зависимостями проверяются в изоляции — подсовываешь фейковый Store и зовёшь хендлер, как его зовёт chi. Как именно строится такой тест без сети и без портов — тема следующего урока, а параметры маршрутов в хендлерах мы разбирали в уроке про URLParam. Полная карта курса — на [странице chi](/courses/chi/).

## Коротко: что запомнить

- r.Route(pattern, fn) строит под-Mux, наполняет через fn и возвращает chi.Router — подроутер выносится в функцию и пакет.
- r.Mount(pattern, handler) монтирует любой http.Handler и ничего не возвращает; Route — сахар над Mount.
- Структура проекта go: handlers/users и handlers/orders отдают Routes(r chi.Router, deps...) — префикс вешает main, пакет знает только свой хвост.
- Повторный Get на тот же путь молча перезаписывает хендлер; повторный Mount паникует на старте — «attempting to Mount() a handler on an existing path».
- Group — inline-роутер без префикса: r.Use внутри действует только на ветку до конца функции.
- Mount копирует подроутеру родительские NotFound и MethodNotAllowed — единый формат ошибок доезжает в смонтированный пакет.
- До десятка ручек — роутер в main; дальше — Routes() в пакете; карта маршрутов возвращается файлом сборки с монтированием в одну строку.

## Домашнее задание

Вынесите одну сущность из main в пакет handlers: файл routes.go с func Routes(r chi.Router, store) и хендлерами-замыканиями над Store. В main повесьте префикс через r.Route и смонтируйте пакет. Добавьте маршруто-тест, который стучится в каждый путь ветки и сверяет, что отвечает именно ваш пакет, — проверка на тихий дубль.

## Вопросы и ответы по уроку

### Route или Mount — что выбрать для выноса пакета?

Если пакет умеет только регистрировать маршруты — Routes(r chi.Router) и r.Route в main: стек общий, точка сборки одна. Если пакет строит своё дерево с собственным стеком — New() chi.Router и r.Mount в main. Route удобнее для типовой раскладки, Mount — для автономных модулей, статики и чужих хендлеров.

### Чем Group отличается от Route?

Group не создаёт поддерево и не добавляет префикс: по исходникам он возвращает mx.With() — inline-роутер на том же дереве. Group — только middleware-зона: Use внутри действует на зарегистрированные после него маршруты ветки. Route строит новое поддерево с префиксом и монтирует его в родителя.

### Куда класть общий middleware — в main или в пакет?

Сквозные слои — RequestID, RealIP, Logger, восстановление после паник — в main до монтирования: они должны накрывать все ветки одинаково и в одном порядке. Специфичное для сущности — внутрь Routes() пакета или в Group в точке сборки. Промежуточный случай — авторизация на группе пакетов: Use в Group перед их монтированием.
