Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 21 из 25 ~20 мин +50 XP

Слой репозитория над коллекцией

Репозиторий mongodb go-сервиса — тонкий слой над коллекцией, который прячет драйвер за интерфейсом: сервис просит Create и ByEmail, а не InsertOne и bson.M. Соберём модели mongo из структур с bson-тегами, объявим интерфейс хранилища на стороне потребителя, реализуем его над *mongo.Collection с переводом ошибок драйвера в доменные — и получим бизнес-логику, которая тестируется фейком за миллисекунды, не поднимая базу.

Содержание урока · 10 разделов

Сценарий, с которого начинается любой средний сервис: хендлер регистрации вызывает 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 — то же кодирование, что драйвер выполняет на отправке и приёме, только без сети: байты в структуру и обратно. Пригодится для кэша, отладочного лога и быстрых тестов модели: свернул в байты, развернул обратно, сверил поля.

Модель пользователя и круговой маршалинг

GO
type 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.com

bson-теги решают, как поля лягут в документ: _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 — фейк для него пишется в три строки, а не в тридцать.

Интерфейс и доменные ошибки сервиса

GO
var (
        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, ни ошибки драйвера в сигнатурах нет. Реализацию сервис получает через конструктор и не знает, кто под капотом — живая база или фейк из теста.

Реализация: *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

GO
type 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

Чего в репозитории быть не должно#

Граница ломается по одним и тем же местам, и все они стоят отдельного абзаца. Первое — жизненный цикл клиента: репозиторий не вызывает ни 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

Восстановите метод репозитория: контекст, декодирование, перевод сентинела драйвера в доменную ошибку.

func (r *Users) ByEmail(ctx context.Context, email string) (User, error) { var u User err := r.coll.FindOne(, bson.M{"email": email}).(&u) if errors.(err, mongo.ErrNoDocuments) { return User{}, ErrNotFound } if err != nil { return User{}, fmt.("find user: %w", err) } return u, nil }

Соберите метод Create

Расставьте строки метода репозитория по порядку: вставка, перевод дубликата, обёртка остальных ошибок.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: слой репозитория

Термин

Модель mongo

нажмите, чтобы перевернуть
Определение

Структура с bson-тегами, в которой живёт документ: bson.ObjectID для _id с omitempty, time.Time для дат, omitempty и minsize для необязательных полей. Кодируется драйвером без ручного bson.M.

1 / 6

Слой собран: модели 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. В конце нарочно верните ошибку драйвера из репозитория и убедитесь, что тест сервиса сломался без базы, — это лучший аргумент за трансляцию ошибок на границе.

Личные заметкиПусто
Хранятся только в вашем браузере (MongoDB · Слой репозитория над коллекцией)

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

Нужен ли слой репозитория в маленьком сервисе?

Смотри на число потребителей драйвера. Один хендлер и три запроса — честнее писать напрямую и не плодить слои. Как только бизнес-правил становится больше пяти и появляются тесты логики, граница окупается: сервис перестаёт зависеть от mongo, а тесты от базы. Вводится слой за час, выводится — за день.

Где объявлять доменные ошибки — в пакете сервиса или репозитория?

Там, где живёт интерфейс, то есть у потребителя. Ошибки — часть контракта: сервис определяет ErrNotFound и ErrEmailTaken, репозиторий их возвращает. Если объявить их в пакете репозитория, сервис начнёт импортировать реализацию ради констант — та же протечка границы, что и с ошибками драйвера.

Стоит ли делать общий интерфейс на все коллекции?

Нет. Generic-хранилище с методами на все случаи жизни превращается во второй ORM: фильтры как карты, типы через any, тестируемость формальная. Лучше маленький интерфейс на каждую коллекцию под конкретного потребителя: UserStore с двумя методами полезнее GenericStore с двадцатью.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

zerolog25 уроков~12 чпрактика25 уроков уже доступны

Zerolog в Go: быстрое JSON-логирование

Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.

  • Писать структурированные JSON-логи с нуля аллокаций
  • Выбирать уровни и поля события осознанно
  • Настраивать ConsoleWriter для dev и JSON для прода
  • Строить сублоггеры и контекстные поля на запрос