Локальный интерфейс задаёт направление зависимости, но сам по себе не решает вопросы транзакций, преобразования данных и контроля импортов. Разберём эти ограничения на примере order, catalog и платёжного клиента.
Это четвёртая статья серии «Архитектура Go» и продолжение статьи «Границы модулей в Go: use case, порты и адаптеры». В предыдущей части checkout объявил локальные интерфейсы, order завёл собственные типы, а Composition Root связал их с конкретными реализациями. Здесь проверим, что происходит внутри репозитория и адаптеров.
Во всех примерах используется тот же условный Go-модуль modular_shop. Дальше понадобятся три интерфейса, объявленные рядом с checkout.UseCase:
// internal/domain/order/checkout/usecase.go
type orderRepository interface {
Create(
ctx context.Context,
params order.CreateOrderParams,
) (order.Order, error)
MarkPaid(
ctx context.Context,
orderID uuid.UUID,
paymentID string,
) error
}
type productPort interface {
GetProduct(
ctx context.Context,
productID uuid.UUID,
) (order.ProductSnapshot, error)
}
type paymentClient interface {
Charge(
ctx context.Context,
command order.ChargeCommand,
) (order.ChargeResult, error)
}
Имена интерфейсов остаются недоступны за пределами checkout, но компилятор проверяет сигнатуры при вызове checkout.New. Дальше по тексту эти сигнатуры служат критерием: реализация обязана совпасть с ними по типам, а не по смыслу.
Содержание
- Репозиторий преобразует модель хранения
- HTTP-вызов не входит в SQL-транзакцию
- Адаптер к чужому сервису
- Как автоматически проверять импорты
- Цена изоляции
- Когда изоляция становится избыточной
- Практическая проверка границы
Репозиторий преобразует модель хранения
Репозиторий order не должен возвращать наружу строку таблицы только потому, что использует GORM. Модель хранения остаётся приватной, а методы репозитория возвращают бизнес-типы и ошибки order.
Для операций хранения репозиторий использует order.ErrOrderNotFound и order.ErrOrderPersistenceFailed. Обе объявлены в errors.go пакета order рядом с ошибками адаптеров: реагирует на них тот же вызывающий код.
PostgreSQL-репозиторий реализует orderRepository целиком:
// internal/domain/order/repositories/postgres/repository.go
package postgres
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"modular_shop/internal/domain/order"
)
type Repository struct {
db *gorm.DB
}
func New(db *gorm.DB) *Repository {
return &Repository{db: db}
}
type orderRow struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
AccountID uuid.UUID `gorm:"type:uuid;not null"`
Status string `gorm:"not null"`
TotalCents int64 `gorm:"not null"`
PaymentID *string
CreatedAt time.Time
}
func (orderRow) TableName() string {
return "orders"
}
func (r *Repository) Create(
ctx context.Context,
params order.CreateOrderParams,
) (order.Order, error) {
row := orderRow{
ID: uuid.New(),
AccountID: params.AccountID,
Status: string(order.StatusPending),
TotalCents: params.TotalCents,
}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return order.Order{}, fmt.Errorf(
"create order %s for account %s: %w: %v",
row.ID,
params.AccountID,
order.ErrOrderPersistenceFailed,
err,
)
}
return mapOrder(row), nil
}
func (r *Repository) MarkPaid(
ctx context.Context,
orderID uuid.UUID,
paymentID string,
) error {
result := r.db.WithContext(ctx).
Model(&orderRow{}).
Where("id = ?", orderID).
Updates(map[string]any{
"status": string(order.StatusPaid),
"payment_id": paymentID,
})
if result.Error != nil {
return fmt.Errorf(
"mark order %s paid with payment %s: %w: %v",
orderID,
paymentID,
order.ErrOrderPersistenceFailed,
result.Error,
)
}
if result.RowsAffected == 0 {
return fmt.Errorf(
"mark order %s paid: %w",
orderID,
order.ErrOrderNotFound,
)
}
return nil
}
func mapOrder(row orderRow) order.Order {
return order.Order{
ID: row.ID,
AccountID: row.AccountID,
Status: order.Status(row.Status),
TotalCents: row.TotalCents,
CreatedAt: row.CreatedAt,
}
}
TableName задан явно, потому что по умолчанию GORM собирает имя из имени структуры и сделал бы из orderRow таблицу order_rows. Схему определяют миграции, а не приватный тип репозитория.
orderRow меняется вместе со схемой PostgreSQL. Пока mapOrder сохраняет контракт order.Order, checkout не требует правок. Теги GORM, nullable-типы и служебные поля базы не пересекают границу репозитория.
Обе операции возвращают ошибки order. Отсутствующий заказ превращается в order.ErrOrderNotFound, а сбой PostgreSQL в order.ErrOrderPersistenceFailed. Исходный текст ошибки базы добавляется через %v для диагностики, но errors.Is не раскрывает вызывающему коду ошибку GORM или драйвера.
Если приложение использует Transaction Manager, репозиторий вместо прямого r.db выбирает подключение из контекста. Механизм ExtractDB разобран в статье «Транзакции в Go через context». Он не меняет направление зависимостей: use case знает интерфейс менеджера, но не *gorm.DB.
HTTP-вызов не входит в SQL-транзакцию
PostgreSQL-репозиторий может выполнять запросы внутри общей транзакции. Платёжный сервис вызывается по HTTP и в эту транзакцию не входит. Одинаковый способ передачи зависимостей через интерфейсы этого различия не меняет:
PostgreSQL repository
может использовать общую SQL-транзакцию
PaymentAdapter и HTTP-клиент
не откатываются вместе с PostgreSQL
Для транзакционных этапов checkout.UseCase получает ещё одну локальную зависимость:
type transactionManager interface {
InTransaction(
ctx context.Context,
fn func(txCtx context.Context) error,
) error
}
В UseCase добавляется поле tm transactionManager. Остальные интерфейсы не меняются, а Composition Root передаёт конкретный Transaction Manager в конструктор вместе с остальными реализациями.
Как делать не стоит
В этом варианте запрос к платёжному сервису уходит внутри транзакции:
// internal/domain/order/checkout/usecase.go
func (uc *UseCase) Execute(
ctx context.Context,
command Command,
) (order.Order, error) {
product, err := uc.productRepo.GetProduct(ctx, command.ProductID)
if err != nil {
return order.Order{}, fmt.Errorf("get product: %w", err)
}
var result order.Order
err = uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
createdOrder, err := uc.orderRepo.Create(txCtx, order.CreateOrderParams{
AccountID: command.AccountID,
TotalCents: product.PriceCents,
})
if err != nil {
return fmt.Errorf("create order: %w", err)
}
payment, err := uc.paymentClient.Charge(txCtx, order.ChargeCommand{
IdempotencyKey: createdOrder.ID.String(),
AccountID: createdOrder.AccountID,
Amount: createdOrder.TotalCents,
})
if err != nil {
return fmt.Errorf("charge payment: %w", err)
}
if err := uc.orderRepo.MarkPaid(
txCtx,
createdOrder.ID,
payment.PaymentID,
); err != nil {
return fmt.Errorf("mark order paid: %w", err)
}
createdOrder.Status = order.StatusPaid
result = createdOrder
return nil
})
if err != nil {
return order.Order{}, err
}
return result, nil
}
Если Charge завершится успешно, а MarkPaid или последующий COMMIT вернёт ошибку, база данных откатит заказ. Списание во внешнем сервисе при этом останется. Пока выполняется HTTP-запрос, транзакция также удерживает соединение и возможные блокировки.
Альтернатива с явными этапами
Внешний вызов можно вынести между двумя короткими SQL-транзакциями. Заказ сначала сохраняется со статусом pending, затем приложение выполняет HTTP-запрос и отдельной транзакцией фиксирует результат:
// internal/domain/order/checkout/usecase.go
func (uc *UseCase) Execute(
ctx context.Context,
command Command,
) (order.Order, error) {
product, err := uc.productRepo.GetProduct(ctx, command.ProductID)
if err != nil {
return order.Order{}, fmt.Errorf("get product: %w", err)
}
createdOrder, err := uc.createPendingOrder(
ctx,
order.CreateOrderParams{
AccountID: command.AccountID,
TotalCents: product.PriceCents,
},
)
if err != nil {
return order.Order{}, fmt.Errorf("create pending order: %w", err)
}
payment, err := uc.paymentClient.Charge(ctx, order.ChargeCommand{
IdempotencyKey: createdOrder.ID.String(),
AccountID: createdOrder.AccountID,
Amount: createdOrder.TotalCents,
})
if err != nil {
return createdOrder, fmt.Errorf(
"charge order %s: %w",
createdOrder.ID,
err,
)
}
err = uc.markOrderPaid(ctx, createdOrder.ID, payment.PaymentID)
if err != nil {
return createdOrder, fmt.Errorf(
"mark order %s paid: %w",
createdOrder.ID,
err,
)
}
createdOrder.Status = order.StatusPaid
return createdOrder, nil
}
createPendingOrder и markOrderPaid скрывают детали сохранения, но каждый из них пока выполняет один запрос. Оборачивать одиночный INSERT или UPDATE в InTransaction не нужно: PostgreSQL выполняет отдельный запрос в неявной транзакции, а явные BEGIN и COMMIT добавят два round trip и дольше удержат соединение. Внутри обоих методов достаточно прямого вызова репозитория с исходным ctx.
Менеджер нужен, когда в этапе появляется вторая запись. Например, вместе со статусом заказа нужно поставить фоновую задачу на отправку чека:
// internal/domain/order/checkout/usecase.go
type receiptEnqueuer interface {
EnqueueReceipt(ctx context.Context, orderID uuid.UUID) error
}
func (uc *UseCase) markOrderPaid(
ctx context.Context,
orderID uuid.UUID,
paymentID string,
) error {
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
if err := uc.orderRepo.MarkPaid(
txCtx,
orderID,
paymentID,
); err != nil {
return err
}
return uc.receipts.EnqueueReceipt(txCtx, orderID)
})
}
Обе операции получают один txCtx, поэтому чек не уедет по заказу, статус которого откатился. Постановка задач в ту же транзакцию разобрана в статье «River в Go». Границу транзакции задаёт checkout.UseCase, а не репозиторий: только сценарий знает, какие записи обязаны фиксироваться вместе.
Во время Charge транзакция PostgreSQL не открыта ни в одном из вариантов.
Такой вариант тоже не становится атомарным. Процесс может остановиться после успешного Charge, но до markOrderPaid. Заказ остаётся в статусе pending, а завершить операцию должна повторная попытка.
Чтобы повтор не создал второе списание, Execute передаёт в ChargeCommand идентификатор заказа как IdempotencyKey. В DTO платёжного клиента это поле помечено json:"-", потому что провайдер ждёт его не в теле запроса, а в заголовке:
// internal/clients/paymentclient/client.go
func (c *Client) Charge(
ctx context.Context,
request ChargeRequest,
) (ChargeResponse, error) {
body, err := json.Marshal(request)
if err != nil {
return ChargeResponse{}, fmt.Errorf("marshal charge: %w", err)
}
httpRequest, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.baseURL+"/v1/charges",
bytes.NewReader(body),
)
if err != nil {
return ChargeResponse{}, fmt.Errorf("build charge: %w", err)
}
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("Idempotency-Key", request.IdempotencyKey)
return c.do(httpRequest)
}
Второй POST /v1/charges с тем же значением заголовка провайдер не выполняет заново: он отдаёт сохранённый результат первого запроса с тем же transaction_id. Поэтому фоновая пересылка или сверка может повторять Charge для существующего заказа, пока не получит ответ, а списание останется одно.
У этой защиты есть точная граница. Ключ живёт ровно столько, сколько живёт заказ в базе. Повторный клик пользователя проходит через createPendingOrder заново, получает новый uuid.UUID и, значит, новый ключ, поэтому от двух заказов подряд идентификатор заказа не спасает. Если защищать нужно и это, ключ должен приходить в checkout.Command из HTTP-слоя вместе с командой, а заказ создаваться уже с ним.
Повтор, сверка и компенсация зависят от правил конкретного платёжного сервиса: сколько он хранит ключ, отвечает ли ошибкой на тот же ключ с другим телом запроса. Интерфейс помогает заменить реализацию в коде, но распределённую транзакцию не добавляет.
Локальную транзакцию лучше ограничить короткой последовательностью запросов к одной базе. Если сценарий сочетает запрос к БД и внешний вызов, код должен явно показывать, что останется после сбоя каждого шага.
Адаптер к чужому сервису
CatalogAdapter из предыдущей части переводил catalog.Product в order.ProductSnapshot. Механика у платёжного адаптера та же: свои типы, свои ошибки, чужие детали не пересекают границу. Отличаются условия, в которых он это делает.
Catalog может принадлежать другой команде, но живёт он в том же репозитории и собирается вместе с order: контракт между модулями меняется по договорённости, и компилятор сразу видит обе стороны. Контракт платёжного сервиса меняется без вас и вне вашей сборки, и отсюда расходятся остальные отличия. Перевод идёт в обе стороны: CatalogAdapter.GetProduct принимал uuid.UUID и переводил только ответ, а PaymentAdapter.Charge принимает order.ChargeCommand, собирает из неё запрос в формате провайдера и лишь потом разбирает ответ обратно в типы order. Отсутствие ошибки перестаёт означать успех: err == nil подтверждает только то, что ответ дошёл и разобрался, а списались ли деньги, сказано в поле Status внутри этого ответа. Неудачный вызов оставляет след на той стороне: повторять его вслепую нельзя, отсюда разобранный выше idempotency key в заголовке запроса.
Платёжный клиент владеет контрактом внешнего API:
// internal/clients/paymentclient/types.go
package paymentclient
import "errors"
var ErrDeclined = errors.New("payment declined")
type ChargeRequest struct {
IdempotencyKey string `json:"-"`
CustomerID string `json:"customer_id"`
AmountCents int64 `json:"amount_cents"`
}
type ChargeResponse struct {
TransactionID string `json:"transaction_id"`
Status string `json:"status"`
ProviderCode string `json:"provider_code"`
}
Пакет order уже объявил собственные ChargeCommand, ChargeResult, ErrPaymentDeclined и ErrPaymentFailed. Адаптер явно связывает два контракта:
// internal/domain/order/adapters/payment.go
package adapters
import (
"context"
"errors"
"fmt"
"modular_shop/internal/clients/paymentclient"
"modular_shop/internal/domain/order"
)
type paymentAPI interface {
Charge(
ctx context.Context,
request paymentclient.ChargeRequest,
) (paymentclient.ChargeResponse, error)
}
type PaymentAdapter struct {
client paymentAPI
}
func NewPaymentAdapter(client paymentAPI) *PaymentAdapter {
return &PaymentAdapter{client: client}
}
func (a *PaymentAdapter) Charge(
ctx context.Context,
command order.ChargeCommand,
) (order.ChargeResult, error) {
response, err := a.client.Charge(ctx, paymentclient.ChargeRequest{
IdempotencyKey: command.IdempotencyKey,
CustomerID: command.AccountID.String(),
AmountCents: command.Amount,
})
if errors.Is(err, paymentclient.ErrDeclined) {
return order.ChargeResult{}, fmt.Errorf(
"charge payment %s: %w",
command.IdempotencyKey,
order.ErrPaymentDeclined,
)
}
if err != nil {
return order.ChargeResult{}, fmt.Errorf(
"charge payment %s: %w: %v",
command.IdempotencyKey,
order.ErrPaymentFailed,
err,
)
}
if response.Status != "captured" {
return order.ChargeResult{}, fmt.Errorf(
"charge payment %s: %w: unexpected status %q",
command.IdempotencyKey,
order.ErrPaymentFailed,
response.Status,
)
}
return order.ChargeResult{
PaymentID: response.TransactionID,
}, nil
}
paymentClient из checkout принимает типы order и описывает зависимость use case. Локальный paymentAPI из adapters принимает DTO внешнего клиента. Одинаковую операцию Charge они описывают на разных сторонах границы, а PaymentAdapter переводит один контракт в другой.
paymentAPI объявлен по тому же правилу, что и productCatalog у CatalogAdapter в предыдущей части: интерфейс описывает тот, кто его вызывает, и только то, что вызывает. У paymentclient.Client кроме Charge могут быть Refund, GetTransaction и настройка таймаутов, но PaymentAdapter про них не знает. Реализация у интерфейса при этом одна, и это нормально: он нужен не ради подмены в продакшене, а чтобы зафиксировать используемый набор методов.
ProviderCode не пересекает границу, потому что checkout его не использует. Внешняя ошибка paymentclient.ErrDeclined превращается в order.ErrPaymentDeclined, неизвестная ошибка клиента в order.ErrPaymentFailed. Правило обёртки то же, что у CatalogAdapter: чужой текст попадает в сообщение через %v, чужой тип в errors.Is не попадает.
Успешный ответ и успешный платёж
С catalog вопроса не возникало: вернулся catalog.Product, значит товар нашёлся. Здесь после двух проверок ошибок в листинге выше стоит третья ветка, response.Status != "captured". Провайдер отвечает 200 OK и на отклонённый платёж тоже, а разбирается это по телу ответа.
Проверка стоит до построения order.ChargeResult, поэтому наверх не уходит результат с пустым PaymentID и статусом, который никто не посмотрел. Список допустимых статусов это решение, а не перевод: authorized означает захолдированные, но не списанные деньги, и считать его успехом или нет, зависит от того, когда сценарий должен отдать заказ. Адаптер это единственное место, где такое решение записано явно.
Список статусов приходит из документации провайдера и меняется вместе с ней. Поэтому неизвестный статус попадает в order.ErrPaymentFailed с текстом самого статуса: незнакомое значение видно в логах как факт, а не как молчаливый успех.
Похожий код преобразования можно сгенерировать с помощью LLM. Ей достаточно передать определения paymentclient.ChargeRequest, paymentclient.ChargeResponse, order.ChargeCommand и order.ChargeResult, а затем попросить написать явное преобразование без reflection.
Результат всё равно нужно проверить вручную. Без дополнительного контекста модель не знает:
- одинаковы ли
AmountиAmountCents; - можно ли считать статус
authorizedуспешным; - допустим ли пустой
TransactionID; - какие ошибки переводить в
ErrPaymentDeclined, а какие вErrPaymentFailed.
Компилятор подтвердит совпадение типов, но не правильность этих решений.
Как автоматически проверять импорты
Компилятор проверяет наборы методов и запрещает циклы, но не мешает order напрямую импортировать catalog. На ревью такой импорт легко пропустить, поэтому правило стоит перенести в CI.
Depguard позволяет запрещать импорты для выбранных файлов. Правило состоит из набора файлов и списка запрещённых для них пакетов:
# .golangci.yml
linters-settings:
depguard:
rules:
checkout:
files:
- "**/internal/domain/order/checkout/*.go"
deny:
- pkg: "gorm.io/gorm"
desc: сценарий не зависит от ORM
- pkg: "modular_shop/internal/domain/catalog"
desc: доступ к catalog идёт через порт и адаптер
business-logic:
files:
- "**/internal/domain/**"
- "!**/internal/domain/*/adapters/*.go"
deny:
- pkg: "modular_shop/internal/clients"
desc: внешние клиенты доступны только адаптерам
Правило checkout закрывает конкретный пакет use case, а business-logic действует на весь internal/domain и делает исключение для адаптеров через отрицание в шаблоне. Формат настройки отличается у отдельно установленного Depguard и версии внутри golangci-lint, поэтому пример нужно адаптировать под выбранный способ запуска. После настройки запрещённый импорт завершит проверку CI с ошибкой, и ревьюеру не придётся помнить ограничения наизусть.
Можно добавить более точечные правила:
- use cases не импортируют
gorm.io/gorm; - пакеты бизнес-логики не импортируют
internal/clients, доступ к клиентам остаётся в*/adapters; - модули не импортируют пакеты хранения данных друг друга;
- только composition root и пакеты
*/adaptersимпортируют несколько модулей одновременно.
Тест с errors.Is проверит перевод sentinel-ошибки, но запрещённый импорт он не заметит. Для графа импортов нужен линтер.
Цена изоляции
Каждая дополнительная граница между слоями обычно приводит к появлению отдельных типов, мапперов, интерфейсов и кода сборки зависимостей. Это увеличивает boilerplate и стоимость изменений.
Похожие структуры
catalog.Product и order.ProductSnapshot могут содержать одинаковые поля. Это оправдано, если каталог и оформление заказа меняют их по разным причинам. Дублировать структуру перед каждым вызовом не нужно. Отдельный тип нужен там, где модули действительно способны развиваться независимо.
Иногда два модуля используют один и тот же бизнес-термин с одинаковыми правилами. Тогда тип можно вынести в небольшой общий пакет. В DDD такой пакет называют shared kernel. Складывать все модели в общий entities не стоит: со временем от него начинают зависеть почти все модули. Пока общий контракт не появился, дешевле держать два типа: разъехаться им ничто не мешает, а свести обратно можно в любой момент.
Больше интерфейсов
У разных сценариев могут появиться похожие узкие интерфейсы. Объединять их только ради порядка не нужно. Общий пакет interfaces оторвёт контракт от использующего его кода. Локальный интерфейс проще изменить или удалить вместе со сценарием.
Больше сборочного кода
Composition root растёт вместе с приложением, потому что все зависимости перечислены в одном месте. Когда ручная сборка разрастается до сотен повторяющихся строк, её можно разделить на конструкторы модулей.
Общие ресурсы иногда собирают в одну структуру:
// internal/app/infrastructure.go
type Infrastructure struct {
DB *gorm.DB
Jobs *river.Client[*sql.Tx]
Events *kafka.Producer
Payments *paymentclient.Client
}
Такую структуру можно собирать внутри composition root. Передавать весь Infrastructure каждому use case не следует. Иначе он превращается в service locator: реальные зависимости исчезают из конструктора, а модуль получает доступ ко всей инфраструктуре.
Когда изоляция становится избыточной
В небольшом CRUD-приложении один бизнес-тип и конкретный репозиторий могут быть достаточным решением. Дополнительный адаптер не окупается, если обе стороны меняются вместе, используют один смысл данных и не требуют отдельного тестирования.
Признаки преждевременной изоляции:
- интерфейс имеет одну реализацию, но у него пока нет потребителя, которому нужна подмена;
- два типа полностью повторяются и всегда меняются одновременно;
- адаптер только копирует десять полей без изменения смысла или формата;
- composition root стал сложнее самих сценариев;
- разработчики обходят границы, потому что не понимают, какую проблему они решают.
Начать можно с конкретных типов. Локальный интерфейс и отдельная модель появляются, когда возникает граница изменений: внешний сервис, другой функциональный модуль, модель хранения или необходимость изолированного unit-теста.
Практическая проверка границы
- Модель GORM не покидает PostgreSQL-репозиторий.
- DTO внешнего клиента не возвращается из локального интерфейса checkout.
- Репозитории и адаптеры оборачивают через
%wтолько ошибки своего модуля. - Текст неизвестной ошибки сохраняется через
%v, но её тип не попадает вerrors.Is. - HTTP-вызов не считается частью транзакции.
- Тесты покрывают все четыре уровня: сценарий, адаптер, клиент и репозиторий.
- Запрещённые импорты проверяются в CI.
- Дополнительные типы действительно защищают независимо меняющиеся части системы.
Протекающую границу видно по diff: правка одного поля затрагивает три пакета вместо одного. Чаще всего тип принадлежит не тому модулю, и его приходится менять с обеих сторон. Бывает, что адаптер копирует поля вместо перевода, и тогда переименование в чужом API проходит насквозь. Бывает, что интерфейс объявлен у поставщика, и потребитель получает методы, которые ему не нужны. Начинать разбор проще с владельца типа: он должен быть один.
Шестой пункт этого списка разобран отдельно: следующая часть посвящена тестам границы. Там собраны стабы для локальных интерфейсов, табличный unit-тест сценария, проверка перевода в адаптере и тест HTTP-клиента, а интеграционные тесты репозитория, транзакции и сквозного сценария вынесены в шестую часть.
Вернуться к предыдущей части.
Материалы
- официальные рекомендации Go по размещению интерфейсов;
- спецификация Go об интерфейсах и наборах методов;
- Depguard для проверки импортов;
- Gateway и Anti-Corruption Layer;
- use case, локальные интерфейсы и адаптеры;
- тесты для границ модулей;
- интеграционные тесты с PostgreSQL;
- sentinel-ошибки и принадлежность контракта;
- транзакции для нескольких репозиториев.