---
title: "Итоговый проект: REST API на chi"
description: "Собираем итоговый проект по chi: REST API записной книжки со слоями handler, service и store, полным стеком middleware, тестами httptest и graceful shutdown."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 24
---

# Итоговый проект: REST API на chi

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

Итоговый проект по chi собирает весь курс в один работающий каркас — REST API записной книжки, где у заметок есть id, title, body и updated_at. Дальше без новых сущностей: структура по слоям, полный стек middleware, тесты на роутер целиком и graceful shutdown — каждый элемент вы уже собирали руками, здесь он встаёт на своё место.

Первый сервис, который я собирал на chi, жил одним файлом: main, хендлеры, запросы к базе и рендер ответов в одной куче. Пока роутов было три, это терпело. На пятом хендлере правка в списке заметок начала ронять создание, curl по всем маршрутам перед релизом стал ритуалом, а тест написать было негде — функция совмещала маршрутизацию, хранилище и транспорт в одной сигнатуре. Итоговый проект по chi — REST API записной книжки — устроен ровно наоборот: те же возможности, что и весь курс, но разложенные по слоям, где каждый элемент вы уже собирали руками. Маршруты и методы — из урока про маршрутизацию, стек middleware — из уроков про встроенные слои и их порядок, единые ошибки — из урока про errorHandler, тесты — из урока про httptest. Новых сущностей здесь нет: каркас собирается из деталей, которые уже лежат на столе, и собирается один раз — чтобы жить дальше в ваших проектах, а не умереть в конце курса.

### REST API на chi: структура по слоям

Дерево проекта короткое: cmd/api/main.go, внутренний пакет internal/notes с тремя файлами — handler.go, service.go, store.go — и тесты рядом. main.go только собирает и поднимает: читает флаги, создаёт хранилище, строит роутер, кладёт его в http.Server и обслуживает graceful shutdown. handler.go — транспорт: декодирует тело, достаёт chi.URLParam, решает, каким кодом ответить, и рендерит JSON. service.go — правила предметной области: обрезать пробелы в title, проставлять updated_at, решать, что вообще считать заметкой. store.go — граница данных: интерфейс Store и реализация хоть над картой в памяти, хоть над postgres; остальной код знает только интерфейс. Каталог internal не даёт чужим проектам импортировать вашу начинку, а нарезка по файлам — не про красоту, а про направление зависимостей: транспорт зовёт правила, правила зовут данные, и никогда наоборот. CRUD-четвёрка из урока про [маршруты и методы](/courses/chi/lessons/03-marshruty-metody/) ложится на subrouter ровно по схеме из [урока про subrouters и структуру каталога](/courses/chi/lessons/19-subrouters-struktura/): ветка /api/notes отвечает за свои маршруты и никого не трогает.

### Каркас: слой → файл → ответственность

| Слой | Файл | Ответственность |
| --- | --- | --- |
| Точка входа | cmd/api/main.go | флаги, http.Server, graceful shutdown |
| Транспорт | internal/notes/handler.go | декод JSON, chi.URLParam, коды ответов |
| Правила | internal/notes/service.go | TrimSpace, updated_at, проверки заметки |
| Данные | internal/notes/store.go | интерфейс Store: List, Get, Create, Update, Delete |
| Сборка | internal/notes/router.go | стек middleware и все маршруты одной функцией |
| Тесты | internal/notes/router_test.go | httptest на весь роутер, мок Store |

> **Живое место: 400 строк handler.go.** Каркас без слоёв выглядит невинно ровно до первого теста. Проект, который мне однажды достался: вся логика в одном handler.go на 400 строк — маршрутизация, запросы к базе и рендер вперемешку, тестов ноль, потому что вызвать хендлер вне живого сервера было невозможно. Расслоили за один вечер: Store уехал в интерфейс, правила — в service, хендлеры похудели до транспорта. Первый же httptest-тест нашёл баг, который месяц жил в проде: PUT с нечисловым id возвращал 500. Тесты — не награда за красивую структуру, а причина её сделать.

### internal/notes/router.go: одна функция сборки

```go
func newRouter(st Store) http.Handler {
        r := chi.NewRouter()

        r.Use(middleware.RequestID)
        r.Use(middleware.RealIP)
        r.Use(errorHandler) // паники и apiError — в единый JSON
        r.Use(middleware.Logger)
        r.Use(middleware.Timeout(5 * time.Second))

        r.Get("/healthz", health)

        r.Route("/api/notes", func(r chi.Router) {
                r.Get("/", listNotes)
                r.Get("/{noteID}", getNote)
                r.Group(func(r chi.Router) {
                        r.Use(RequireAuth)
                        r.Post("/", createNote)
                        r.Put("/{noteID}", updateNote)
                        r.Delete("/{noteID}", deleteNote)
                })
        })

        return r
}
```

newRouter — единственная сборка каркаса, и main.go, и тесты зовут именно её: одна функция гарантирует, что тест проверяет то же, что работает в проде. Пять слоёв глобально, health-проба без auth, чтение публично, изменения — за Group с RequireAuth. Маршруты внутри Group получают проверку потому, что зарегистрированы после r.Use в этой ветке. Пощупать каркас руками можно локальным Go: go run ./cmd/api и curl на /healthz и /api/notes.

### Проверка: почему auth в Group

**Вопрос:** Чтение оставили публичным, а POST, PUT и DELETE — за Group с RequireAuth. Почему auth не поставили общим r.Use в корне стека?

- ✅ Тогда и /healthz, и чтение требовали бы токен: Group добавляет middleware только своим маршрутам
- r.Use в chi разрешено вызывать только один раз на роутер
- Group ускоряет маршрутизацию: префиксное дерево не проверяет лишние ветки
- RequireAuth технически не работает как глобальный middleware

Слой применяется к маршрутам, зарегистрированным в этой ветке после r.Use, — Group со своим Use закрывает только свои роуты. Корневой стек общий для всех: RequestID и Logger нужны каждому запросу, а auth — не каждому. Публичное чтение и health-проба остаются бесплатными, изменения прикрыты проверкой из урока про аутентификацию.

### Middleware-стек: RequestID, RealIP, errorHandler, Logger и Timeout

Порядок слоёв в newRouter — не вкусовщина, а рабочий контракт из уроков про встроенные middleware и порядок стека. RequestID первый: он кладёт идентификатор в контекст, его подхватывают Logger и ответы об ошибках, и жалоба пользователя сшивается с журналом за секунды. RealIP стоит рядом и тоже до Logger: адрес в строке лога берётся из RemoteAddr в момент сборки, и слой должен успеть переписать его на настоящий адрес клиента заранее. errorHandler из урока про централизованные ошибки ловит свои apiError и чужие паники, отвечает единым JSON и стоит до Logger — так его ответы попадают в строку лога с честным статусом. Logger пишет одну строку на запрос: метод, путь, статус, длительность, идентификатор. Timeout задаёт дедлайн контексту в пять секунд: зависший хендлер не держит соединение вечно, а значение согласовано с бюджетом прокси из урока про деплой. RequireAuth в корневой стек не входит — он живёт в Group внутри ветки и достаётся только защищённым маршрутам.

### Валидация тела и параметров с единым JSON-форматом ошибок

Хендлер — худшее место для бизнес-правил и лучшее для HTTP-церемоний. Валидация из урока про URLParam и проверку данных распадается на два этажа. Тело: json.NewDecoder в request-структуру, TrimSpace по title, проверка непустоты и длины; синтаксический мусор — это 400, а валидный JSON с пустым title — 422. Параметры: noteID из chi.URLParam обязан быть числом, иначе 400 ещё до обращения к хранилищу. Ответ об ошибке всегда один: helper renderError из урока про [централизованные ошибки](/courses/chi/lessons/15-error-handling/) пишет обёртку error с машинным code и человеческим message, одинаковые для любого провала. Клиент парсит один контракт вместо трёх, фронтенд-обработчик перестаёт падать на неожиданном формате, а тест фиксирует формат жёстче любой договорённости в чате. Коды разведены по смыслу: 400 — синтаксис, 422 — семантика, 404 — нет заметки, 401 — нет токена на защищённой ветке.

### internal/notes/handler.go: create и getNote

```go
type createRequest struct {
        Title string
        Body  string
}

func createNote(w http.ResponseWriter, r *http.Request) {
        var in createRequest
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
                renderError(w, r, http.StatusBadRequest, "bad_request", "битый JSON")
                return
        }
        in.Title = strings.TrimSpace(in.Title)
        if in.Title == "" || utf8.RuneCountInString(in.Title) > 120 {
                renderError(w, r, http.StatusUnprocessableEntity, "validation_failed", "title пуст или длиннее 120 символов")
                return
        }

        n, err := store.Create(r.Context(), in.Title, in.Body)
        if err != nil {
                renderError(w, r, http.StatusInternalServerError, "internal", "не удалось сохранить заметку")
                return
        }
        writeJSON(w, http.StatusCreated, n)
}

func getNote(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "noteID"))
        if err != nil {
                renderError(w, r, http.StatusBadRequest, "bad_request", "noteID должен быть числом")
                return
        }
        n, err := store.Get(r.Context(), id)
        if errors.Is(err, ErrNotFound) {
                renderError(w, r, http.StatusNotFound, "not_found", "заметки нет")
                return
        }
        writeJSON(w, http.StatusOK, n)
}
```

Два хендлера — и весь шаблон транспорта: декод в request-структуру, TrimSpace, проверка длины, ответ через renderError с кодом из таблицы статусов. Нечисловой noteID отсекается до хранилища: мусор в пути не превращается в нечитаемую ошибку на слое данных. JSON-теги у Note и request-структур задают ключи id, title, body и updated_at — здесь они опущены для краткости, а в реальном каркасе пишутся рядом с каждым полем.

### Что получит клиент на /api/notes/abc?

```go
// маршрут: r.Get("/{noteID}", getNote)
func getNote(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "noteID"))
        if err != nil {
                renderError(w, r, http.StatusBadRequest, "bad_request", "noteID должен быть числом")
                return
        }
        n, err := store.Get(r.Context(), id)
        if errors.Is(err, ErrNotFound) {
                renderError(w, r, http.StatusNotFound, "not_found", "заметки нет")
                return
        }
        writeJSON(w, http.StatusOK, n)
}
```

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

- 200 и тело заметки: Atoi вернёт ноль, store найдёт заметку 0
- ✅ 400: renderError ответит обёрткой error с code bad_request и текстом про noteID
- 404: пустая строка параметра превращается в not_found
- 500: strconv паникует на нечисловой строке

Atoi возвращает ошибку на abc, хендлер отвечает через renderError: 400, машинный код bad_request и человеческий текст. До store дело не доходит — мусор отсечён на транспорте. Обёртка error та же, что у 422 и 404: клиент читает один контракт. Паники нет — Atoi честно возвращает ошибку, а не валит процесс.

### Тесты: httptest на роутер целиком

Тесты из урока про [тестирование chi](/courses/chi/lessons/20-testing-chi/) поднимают не хендлер поодиночке, а весь каркас: newRouter с той же сборкой, что и в main, httptest.NewRequest без сокета и httptest.NewRecorder вместо ResponseWriter. r.ServeHTTP(rr, req) прогоняет запрос через весь стек — RequestID генерируется, URLParam наполняется только роутером, RequireAuth срабатывает на защищённых маршрутах — и это ловит класс багов, невидимый на уровне функций: опечатку в шаблоне маршрута, забытый Use в Group, неверный Content-Type. Мок Store через интерфейс делает набор автономным: Store — интерфейс с пятью методами, в тестах подставляется mockStore над картой, и никакая база не нужна. Набор для каркаса компактен: 201 на создание, 200 на чтение, 404 по errors.Is, 400 на нечисловой noteID, 422 на пустой title, 401 на изменение без токена. Каждый случай — десяток строк, весь набор крутится за миллисекунды.

### internal/notes/router_test.go: мок и полный прогон

```go
type mockStore struct {
        notes map[int]Note
}

func (m *mockStore) Get(ctx context.Context, id int) (Note, error) {
        n, ok := m.notes[id]
        if !ok {
                return Note{}, ErrNotFound
        }
        return n, nil
}

// List, Create, Update, Delete — по той же схеме над картой

func TestGetNote(t *testing.T) {
        st := &mockStore{notes: map[int]Note{7: {ID: 7, Title: "черновик"}}}
        r := newRouter(st) // тот же каркас, что и в main

        req := httptest.NewRequest(http.MethodGet, "/api/notes/7", nil)
        rr := httptest.NewRecorder()

        r.ServeHTTP(rr, req)

        if rr.Code != http.StatusOK {
                t.Fatalf("код: %d, хотим 200", rr.Code)
        }
        if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
                t.Fatalf("Content-Type: %q, хотим application/json", ct)
        }
}
```

mockStore реализует интерфейс Store над картой: тест подменяет только хранилище, весь остальной каркас настоящий. Запрос идёт через r.ServeHTTP — то же дерево маршрутов, что и в main, поэтому опечатка в шаблоне или забытый RequireAuth упадут прямо здесь. Прогон — локальным Go командой go test ./...: без портов, сети и базы, параллельно и за миллисекунды. Заготовка та же, что в уроке про httptest, — она просто наезжает на готовый каркас.

### Проверка: что реально проверено

**Вопрос:** Тест гоняет запрос через newRouter с mockStore вызовом r.ServeHTTP. Что он проверил?

- Только логику хендлера: middleware в тесте не работают
- ✅ Весь каркас: стек middleware, маршрутизацию, URLParam, хендлер и мок-хранилище
- Совместимость с реальной СУБД: mockStore подменяет драйвер postgres
- Производительность роутера: ServeHTTP меряет время ответа

chi.Router — это http.Handler, поэтому ServeHTTP исполняет полный стек: RequestID кладёт идентификатор, URLParam наполняется только маршрутизатором, RequireAuth отсекает запрос без токена. Прямой вызов хендлера такого не поймает — RouteContext в контексте не появится, и параметр окажется пустым. Драйвер базы тест не затрагивает: для этого нужны интеграционные тесты.

### Graceful shutdown и выкат: каркас закрывается

Два последних болта закрывают эксплуатацию. Graceful shutdown из одноимённого урока: srv.ListenAndServe уходит в горутину, signal.NotifyContext ловит SIGTERM от systemd или docker stop, srv.Shutdown с бюджетом в десять секунд даёт долетающим запросам закончиться, а новые не принимает. Таймауты http.Server — ReadTimeout, WriteTimeout, IdleTimeout — ставятся рядом с middleware.Timeout и согласуются с ним, чтобы дедлайны не противоречили друг другу. Выкат за прокси из урока про деплой: chi слушает 127.0.0.1:8080, nginx держит TLS и передаёт заголовки клиента, middleware.RealIP в начале стека восстанавливает адрес, /healthz без токена опрашивается прокси. Каркас к этому ничего не требует: health-проба и вежливое выключение уже в коде, поэтому инстанс снимается с балансировки до остановки, а выкат не рвёт активные запросы.

### Соберите ветку /api/notes

Верните три строки каркаса: префикс ветки, auth-слой и имя параметра маршрута.

```go
r.Route("___", func(r chi.Router) {
        r.Get("/", listNotes)
        r.Group(func(r chi.Router) {
                r.Use(___)
                r.Post("/", createNote)
        })
        r.Get("/{___}", getNote)
})
```

Подсказка: Префикс — /api/notes; auth-слой из урока про JWT зовётся RequireAuth; имя в шаблоне должно совпадать с тем, что читает chi.URLParam.

Правильные ответы в пропусках: `/api/notes`, `RequireAuth`, `noteID`

### Соберите стек newRouter

Расставьте вызовы стека в правильном порядке — от идентификатора запроса до auth на защищённых ветках.

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

1. `r.Use(middleware.RequestID)`
2. `r.Use(middleware.RealIP)`
3. `r.Use(errorHandler)`
4. `r.Use(middleware.Logger)`
5. `r.Use(middleware.Timeout(5 * time.Second))`
6. `r.Group(func(r chi.Router) { r.Use(RequireAuth) })`

### Карточки: каркас итогового проекта

- **Слои каркаса** — handler.go — транспорт: декод, URLParam, коды ответов. service.go — правила: TrimSpace, updated_at. store.go — данные: интерфейс Store. Зависимости только вниз, от транспорта к данным.
- **Интерфейс Store** — Пять методов: List, Get, Create, Update, Delete. Реализация — карта в памяти или postgres; в тестах подставляется mockStore. Смена хранилища не трогает service и handler.
- **renderError** — Helper из урока про централизованные ошибки: пишет обёртку error с машинным code и человеческим message. Один формат для 400, 422, 404 и 500 — клиент парсит один контракт.
- **middleware.Timeout** — Ставит дедлайн контексту на каждый запрос. Значение согласуется с таймаутами http.Server и бюджетом прокси: дедлайн chi должен сработать раньше, чем отрежет nginx.
- **RequireAuth в Group** — Auth живёт не в корневом стеке, а внутри Group: middleware применяется только к маршрутам, зарегистрированным в ветке после Use. Чтение и /healthz остаются публичными.
- **Graceful shutdown** — SIGTERM ловится через signal.NotifyContext, srv.Shutdown с бюджетом даёт долетающим запросам закончиться и не принимает новые. systemd и docker stop шлют именно SIGTERM.

> **Спор: монолит-каркас или микросервисы для нового проекта.** Мой расклад: новый проект начинает с монолит-каркаса из этого урока, и это осознанный выбор, а не компромисс. Граница такая: один сервис, одна команда, общие данные — слои handler, service, store дают ту же изоляцию, что и отдельные сервисы, но без сетевых прыжков, версионирования API и распределённых трейсов. Микросервисы окупаются, когда домены реально независимы: разные команды, разные циклы релизов, разная нагрузка — платежи и каталог, например. Резать заранее — значит платить цену распределённости за гибкость, которая ещё не понадобилась. Признак, что пора делить: два модуля начали деплоиться по расписанию друг друга. До этого момента один процесс с чистыми слоями — самый дешёвый работающий вариант.

### Чек-лист: каркас готов к прод

- Стек полный и в правильном порядке: RequestID, RealIP, errorHandler, Logger, Timeout.
- RequireAuth только внутри Group: /healthz и чтение публичны, изменения по токену.
- Все ошибки — один контракт error с code и message через renderError.
- Валидация до хранилища: битый JSON и нечисловой noteID — 400, пустой title — 422.
- Store — интерфейс: mockStore в тестах, mapStore в main, postgres когда дорастёте.
- Тесты на роутер целиком через ServeHTTP: go test ./... зелёный без сети и базы.
- Graceful shutdown ловит SIGTERM, таймауты http.Server согласованы с Timeout и прокси.

Что получилось в сухом остатке: REST API на chi с четырьмя операциями над заметками, пять файлов Go и ни одной новой сущности сверх курса. Каркас намеренно тонкий — на него наращивается всё, что шло дальше по программе: postgres вместо mapStore подменяется в одной точке main.go, Throttle добавляется одной строкой Use, CORS встаёт перед auth по правилу порядка слоёв, версионирование API — новой веткой Route. Дальше только ваши проекты: скопируйте структуру, соберите стек, прогоните тесты и выкатите за прокси. Каждый болт этого каркаса вы уже держали в руках по отдельности — осталось затянуть их вместе.

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

- Каркас REST API записной книжки — пять файлов: main.go собирает и поднимает, internal/notes делит транспорт, правила и данные.
- newRouter — одна функция сборки: её зовут и main, и тесты, поэтому прод и прогон идут по одному стеку.
- Стек: RequestID, RealIP, errorHandler, Logger, Timeout глобально; RequireAuth — только в Group на изменениях.
- Ошибки в одном контракте error с code и message: 400, 422, 404 и 401 разведены по смыслу.
- Store — интерфейс: mockStore в тестах, mapStore в проде; httptest гоняет роутер целиком через ServeHTTP.
- Graceful shutdown и выкат за прокси замыкают каркас: SIGTERM ловится, /healthz отвечает, RealIP восстанавливает адрес.

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

Развейте каркас: добавьте реализацию Store поверх JSON-файла, не меняя интерфейс, и вынесите порт в переменные окружения. Допишите в router_test.go два кейса — 404 на несуществующий noteID и 401 на PUT без токена. Прогоните go test ./... и поднимите каркас за nginx из урока про деплой.

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

### Почему Store — интерфейс, если данных пока немного?

Интерфейс — не про объём данных, а про границы теста. Handler и service зависят от пяти методов, а не от реализации: mockStore подставляется за минуту, тесты идут без базы, а переход на postgres — новый файл store_postgres.go без правок логики. Цена — одна декларация, выгода — автономность тестов.

### Можно ли обойтись без слоя service в маленьком API?

Технически да — хендлеры могут звать Store напрямую. Но тогда правила — TrimSpace, updated_at, перевод ErrNotFound — расползаются по хендлерам и дублируются между create и update. Service на сотню строк возвращает себя первым же тестом: подменять и проверять приходится одно место, а не каждую ручку отдельно.

### Достаточно ли этого стека middleware для продакшена?

Для простого API — да: идентификация, адрес, ошибки, лог и дедлайн закрывают базовые риски. Дальше слои добавляются по мере роста: Throttle и NoCache из встроенных, CORS перед auth, если появится браузерный фронтенд, метрики — своим слоем. Правило из урока про порядок: сначала вопрос «кто кого видит», потом строка r.Use.
