---
title: "Лучшая структура проекта"
description: "Структура Connect-проекта: контракт в proto/, генерат в gen/, service-слой и платформа в internal/, композиционный корень cmd и закон импортов, который держит слои."
course: "Connect: современный RPC от Buf"
url: "https://golangmaster.ru/courses/connect/lessons/23-struktura/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Лучшая структура проекта

*Курс «Connect: современный RPC от Buf», урок 23 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/connect/lessons/23-struktura/*

структура проекта connect — тема, которую второй урок честно отложил: тогда мы собирали минимальный скелет proto, gen и cmd, а обещание разобрать взрослую раскладку оставили на финал курса. Обещание исполняю: если собрать частые запросы — connect структура, grpc проект структура, buf проект структура, — получится ровно карта этого урока. Раскладываем по этажам контракт proto/, генерат gen/, service-слой с хендлерами и хранилищем, сквозную платформу и композиционный корень cmd; фиксируем закон импортов, который не даёт проекту сгнить; проходим путь фичи от правки схемы до PR и смотрим, как раскладка переживает второй сервис и вторую версию. К концу урока у тебя будет шаблон, который открывается в редакторе и не требует пояснений в чате команды.

Во втором уроке каноническая минимальная структура держалась на трёх ролях: схемы редактируешь руками, генерат не трогаешь никогда, свой код пишешь всегда. Тот скелет — proto/, gen/ и cmd/server — честно работает, пока проект учебный. Но рабочий сервис из девятнадцатого урока уже тянет за собой healthz, readyz и metrics на общем mux, интерсепторы из седьмого и тринадцатого уроков, хранилище, конфигурацию из окружения, а деплой из двадцать второго добавляет Dockerfile и CI. Всё это нельзя сваливать в один каталог: main.go разрастается до тысячи строк, и правка интерсептора начинается с получаса поиска нужной функции. Взрослый шаблон добавляет к трём ролям два этажа — service-слой со своей логикой и платформу со сквозным, — и главное, фиксирует направление зависимостей между ними. План урока: дерево проекта целиком, каждый этаж по отдельности, закон импортов, таблица раскладки, путь фичи от схемы до PR и то, как шаблон растёт вместе с командой. Второй урок обещал эту тему ближе к концу курса — обещание исполняется здесь и сейчас.

### Дерево рабочего шаблона

```go
notes-api/
├── buf.yaml              # паспорт модуля: path proto, lint, breaking
├── buf.gen.yaml          # рецепт генерации: плагины, out: gen
├── go.mod                # module example.com/notes-api
├── proto/                # КОНТРАКТ — редактируешь только здесь
│   └── notes/v1/notes.proto
├── gen/                  # ГЕНЕРАТ — пишет только buf generate
│   └── notes/v1/
│       ├── notes.pb.go
│       └── notesv1connect/notes.connect.go
├── internal/
│   ├── notes/            # SERVICE-слой: хендлеры, домен, хранилище
│   │   ├── server.go     # notesServer: реализация handler-интерфейса
│   │   ├── store.go      # интерфейс хранилища + sentinel-ошибки
│   │   └── memory.go     # in-memory реализация для тестов и локалки
│   └── platform/         # СКВОЗНОЕ: auth, метрики, логи, рефлексия
│       ├── auth.go
│       └── metrics.go
└── cmd/notes/main.go     # КОМПОЗИЦИОННЫЙ КОРЕНЬ: сборка всего
```

Пять этажей и два конфига в корне. Комментарии — это контракты каталогов: любой новый файл обязан знать своё место до того, как написан. internal/ — встроенная защита Go: пакеты из него не импортирует чужой модуль, поэтому домен не «утекает» в соседние репозитории.

### proto/: контракт как первый гражданин

Каталог proto/ — единственное место, где ты редактируешь контракты, и buf.yaml в корне делает этот каталог модулем: секция modules с path: proto отсчитывает все пути от него, поэтому файл proto/notes/v1/notes.proto для buf — это notes/v1/notes.proto. Каталог внутри proto/ повторяет имя пакета: notes.v1 живёт в notes/v1/, и версия — часть пути, а не комментарий. buf.yaml же держит политику качества: lint с набором STANDARD ловит стиль схем до генерации, секция breaking задаёт правила обратной совместимости для CI. Комментарии над service и rpc — не украшение: из пятнадцатого урока ты знаешь, что из них собираются доки, OpenAPI и ответы рефлексии, поэтому контракт редактируется вместе с документацией, в одном коммите. Правило гигиены: в proto/ нет ничего, кроме схем, — ни моков, ни утилит, ни Go-файлов. Если в схеме появилась языковая опция, подумай дважды: managed mode из одиннадцатого урока умеет проставлять go_package_prefix за все языки сразу, оставляя схемы чистыми. Публикация контракта наружу — buf push с меткой из двенадцатого урока, когда потребители хотят версию без клона твоего репозитория.

### buf.yaml и buf.gen.yaml в корне

```go
# buf.yaml — паспорт модуля
version: v2
modules:
  - path: proto
lint:
  use: [STANDARD]
breaking:
  use: [FILE]

# buf.gen.yaml — рецепт генерации
version: v2
plugins:
  - local: protoc-gen-go
    out: gen
    opt: paths=source_relative
  - local: protoc-gen-connect-go
    out: gen
    opt: paths=source_relative
```

Оба конфига живут в корне рядом с go.mod: buf generate без аргументов читает их сам. out: gen и paths=source_relative дают путь gen/notes/v1/notes.pb.go — ровно тот, что зашит в go_package схемы. Взрослый вариант заменяет local на remote-плагины с закреплённой версией — тогда CI не ставит тулчейн вообще.

### gen/: генерат и правила сосуществования

Каталог gen/ — территория buf generate: сюда падают notes.pb.go с message-типами и подпакет notesv1connect с handler-интерфейсом, заглушкой UnimplementedNoteServiceHandler, конструктором NewNoteServiceHandler и типизированным клиентом. Первое правило ты знаешь со второго урока: править файлы в gen/ нельзя категорически — любая перегенерация затрёт правки, а источник правды всегда один, и это схема. Второе правило — коммитить или нет — вечный спор двух лагерей, и для учебного проекта второй урок уже дал вердикт: коммить, меньше движущихся частей. Взрослая политика выглядит иначе: gen/ в .gitignore, buf generate в CI с remote-плагинами закреплённой версии, и тогда репозиторий читается без шума в диффах, а воспроизводимость гарантирует конфиг, а не память коллеги. В честном buf проект структура gen/ — артефакт сборки, а не исходник; какую из двух политик ни выбрал бы, выбери одну на всю команду и запиши её в contributing. Третье правило — границы: gen/ импортирует только runtime connectrpc.com/connect и protobuf-пакеты; ничего из internal/ сгенерированный код не видит и видеть не должен, иначе шов между контрактом и кодом перестаёт быть швом.

**Вопрос:** Коллега поправил сообщение в gen/notes/v1/notes.pb.go, чтобы «не ждать генерацию». Что произойдёт?

- Ничего: правка останется навсегда, buf generate её учтёт
- ✅ При ближайшей перегенерации правка затрётся, а до того — разъедется со схемой у остальных
- buf заметит чужие правки и откажется генерировать
- Правка безопасна, если её закоммитить вместе со схемой

gen/ — артефакт, единственный источник правды — proto/. buf не сравнивает правки руками: следующая генерация перепишет файл по схеме, и чужой хендлер перестанет собираться в самый неподходящий момент. Правильный путь: правка в схеме, buf lint, buf generate, и компилятор сам укажет, что адаптировать.

### internal/notes: service-слой

Здесь живёт твой код, который делает сервис сервисом. Реализация notesServer — тот тип с приёмником-указателем из третьего урока, что реализует handler-интерфейс: по методу на каждый rpc, сигнатуры со сгенерированными message-типами, состояние в полях структуры. Рядом — интерфейс хранилища Store с методами Create, Get, List, Update, Delete и sentinel-ошибки ErrNotFound и ErrForbidden: хендлер переводит их в коды через connect.NewError из шестого урока, и ни одна ошибка хранилища не утекает наружу сырым текстом. Хранилище может быть in-memory из этого же каталога, SQL-драйвером или прокси на соседний сервис — notesServer не знает и не должен знать. Домен держи в чистых Go-типах: структура Note с полями, валидации, правила владения; конвертация домен-контракт занимает одну строку на поле, и эта плата за чистоту окупается, когда появится вторая версия схемы из восемнадцатого урока и общий домен станет общим честно. Тесты слоя лежат рядом: service_test.go с table-driven матрицей кодов — канон шестнадцатого урока.

### internal/platform: сквозное, не знающее домена

Платформа собирает то, что должно работать на каждом вызове, не зная ни одного message-типа. Auth-интерсептор из тринадцатого урока: читает Authorization, проверяет токен, кладёт владельца в контекст, отказывает unauthenticated — его интересуют заголовки и процедуры, а не заметки. Метрики из семнадцатого: счётчик по меткам procedure и code, гистограмма длительности — интерсептор работает с req.Spec().Procedure и connect.CodeOf(err), домен ему не нужен. Логирующий интерсептор с request id, otelconnect для трейсов, монтаж gRPC-рефлексии из пятнадцатого урока — всё это пакеты platform, которые можно взять и переиспользовать в следующем сервисе проекта. CORS для браузерных клиентов из девятого урока — тоже platform: middleware на mux, белый список origin из конфига. Ключевое отличие платформы от service-слоя: она обязана собираться без импорта notes-пакета. Если платформенному файлу понадобился notesv1 — ты пишешь не платформу, а бизнес-логику не в том месте, и это ранний сигнал переставить код. Именно так интерфейсные типы AnyRequest и AnyResponse из седьмого урока окупаются архитектурой: один интерсептор на все сервисы и версии.

### cmd/notes/main.go: композиционный корень

```go
func main() {
    store := notesmemory.New()
    srv := &notesserver.Server{Store: store}

    mux := http.NewServeMux()
    mux.HandleFunc("GET /healthz", health)
    mux.HandleFunc("GET /readyz", ready(store))
    mux.Handle("GET /metrics", promhttp.Handler())

    path, handler := notesv1connect.NewNoteServiceHandler(
        srv,
        connect.WithReadMaxBytes(1<<20),
        connect.WithCompressMinBytes(1024),
        connect.WithInterceptors(&metrics.Interceptor{}, &auth.Interceptor{}),
    )
    mux.Handle(path, handler)

    httpSrv := &http.Server{
        Addr:    ":8080",
        Handler: h2c.NewHandler(mux, &http2.Server{}),
    }
    // запуск, сигнал SIGTERM и srv.Shutdown — как в девятнадцатом уроке
    _ = httpSrv
}
```

main — единственное место, где видны все пакеты сразу: хранилище, сервер, платформа, генерат. Опции лимитов и сжатия из четырнадцатого урока, h2c из девятнадцатого, пробные роуты из того же урока — композиционный корень собирает канон курса в один файл, и других мест с импортом всего и вся в проекте нет.

**Вопрос:** Куда положить интерсептор, который проверяет Bearer-токен и кладёт владельца в контекст?

- В cmd/notes/main.go — чтобы всё было видно сразу
- В gen/ — рядом с конструктором handler-а
- ✅ В internal/platform/auth.go, подключить через WithInterceptors в main
- В каждый метод notesServer отдельно — надёжнее

Сквозное живёт в platform, сборка — в cmd. Писать интерсептор в main — смешивать композицию с логикой; в gen/ — править генерат; копировать проверки в каждый метод — нарушать DRY и рано или поздно пропустить метод. Интерсептор в platform не знает домена, а владение заметкой — это уже уровень хендлера из шестнадцатого урока.

### Закон импортов: направление, которое нельзя развернуть

Структура без правил зависимостей — это просто папки. Закон этого шаблона читается по стрелкам: cmd импортирует всех, internal/notes импортирует gen и стандартную библиотеку, internal/platform импортирует только connect и общие пакеты, gen не импортирует ничего из internal. Обратное направление запрещено: платформа не знает домена, домен не знает платформы, и если оба правила держать, import cycle невозможен физически — компилятор будет ругаться раньше, чем ревьюер. gen — граница: только cmd и internal/notes имеют право импортировать notesv1 и notesv1connect; появились такие импорты в platform — код лежит не в своём этаже. Проверка закона автоматизируется: goimports держит порядок импортов, а правила типа «никаких notesv1 вне notes и cmd» ловит lint-скрипт в CI за десять строк. Отдельный запрет — каталоги utils и helpers: имя, под которым нельзя угадать содержимое, — имя, в которое складывают всё. Закон импортов — тот же принцип, что кнопки лимитов из четырнадцатого урока: каждый барьер существует, чтобы изменения текли в одну сторону, и его нарушение видно сразу.

### Карта раскладки

| Каталог | Что живёт | Правило |
| --- | --- | --- |
| proto/ | Схемы notes/v1, комментарии контракта | Единственное место ручных правок контракта; buf lint и breaking смотрят сюда |
| gen/ | notes.pb.go и notesv1connect | Только buf generate; правки затираются, политика коммита одна на команду |
| internal/notes | notesServer, Store, sentinel-ошибки, домен | Импортирует gen; тесты table-driven рядом |
| internal/platform | Auth, метрики, логи, CORS, рефлексия | Не знает домена: работает с Any-типами и процедурами |
| cmd/notes | main.go, композиционный корень | Собирает всех; единственный файл, знающий все пакеты |
| buf.yaml | Модуль path proto, lint STANDARD, breaking | Меняется при смене политики, не при каждой фиче |
| buf.gen.yaml | Плагины и out: gen | Remote-плагины с закреплённой версией — воспроизводимость в CI |
| go.mod | Модуль и зависимости | connectrpc.com/connect без grpc-go; internal закрыт от соседей |
| Makefile и CI | Цели lint, generate, breaking, test, build | Генерация и breaking в пайплайне, не на памяти коллег |

### Собери монтаж сервиса в main

Впиши пропущенные вызовы: конструктор handler-а, опция интерсепторов и два способа монтирования на mux.

```go
path, handler := notesv1connect.___(
    &notesServer{store: store},
    connect.___(&metrics.Interceptor{}, &auth.Interceptor{}),
)
mux.___(path, handler)
mux.___("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
})
```

Подсказка: Конструктор из notesv1connect возвращает путь и handler, опция интерсепторов — WithInterceptors, а роуты вешаются Handle-м с путём-строкой и HandleFunc-ом с паттерном метода из Go 1.22.

Правильные ответы в пропусках: `NewNoteServiceHandler`, `WithInterceptors`, `Handle`, `HandleFunc`

### Путь фичи от схемы до PR

Расставь шаги новой фичи в рабочем шаблоне.

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

1. `Правка контракта: новое поле и rpc в proto/notes/v1/notes.proto`
2. `buf lint — стиль схемы проверен до генерации`
3. `buf generate — плагины обновили gen/`
4. `Компилятор показывает незакрытые методы: реализуешь хендлер в internal/notes`
5. `Хранилище дополняется за интерфейсом Store со своими sentinel-ошибками`
6. `Table-driven тест нового метода встаёт в матрицу кодов`
7. `buf breaking в CI сверяет схему с прошлым релизом`
8. `PR с proto, gen и кодом одним диффом — ревьюер видит контракт и реализацию вместе`

### Рост: второй сервис и вторая версия

Хороший шаблон узнаётся по тому, как он переживает рост. Второй сервис: добавляешь proto/tasks/v1/tasks.proto, buf generate сам раскладывает генерат по путям, в internal появляется tasks/, а в main — вторая пара строк конструктор плюс Handle на том же mux и том же порту: третий урок показал это на eliza и notes рядом, и ничего не изменилось. Схемы разных сервисов могут жить в одном модуле, а могут разъехаться по репозиториям — тогда контракт доставляется зависимостью из BSR, и двенадцатый урок уже дал тебе механику. Вторая версия: notes.v2 живёт рядом с v1 на том же mux, маршрутизация по путям процедур разделяет их автоматически, конвертация v1-v2 занимает место в общем домене — восемнадцатый урок расписал жизненный цикл. Несколько модулей Go в одном репозитории требуют go.work — не включай его без нужды: пока пакетов меньше десятка, один go.mod проще. И главный индикатор здоровья: добавление фичи не должно требовать правок в platform. Если платформа начала меняться под каждую фичу — сквозное и доменное перепутались, и стоит перечитать закон импортов.

**Вопрос:** В команде появился второй сервис tasks.v1. Что происходит с mux и портом?

- Нужен второй порт и второй процесс сразу
- ✅ Ещё один конструктор NewTaskServiceHandler и mux.Handle на том же mux; порт один
- Задачи объединяют с заметками в один proto-файл
- Для задач нужен отдельный go.mod и отдельный деплой

Пути процедур выведены из package и имени сервиса и не пересекаются: столько конструкторов, сколько схем, — канон третьего урока. Второй процесс оправдан независимым жизненным циклом и деплоем, а не просто наличием второго сервиса. Имя сервиса и версия — часть пути, так что v2 тоже соседствует без конфликтов.

> **Три ошибки раскладки.** Правки в gen/ «пока не готова генерация» — затрутся и разъедутся по команде; правь схему. Buf-тулчейн в runtime-образе — генерация живёт в CI, деплой из двадцать второго урока везёт только бинарник. Импорт notesv1 в internal/platform — начало конца: платформа обрастает доменом, импорт-цикл становится делом времени, и вычленить сквозное из доменного потом не сможет ни один рефакторинг-инструмент.

> **Makefile как оглавление проекта.** Четыре цели — make lint, make generate, make test, make run — рассказывают новому человеку весь жизненный цикл без чтения документации. Внутри — buf lint, buf generate, go test -race ./... и go run ./cmd/notes. CI вызывает те же цели: одна правда о сборке и локально, и в пайплайне, а docker build из двадцать второго урока просто наступает на этот же Makefile.

> **Как проходить этот курс.** Код урока — настоящий шаблон: перенеси его в редактор, разложи notes-сервис из третьего урока по этажам и добейся зелёного go build. Браузерная песочница файловую систему не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP, а раскладку проверяет компилятор: неверный импорт или незакрытый метод он не простит.

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

- **Пять этажей шаблона** — proto/ — контракт, gen/ — генерат, internal/notes — service-слой, internal/platform — сквозное, cmd/notes — композиционный корень. Плюс buf.yaml и buf.gen.yaml в корне и go.mod с модулем.
- **Закон импортов** — cmd → все; internal/notes → gen; internal/platform → только connect; gen не знает internal. gen импортируют лишь cmd и notes — иначе код лежит не в своём этаже, и цикл импортов становится делом времени.
- **Коммитить ли gen/** — Учебный проект — коммить: меньше движущихся частей. Взрослая политика: gen в .gitignore, buf generate в CI, remote-плагины с закреплённой версией. Одно правило на всю команду, записанное в contributing.
- **Платформа против service-слоя** — Platform не импортирует notesv1: интерсепторы, метрики, логи работают с Any-типами и req.Spec().Procedure. Понадобился домен в platform — код лежит не там. Service-слой, наоборот, импортирует gen и маппит sentinel-ошибки на коды.
- **Второй сервис и вторая версия** — Новая схема — новый пакет, конструктор и mux.Handle на том же mux и порту; пути процедур не пересекаются. v2 рядом с v1 на одном mux, общий домен с конвертацией; контракты наружу — BSR с метками.
- **Makefile и CI** — Четыре цели: lint, generate, test, run — те же команды в пайплайне. Генерация и buf breaking живут в CI, не на памяти коллег; runtime-образ получает только бинарник без тулчейна.

### Что дальше по курсу

Раскладка готова, и шаблон уже ждёт первую поломку: в двадцать четвёртом уроке — типичные ошибки и отладка curl-режимом, grpcurl и buf curl, карта симптомов от 404 до resource_exhausted и трассировка проблемного вызова по меткам и спанам. Финал курса — двадцать пятый урок: итоговый проект, который собирает всё в один Notes API. Для повторения: скелет и первые конфиги — во [втором уроке](/courses/connect/lessons/02-ustanovka/), два сервиса на одном mux — в [третьем](/courses/connect/lessons/03-pervyj-servis/), lint и breaking — в [десятом](/courses/connect/lessons/10-buf-lint/), генерация и remote-плагины — в [одиннадцатом](/courses/connect/lessons/11-buf-generate/), модули BSR — в [двенадцатом](/courses/connect/lessons/12-bsr/), рефлексия и дескрипторы — в [пятнадцатом](/courses/connect/lessons/15-dokumentaciya/), версии схем — в [восемнадцатом](/courses/connect/lessons/18-versii/), общий mux с пробами и h2c — в [девятнадцатом](/courses/connect/lessons/19-net-http/). Программа всех двадцати пяти уроков — на [странице курса](/courses/connect/).

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

- Рабочая структура: proto/ контракт, gen/ генерат, internal/notes — service-слой, internal/platform — сквозное, cmd/notes — композиционный корень.
- buf.yaml делает proto/ модулем: путь и версия пакета — часть каталога; lint STANDARD и breaking FILE — политика CI.
- gen/ — артефакт: правки затираются; коммить или генерируй в CI — одно правило на команду, remote-плагины дают воспроизводимость.
- Service-слой: notesServer реализует handler-интерфейс, хранилище за интерфейсом Store, sentinel-ошибки маппятся в коды на границе.
- Platform не знает домена: auth, метрики, логи, CORS и рефлексия работают с Any-типами и req.Spec().Procedure.
- Закон импортов: gen импортируют только cmd и notes; platform без notesv1; цикл невозможен, пока стрелки не развернуты.
- Рост без боли: второй сервис — конструктор и Handle на том же mux; v2 рядом с v1; контракт наружу — BSR.
- Makefile с целями lint, generate, test, run — одна правда о сборке локально и в CI; тулчейн не попадает в runtime-образ.

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

Переложи notes-сервис из третьего урока в рабочий шаблон. Шаг первый: создай каталоги internal/notes и internal/platform, вынеси notesServer в internal/notes/server.go, добавь интерфейс Store с sentinel-ошибками и in-memory реализацией. Шаг второй: перенеси auth-интерсептор из тринадцатого урока в internal/platform/auth.go и добейся, чтобы пакет platform собирался без импорта notesv1. Шаг третий: собери cmd/notes/main.go с healthz, readyz, metrics, опциями лимитов из четырнадцатого урока и h2c — сервис должен запускаться одной командой go run ./cmd/notes. Шаг четвёртый: добавь Makefile с целями lint, generate, test, run и прогони их по очереди. Шаг пятый, исследовательский: попробуй импортировать notesv1 из platform и прочитай ошибку компилятора; затем попробуй вызвать platform-код из notes — убедись, что закон импортов держит тебя сильнее, чем код-ревью. Зафиксируй выбранную политику по gen/ в README: коммит или генерация в CI.

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

### Коммитить gen/ или генерировать в CI?

Обе политики рабочие, выбирается одна на команду. Учебный проект — коммить: клон и сборка без тулчейна. Взрослая — gen в .gitignore, buf generate в CI, remote-плагины с закреплённой версией: чистые диффы и воспроизводимость. Опасно только гибрид, когда часть файлов коммитится, а часть генерируется.

### Один репозиторий на сервис или монорепозиторий?

Начинай с одного репозитория и одного go.mod: пока сервисов меньше пяти, цена монорепо — издержки, а польза — атомарные PR с контрактом и кодом. Когда сервисы разъезжаются, контракты выносятся в BSR, и потребители берут схему зависимостью из двенадцатого урока. go.work нужен, только если модулей Go стало несколько.

### Где должны лежать тесты?

Юнит и table-driven матрицы кодов — рядом с пакетом: internal/notes/server_test.go собирается без сети и прогоняется с -race. Полностековые тесты на httptest.NewServer — там же или в cmd. Интеграционные с реальными зависимостями — в отдельном каталоге с меткой сборки, чтобы go test ./... оставался быстрым.
