Содержание урока · 10 разделов
- Модели mongo: структуры с bson-тегами
- Интерфейс хранилища: контракт потребителя
- Реализация: *Collection за конструктором
- Тестируемость: фейк вместо базы
- Границы слоёв одним взглядом
- Чего в репозитории быть не должно
- Правила слоя репозитория одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сценарий, с которого начинается любой средний сервис: хендлер регистрации вызывает client.Database("shop").Collection("users") прямо в своём коде, строит bson.M по полям запроса и сам разбирает ошибки драйвера. На трёх хендлерах это терпимо, на тридцати превращается в копипасту: фильтры размазаны по файлам, тест на бизнес-правило требует живой MongoDB, а переименование поля в коллекции ловится только на проде. Слой репозитория решает все четыре проблемы одним движением: работа с коллекцией собирается в одном месте, сервис общается с хранилищем через маленький интерфейс, а тесты подсовывают фейк вместо базы. Репозиторий mongodb go-кода — не церемония ради церемонии, а граница: всё, что знает о драйвере, живёт по эту сторону, всё, что знает о бизнесе, — по ту. В этом уроке соберём слой по шагам: модели mongo из структур с bson-тегами, интерфейс хранилища с доменными ошибками, реализацию над *mongo.Collection и фейк для быстрых тестов. Карта ошибок из двадцатого урока ляжет сюда как родная: именно репозиторий решает, какая ошибка драйвера уйдёт наружу, а какая останется внутри.
Модели mongo: структуры с bson-тегами#
Модель — обычная структура Go, каждое поле которой подписано bson-тегом. Для пользователя это выглядит так: ID типа bson.ObjectID с тегом _id и omitempty — драйвер сам сгенерирует идентификатор при кодировании, когда поле нулевое, и вернёт его в InsertedID, не трогая исходную модель; Email с тегом email — обязательное бизнес-поле; Name с omitempty — необязательное; Age с omitempty и minsize — целое, которое драйвер упакует в 32 бита, когда значение помещается; CreatedAt типа time.Time — драйвер кодирует её в BSON-дату без ручных преобразований. Правило пятого урока остаётся в силе: omitempty не пишет поле, когда оно равно нулевому значению — пустой строке, нулю, nil у указателя, — и если ноль и «нет значения» нужно различать, поле объявляется указателем. Живут модели отдельно от хендлеров и от реализаций: свой файл или пакет домена, где нет ни одного *mongo.Collection. Импорт bson ради ObjectID и тегов — приемлемая плата за честные типы. bson.Marshal и bson.Unmarshal — то же кодирование, что драйвер выполняет на отправке и приёме, только без сети: байты в структуру и обратно. Пригодится для кэша, отладочного лога и быстрых тестов модели: свернул в байты, развернул обратно, сверил поля.
Модель пользователя и круговой маршалинг
GOtype User struct {
ID bson.ObjectID `bson:"_id,omitempty"`
Email string `bson:"email"`
Name string `bson:"name,omitempty"`
Age int `bson:"age,omitempty,minsize"`
CreatedAt time.Time `bson:"created_at"`
}
raw, err := bson.Marshal(User{Email: "anna@example.com", CreatedAt: time.Now()})
if err != nil {
return fmt.Errorf("marshal user: %w", err)
}
var back User
if err := bson.Unmarshal(raw, &back); err != nil {
return fmt.Errorf("unmarshal user: %w", err)
}
fmt.Println(back.Email) // anna@example.combson-теги решают, как поля лягут в документ: _id с omitempty оставляет генерацию ObjectID драйверу на вставке, minsize пакует целое в 32 бита, когда помещается, time.Time превращается в BSON-дату. bson.Marshal и bson.Unmarshal — то же кодирование, что делает драйвер на проводе, только без сети: удобно для логов, кэшей и быстрых тестов модели.
Интерфейс хранилища: контракт потребителя#
Интерфейс объявляет тот, кто им пользуется. В Go интерфейсы удовлетворяются неявно: реализация из следующего раздела станет UserStore без единого упоминания этого факта в своём коде. Методы — строго по потребности сервиса: Create и ByEmail, а не десять операций CRUD на всякий случай. Первым аргументом всегда context.Context: дедлайн HTTP-запроса должен доезжать до базы и отменять операцию, иначе горутины копятся на каждом медленном запросе. Сигнатуры говорят на языке домена: модели на входе и выходе, доменные ошибки наружу. Это и есть главный тест на качество границы: если в интерфейсе появился *mongo.Cursor, bson.M или тип ошибки драйвера, граница протекла, и тестировать без базы уже не выйдет. Доменные сентинелы — ErrNotFound и ErrEmailTaken — часть контракта: сервис различает «пользователя нет» и «адрес занят» без единого импорта mongo. Маленькие интерфейсы лучше больших: второму сервису, который только читает, объявите узкий контракт с одним ByEmail — фейк для него пишется в три строки, а не в тридцать.
Интерфейс и доменные ошибки сервиса
GOvar (
ErrNotFound = errors.New("user not found")
ErrEmailTaken = errors.New("email already taken")
)
type UserStore interface {
Create(ctx context.Context, u User) error
ByEmail(ctx context.Context, email string) (User, error)
}Доменные ошибки объявляются рядом с интерфейсом — они часть контракта. Интерфейс живёт в пакете сервиса и знает только модели и context.Context: ни *mongo.Collection, ни bson, ни ошибки драйвера в сигнатурах нет. Реализацию сервис получает через конструктор и не знает, кто под капотом — живая база или фейк из теста.
Хендлер регистрации отдавал 500 на занятый email, хотя случай ровно тот, ради которого существует 409. Разбор показал: репозиторий возвращал ошибку драйвера как есть, и сервис проверял mongo.IsDuplicateKeyError сам. Если ошибка драйвера пересекает границу репозитория, то сервис и хендлер импортируют mongo, а юнит-тест сервиса без базы невозможен: фейк не умеет строить WriteException. Переводите ошибки в доменные внутри репозитория и всегда заворачивайте через fmt.Errorf с %w — цепочка для errors.Is останется целой, как в двадцатом уроке.
Реализация: *Collection за конструктором#
Реализация — структура с одним полем coll mongo.Collection. Конструктор NewUsers принимает mongo.Database и возвращает конкретную структуру, а не интерфейс: правило accept interfaces, return structs — интерфейс нужен потребителю, а не поставщику. Create вызывает InsertOne с моделью как есть: драйвер закодирует её по bson-тегам, сгенерирует _id благодаря omitempty и вернёт InsertOneResult, чей InsertedID здесь не нужен — идентификатор уже внутри модели не появится, и если он требуется сервису, метод возвращает модель или ObjectID явно. Дубликат по уникальному индексу ловится mongo.IsDuplicateKeyError и уходит наружу ErrEmailTaken; всё остальное заворачивается в fmt.Errorf с %w. ByEmail декодирует FindOne прямо в модель: пустая выборка — штатный ErrNoDocuments, и он превращается в ErrNotFound. Списки читаются тем же Find из седьмого урока: cursor.All декодирует сразу срез моделей []User. Дисциплина контекста: репозиторий никогда не создаёт context.Background сам — контекст приходит от вызывающего и уходит в драйвер без изменений, иначе отмена и дедлайны запроса перестанут действовать на середине пути. Состояния у репозитория нет: только дескриптор коллекции, поэтому один экземпляр безопасно обслуживает все горутины.
MongoUsers: Create и ByEmail
GOtype Users struct{ coll *mongo.Collection }
func NewUsers(db *mongo.Database) *Users {
return &Users{coll: db.Collection("users")}
}
func (r *Users) Create(ctx context.Context, u User) error {
_, err := r.coll.InsertOne(ctx, u)
if mongo.IsDuplicateKeyError(err) {
return ErrEmailTaken
}
if err != nil {
return fmt.Errorf("create user: %w", err)
}
return nil
}
func (r *Users) ByEmail(ctx context.Context, email string) (User, error) {
var u User
err := r.coll.FindOne(ctx, bson.M{"email": email}).Decode(&u)
if errors.Is(err, mongo.ErrNoDocuments) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("find user: %w", err)
}
return u, nil
}Репозиторий — обычная структура с Collection внутри; конструктор принимает Database и забирает имя коллекции. Create вставляет модель целиком: драйвер кодирует её по bson-тегам. ByEmail декодирует документ в модель и переводит два сентинела драйвера — ErrNoDocuments и дубликат по уникальному индексу — в доменные ErrNotFound и ErrEmailTaken, которые знает сервис.
Тестируемость: фейк вместо базы#
Фейк — вторая реализация того же интерфейса, хранящая данные в памяти: срез или карта, методы дописывают и ищут, ошибки возвращаются запрограммированные. Сервис тестируется целиком: подали User — проверили, что фейк получил запись; научили фейк возвращать ErrEmailTaken — проверили, что сервис честно переходит в ветку ошибки. Каждый такой тест занимает миллисекунды и не требует запущенной MongoDB и сети, поэтому регрессия бизнес-логики ловится на каждый коммит, а не по выходным в отдельной джобе. Распределение ролей простое: фейк отвечает за логику сервиса, живая база — за запросы. Фильтр FindOne, уникальный индекс и транзакция фейком не проверяются — их место в интеграционных тестах с настоящей MongoDB, о которых рассказывается в уроке двадцать третьем. Фейк обязан быть честным: если живая реализация возвращает ErrEmailTaken на занятом адресе, фейк должен уметь возвращать его по условию — иначе тесты проверяют выдуманный контракт, а не реальный. И не тащите бизнес-правила в репозиторий: «нельзя регистрировать несовершеннолетних» — это сервис, «как найти пользователя по email» — это репозиторий. Как только правило пересекло границу, протестировать его без базы снова стало нельзя.
Границы слоёв одним взглядом#
| Слой | Знает о | Возвращает |
|---|---|---|
| Хендлер | сервис и его интерфейсы | HTTP-статусы и ответы |
| Сервис | интерфейс хранилища, модели домена | доменные ошибки: ErrNotFound, ErrEmailTaken |
| Репозиторий | *Collection, модели, bson, предикаты | доменные ошибки и модели |
| Драйвер | протокол, пул соединений, BSON | ошибки драйвера: WriteException, ErrNoDocuments |
Команда объявила UserStore в пакете репозитория — рядом с реализацией, так казалось логичнее. Через месяц поймали цикл импортов: сервису понадобился интерфейс, а пакету репозитория — домен сервиса. Если интерфейс объявляет поставщик, он раздувается до общего знаменателя всех потребителей, а подмена фейком тянет лишние зависимости в тесты. Объявляйте контракт в пакете потребителя с методами по потребности: поставщик узнает о нём неявно, а общий интерфейс появится, только когда его попросит второй потребитель.
Чего в репозитории быть не должно#
Граница ломается по одним и тем же местам, и все они стоят отдельного абзаца. Первое — жизненный цикл клиента: репозиторий не вызывает ни mongo.Connect, ни Disconnect, клиент собирается один раз на старте приложения и передаётся вниз через Database; закрытие — обязанность главного, а не каждого хранилища. Второе — god-интерфейс: GenericStore с двадцатью методами и фильтрами как картами превращает фейки в фикцию — тестировать такой контракт честно нельзя. Третье — курсор наружу: метод, возвращающий mongo.Cursor или *SingleResult, снова делает потребителя зависимым от драйвера; наружу уходят срезы моделей. Четвёртое — кэш внутри репозитория: как только хранилище держит состояние, invalidate между инстансами становится отдельной задачей, а тесты — недетерминированными; кэш — отдельный слой. Пятое — бизнес-решения: «заблокировать пользователя за три неудачи» — это сервис, репозиторий лишь честно выполняет то, что ему сказали. Каждый пункт здесь — та же история: то, что просочилось через границу, нельзя проверить без живой базы, и чем позже это обнаружится, тем дороже
Правила слоя репозитория одним списком#
- Модели mongo — структуры с bson-тегами; bson.M остаётся для разовых скриптов и сырых фильтров.
- Интерфейс хранилища объявляет потребитель; реализация возвращает конкретную структуру.
- В сигнатурах интерфейса нет mongo-типов: только модели, context.Context и доменные ошибки.
- Ошибки драйвера переводятся внутри репозитория: IsDuplicateKeyError в ErrEmailTaken, ErrNoDocuments в ErrNotFound.
- Контекст не создаётся внутри репозитория: он приходит от вызывающего и уходит в драйвер без изменений.
- Фейк тестирует логику сервиса за миллисекунды; живая база — отдельные интеграционные тесты.
Проверка: пустая выборка
Что возвращает репозиторий, когда FindOne не нашёл документ?
Проверка: где живёт интерфейс
Где объявляют интерфейс хранилища?
Что напечатает тест сервиса?
Прочитайте код и выберите, что он выведет:
type fakeStore struct{ saved []User }
func (f *fakeStore) Create(ctx context.Context, u User) error {
f.saved = append(f.saved, u)
return nil
}
func (f *fakeStore) ByEmail(ctx context.Context, email string) (User, error) {
return User{}, ErrNotFound
}
svc := &Registration{store: &fakeStore{}}
err := svc.Register(ctx, User{Email: "anna@example.com"})
fmt.Println(len(svc.store.saved), err)Соберите метод ByEmail
Восстановите метод репозитория: контекст, декодирование, перевод сентинела драйвера в доменную ошибку.
Соберите метод Create
Расставьте строки метода репозитория по порядку: вставка, перевод дубликата, обёртка остальных ошибок.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: слой репозитория
Слой собран: модели mongo с bson-тегами, интерфейс у сервиса, реализация над коллекцией, фейк для тестов. Дальше по курсу эта конструкция работает сама: в уроке про тесты фейк вырастет в полноценные моки, а тестовая база возьмёт на себя проверку самих запросов; там же выяснится, какие тесты оставляют на живой MongoDB. Карта ошибок, которые репозиторий переводит на границе, разобрана в уроке про ошибки драйвера — держите её под рукой, когда решаете, что возвращать наружу, а что завернуть в %w. Справочник по bson-тегам — omitempty, minsize, inline и тире — живёт в пятом уроке: модели только что собранной формы сверяйте по нему. Тем, кто пишет и под Postgres, полезно провести одну параллель: уникальность 23505 в pgx лечится точно так же — доменная ошибка на границе хранилища, слой тестируется без SQL. Проверьте слой руками: песочница сайта не исполняет mongo-go-driver, поэтому поднимите MongoDB локально в Docker из второго урока, соберите две реализации UserStore и прогоните сервис с фейком и с живой коллекцией — поведение обязано совпасть во всём, кроме скорости.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Репозиторий — граница: всё, что знает о драйвере, живёт по одну сторону, бизнес — по другую.
- Модели mongo — структуры с bson-тегами: bson.ObjectID с omitempty, time.Time, minsize для компактных целых.
- Интерфейс хранилища объявляет потребитель: модели, context.Context и доменные ошибки, никаких mongo-типов.
- Ошибки драйвера переводятся внутри репозитория: IsDuplicateKeyError в ErrEmailTaken, ErrNoDocuments в ErrNotFound.
- Реализация — структура с *Collection и конструктором от *Database; возвращается конкретный тип, не интерфейс.
- Контекст приходит от вызывающего: репозиторий не создаёт context.Background и не рвёт цепочку отмены.
- Фейк-реализация интерфейса тестирует логику сервиса за миллисекунды; запросы проверяет живая база из двадцать третьего урока.
- Бизнес-правила остаются в сервисе: как только правило ушло в репозиторий, протестировать его без базы нельзя.
Домашнее задание
Соберите UserStore, MongoUsers и фейк из урока, затем напишите три теста сервиса без базы: успешная регистрация через фейк, ветка ErrEmailTaken и ветка ErrNotFound. Поднимите живую MongoDB и прогоните тот же сервис на MongoUsers — поведение должно совпасть. Добавьте метод Count(ctx) (int64, error) так, чтобы интерфейс сервиса не изменился: сначала узкий контракт у потребителя, потом реализация через CountDocuments. В конце нарочно верните ошибку драйвера из репозитория и убедитесь, что тест сервиса сломался без базы, — это лучший аргумент за трансляцию ошибок на границе.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли слой репозитория в маленьком сервисе?
Смотри на число потребителей драйвера. Один хендлер и три запроса — честнее писать напрямую и не плодить слои. Как только бизнес-правил становится больше пяти и появляются тесты логики, граница окупается: сервис перестаёт зависеть от mongo, а тесты от базы. Вводится слой за час, выводится — за день.
Где объявлять доменные ошибки — в пакете сервиса или репозитория?
Там, где живёт интерфейс, то есть у потребителя. Ошибки — часть контракта: сервис определяет ErrNotFound и ErrEmailTaken, репозиторий их возвращает. Если объявить их в пакете репозитория, сервис начнёт импортировать реализацию ради констант — та же протечка границы, что и с ошибками драйвера.
Стоит ли делать общий интерфейс на все коллекции?
Нет. Generic-хранилище с методами на все случаи жизни превращается во второй ORM: фильтры как карты, типы через any, тестируемость формальная. Лучше маленький интерфейс на каждую коллекцию под конкретного потребителя: UserStore с двумя методами полезнее GenericStore с двадцатью.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос