Pasted image 20251117204228.png

Я пишу всякое на Go в Ви.Tech (IT-дочка ВсеИнструменты.ру) и как и все, люблю подискутировать на технические темы.

У этой заметки сложная судьба, мне загорелось написать ее еще летом, но совершенно не хотел говорить об очевидных вещах и писать миллион первую статью со ссылкой на гугловский go code review comments. Тема уже разобрана всеми кому не лень, на русском языке вот у  Николая @JustSkiv Тузова, есть замечательное видео на его ютуб канале, раскладывающее по полочкам, для чего это нужно.

Последний дисклеймер и перейду к сути: тема на самом деле очень обширна и я сознательно сконцентрировался на одном аспекте (неуместные определения интерфейсов). Буду рад, если продолжим общение в комментариях, очень нехватает хабра начала 10-х годов, с живыми, а иногда и крайне горячими, инженерными дискуссиями.

Правило о размещении интерфейсов в Go впервые сформулировано как идиома «Интерфейсы принадлежат потребителю». Такой подход способствует уменьшению связанности между компонентами, упрощает тестирование, облегчает мокирование и поддерживает принципы SOLID.

Эта идиома стала частью философии Go и была распространена с самого начала активного развития Go примерно с 2009-2010 годов, когда Роб Пайк, Роберт Гризмер и Кен Томпсонон формировали основные рекомендации по стилю и архитектуре кода. Правило активно объяснялось и продвигалось в официальных материалах и статьях по Go, например, в материалах по ревью кода (Go CodeReviewComments) и в статьях сообщества за последние 10-15 лет.

Зачем мы вообще начинаем вводить интерфейс

Я давно пишу на го и за все время, не встречал ни одного разработчика для которого go был бы первым и единственным ЯП. Это важный момент, так бэкграунд определенно накладывает отпечаток на дизайн наших решений.

Повседневый опыт рекомендует при планировании двигаться от общего к частному. Интерполируя на разработку: двигаться от абстракции к реализации - но вот только в Go так не делают

Мы со школьных лет привыкли мыслить в парадигме таких языков как delphi / java / c++, где во время компиляции создаётся. в том или ином виде, статическая таблица диспетчеризации и за счет явного указания, какие интерфейсы тип должен реализовывать, берутся указатели на методы

Это сидит где то на подкорке, подталкивая продумать и описать интерфейс, когда в нем еще нет абсолютно никакой нужды, начинать пакет с абстрактных типов и уже потом переходить к конкретным реализациям

//Вполне валидный код для джавы
public interface Storage {
    Item get(String id);
    void save(Item item);
}


public class SqlStorage implements Storage {
    @Override
    public Item get(String id) { ... }

    @Override
    public void save(Item item) { ... }
}

В гошке своя атмосфера, при присваивании интерфейсному значению создаётся отдельная таблица с указателями на методы для конкретного типа. Это само по себе интересная тема, которая выходит за рамки моей заметки. Понятно, что такой подход накладывает некоторый оверхед по производительности, зато дает больше гибкости и к этому сложнее привыкнуть, чем кажется

// А вот это уже, почти наверняка, плохое решение 
// но может быть оправдано если у нас в пакете есть еще реализации Storage
type Storage interface {
    Save(ctx context.Context, o Order) error
    Load(ctx context.Context, id uuid.UUID) (Order, error)
}

type PostgresStorage struct {
    db *sql.DB
}

func (s *PostgresStorage) Save(...) { … }
func (s *PostgresStorage) Load(...) { … }

Не буду перечислять, почему идея так себе, хочу только закинуть мысль, что начиная конструировать с описания интерфейса, мы не только делаем лишнюю работу но и загоняем себя в ловушку преждевременной абстракции, пребывая в иллюзии, что проектируем гибкую, готовую к изменениям, систему

Причин объявлять интерфейс меньше чем кажется

Может показаться естественным вынести интерфейс наружу и сделать его частью публичного контракта пакета. Это выглядит логично: раз у нас есть некоторая функциональность, то пусть она будет представлена интерфейсом, а реализацию мы спрячем за ним. Возвращаясь к примеру выше, такой подход привычен разработчикам, которые приходят из других языков, потому что там интерфейс это исходная точка проектирования.

Когда интерфейс становится публичным контрактом пакета, он перестаёт служить тому, для чего он создан - описывать потребности вызывающего кода. Вместо этого он начинает работать как абстрактный класс и навязывать свой API.

Так выглядит эволюция типичного интерфейса

type Storage interface {
    Save(...)
    Get(...)
}

Потом:

    Delete(...)
    Update(...)

Потом:

    Exists(...)
    Count(...)
    GetBySomeWeirdCriteria(...)

Ну и так далее, чем дальше, тем больше интерфейс разрастается и целиком он никому не нужен.

При этом часто считают, что если вы вернёте из конструктора конкретный тип, а не интерфейс, то раскроете реализацию. Но и это не так

///понятно что пример довольно искуственный, но будем придерживаться его для краткости 
type Storage struct {
    db *sql.DB
}

func New(db *sql.DB) *Storage {
    return &Storage{db: db}
}

func (s *Storage) Save(...) { … }
func (s *Storage) Load(...) { … }
func (s *storage) someInternalStuff(...) {...}

Пользователь не может изменить состояние вашей структуры, если вы не дали ему экспортируемые поля. Он не может вызвать приватные методы. Он не знает, как хранятся данные внутри, какие используются алгоритмы, как организованы кеши, транзакции или дополнительные уровни абстракции. Всё это остаётся полностью внутри пакета.

Возвращение конкретного типа в Go раскрывает лишь одно - набор экспортируемых методов, которые вы и так считаете безопасными и публичными.
Это уже и есть ваш API. Интерфейс здесь ничего не добавит, он не усиливает инкапсуляцию и не даёт защиты от неправильного использования.

Защита достигается приватностью полей и типами, а не интерфейсами. Всё, что пользователь увидит - это то, что вы и так хотели ему показать. Всё, что вы скрыли, остаётся скрытым.

Если у вас всего одна реализация и вы не пишете библиотеку/фреймфорк, предлагая внешним пакетам имплементировать интерфейс, для каких либо целей - скорее всего он вам и не нужен

То же самое относится и к тестируемости: не нужно предоставлять интерфейсы только для того, чтобы потребитель мог написать свои моки. Go way - предложить пользователю самому ввести интерфейс, который фокусируется на необходимых вызовах и уже самому решать, использовать, например, mockgen или сделать легковесную реализацию

// Ну вот например так, если нас интересуют только эти методы
type saverLoader interface {
    Save(ctx context.Context, item storage.Item) error
    Load(ctx context.Context, id string) (storage.Item, error)
}

type memStorage struct {
    savedItems map[string]storage.Item
}

  
func (m *mockStorage) Save(ctx context.Context, item storage.Item) error {
    m.savedItems[item.ID] = item

    return nil
}

func (m *mockStorage) Load(ctx context.Context, id string) (storage.Item, error) {
    item, ok := m.savedItems[id]
    if !ok {
        return storage.Item{}, nil
    }

    return item, nil
}

Интерфейс как внутренний контракт

Нужно некоторое время, чтобы адаптироваться и привыкнуть, что интерфейс это не средство наследования и не описание абстрактного класса. Это способ описать поведение, которое мы ожидаем от значения. И ключевое слово здесь - ожидаем.
Это делает интерфейсы в Go особенно выразительными: они определяются не для того, кто реализует поведение, а для того, кто им пользуется. В этом даже что -то поэтичное есть.

Когда интерфейс создаётся на всякий случай, для моков или для красивого представления контракта, он изначально лишен смысла и время на поддержку абстракции выброшено на ветер. Когда рождается из конкретной потребности, он делает код простым и надёжным.

Правило "определять интерфейсы по месту использования" - буквально следствие здравого проектирования. Оно помогает не только уменьшить связанность (что конечно важно), но и не вводить лишние абстракции. На последнем я делаю особенный акцент.

Go как язык устроен так, чтобы разработчик не создавал архитектуру на будущее. Отсутствие классов, наследования, обязательного объявления имплементации, все это не потому, что авторы языка что-то не доделали, а потому что они сознательно убрали механизмы, провоцирующие преждевременное проектирование.

Всем хорошего дня =)

Комментарии (11)


  1. xxxcoltxxx
    18.11.2025 18:20

    Если придерживаться такой концепции, то в будущем переписать много кода, чтобы что-нибудь протестировать. Ладно если свой проект. Но почему бы не сделать готовые интерфейсы для публичного пакета, чтобы это не делали люди сами?

    В основном, я согласен, что интерфейсы часто пихают везде, где нужно и где не нужно


    1. gudvinr
      18.11.2025 18:20

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

      Потому что это не джава.

      Вот вы используете библиотеку X и не планируете никогда переходить на другие имплементации. Зачем вам интерфейс нужен? Он просто бесполезным грузом будет и мешает инлайнить короткие функции заодно.

      Или, допустим, библиотеки A и B реализуют одну концепцию, но совсем по-разному. У них разные функции, с разными типами и разными аргументами. Пусть обе предоставляют интерфейс, но зачем он? Библиотека A будет описывать только свои методы, библиотека B только свои. Чтобы можно было их поменять, придётся все равно писать обёртку над одной или другой имплементацией.

      Интерфейс в Go - это, как в конце отмечено, инструмент вызывающего кода, а не имплементаций. И хороший интерфейс реализует минимальный набор функций, который необходим конкретно вызывающему коду.


      1. sl4mmer Автор
        18.11.2025 18:20

        в определенных случаях, вполне имеет место быть и интерфейс по месту реализации, чтоб далеко не ходить за примером context.Context


        1. gudvinr
          18.11.2025 18:20

          context.Context - интерфейс не поэтому, а потому что Background, WithValue, WithCancel и пр. возвращают разные типы.


          1. koleso_O
            18.11.2025 18:20

            Тут интерфейс как раз-таки по этому, своего рода забота о потребителях. Абстракция предоставлена за ранее, а не по месту использования. Так что я согласен, что интерфейс по месту объявления иногда имеет смысл.


    1. sl4mmer Автор
      18.11.2025 18:20

      Идея благая по намерению, но на практике готовый интерфейс очень редко совпадает с тем, что нужно тестам пользователя, лучше дать простой, прозрачный, конкретный тип.

      Хотя опять же, только ситхи все возводят в абсолют, если у нас пара тривиальных методов и больше не предвидится, вай нот


  1. Fardeadok
    18.11.2025 18:20

    Интерфейсы и предназначены чтобы вот такие умники не передавали структуры куда попало и потом удивлялись а че это там другой умник начал вызывать все методы подряд и расхерачил все данные внутри. Нет уж, вот вам через интерфейс только один безопасный метод и все


    1. gudvinr
      18.11.2025 18:20

      А зачем экспортировать то, что может что-то поломать? Никто не мешает умникам не использовать интерфейс или добавить в него запрещённые методы и продолжить расхерачивать.

      Пока методы доступны снаружи, интерфейсы от такого не спасут. Они нужны не для этого. Для того, чтобы не расхерачить - просто нельзя давать принципиальную возможность расхерачить.