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

Парадигма и инструменты языка

Я несколько раз встречал мнение, что го не ООП-язык. И поэтому прежде всего договоримся о том, что такое ООП.

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

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

Когда-то в пхп я писал:

$conn = mysql_connect('...');
$result = mysql_query('select ...', $conn);

Тут вместо класса используется префикс функции, а состояние передаётся как аргумент. Не так прекрасно, как с классами, но определённо объектно-ориентированно.

Приёмы

Проектирование

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

UML

Диаграммы очень выразительны, используйте их при проектировании. На диаграммах классов и последовательности удобно проектировать границы ответственности сервисов и классов. Их легко рисовать от руки или записывать в [plantuml](https://plantuml.com/). Диаграммы деятельности или блок-схемы иногда удобнее записать псевдокодом.

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

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

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

Сначала интерфейс, потом реализация

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

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

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

func orderPrice(buns []Bun, d Discount) {
    sum := 0
    for _, b := range buns {
        sum += b.Price(d)
    }

    // ...
}

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

type Discount interface {
    Price(price int, bakedAt time.Time, bunType BunType) int
}

h := NewHappyHour(HappyHourConf{
    Interval: "20:00-21:00", // собственно счастливый час
    BakedBefore: "6h", // скидка применяется только для засохших булочек
    Discount: 0.5, // размер скидки
    Exclusions: []BunType{SuperLuxuryBun}, // налог на роскошь
})

Вот уже что-то вырисовывается. Важно понимать, что проектируем мы по конкретным требованиям (без заделов на будущее и не для абстрактной пекарни). Если требования предельно просты, то и решение должно быть простым. Тут я вообразил довольно сложные условия предоставления скидки, чтобы было на что посмотреть.

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

Реализация

Объекты с поведением

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

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

Data-объекты

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

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

package orders

const (
	StatusNew       = "new"
	StatusConfirmed = "confirmed"
	// ...
)

type deliveryClient interface {
	Create(ctx context.Context, t time.Time, orderId string) (trackId string, err error)
}

type OrderData struct {
	Id        string
	Status    string
	TrackId   string
	CreatedAt time.Time
	UpdatedAt time.Time
}

type Order struct {
	OrderData
	delivery deliveryClient
}

func NewOrder(data OrderData, delivery deliveryClient) *Order {
	return &Order{
		OrderData: data,
		delivery:  delivery,
	}
}

func CreateOrder(now time.Time, delivery deliveryClient) (*Order, error) {
	id, err := uuid.NewV7()
	if err != nil {
		return nil, fmt.Errorf("uuid.NewV7: %w", err)
	}

	return NewOrder(
		OrderData{
			Id:        id.String(),
			Status:    StatusNew,
			CreatedAt: now,
			UpdatedAt: now,
		},
		delivery,
	), nil
}

func (o *Order) Confirm(ctx context.Context, now time.Time, t time.Time) (trackId string, err error) {
	if o.Status != StatusNew {
		return "", fmt.Errorf("can't confirm order in status %s", o.Status)
	}

	trackId, err = o.delivery.Create(ctx, t, o.Id)
	if err != nil {
		return "", fmt.Errorf("delivery.Create: %w", err)
	}

	o.TrackId = trackId
	o.Status = StatusConfirmed
	o.UpdatedAt = now

	return trackId, nil
}

Мне нравится использовать New-конструктор для создания объекта из существующих корректных данных. Я передаю в него всё то, из чего объект состоит. И я использую Create-конструктор для создания заказа в доменом смысле. New никогда не возвращает ошибку, а доменный конструктор может делать какую-нибудь валидацию, вызывать методы, которые возвращают ошибку.

При необходимости сохранить данные объекта или отдать их в http-ответ, можно просто использовать его свойство.

func handleHttp(ctx context.Context) (any, error) {
	order, err := orders.CreateOrder(time.Now(), delivery.DefaultClient)
	if err != nil {
		return nil, fmt.Errorf("orders.CreateOrder: %w", err)
	}

	_, err := order.Confirm(ctx, time.Now(), time.Now())
	if err != nil {
		var timeErr delivery.InvalidTimeError
		if errors.As(err, &timeErr) {
			return nil, InvalidRequestError(map[string]any{
				"next_time": timeErr.NextTime,
			})
		}

		return nil, fmt.Errorf("order.Confirm: %w", err)
	}

	if err := repositories.Orders.Upsert(ctx, order.OrderData); err != nil {
		_ = delivery.DefaultClient.AsyncCancel(order.Id)

		return nil, fmt.Errorf("ordersRepo.Upsert: %w", err)
	}

	return order.OrderData, nil
}

Валидация на уровне бизнеса

В веб-приложениях обычно есть два типа валидации. Валидация входящих данных (например имэйл должен содержать @) и валидация бизнесовая (у пользователя не может быть больше 10 активных заказов). Первую валидацию надо разместить в соответствующем обработчике, а бизнесовую убрать в доменный слой. Это важно, так как бизнес-ограничение должно действовать независимо от того, где используется объект.

Инверсия зависимостей

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

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

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

Колбэк для инверсии управления

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

Например это может быть удобно при работе с транзакциями.

func (r *Repository) FindAndUpdate(ctx context.Context, id string, updFn func(context.Context, *OrderData) error) error {
	var err error
	tx, err := r.conn.Begin(ctx)
	if err != nil {
		return fmt.Errorf("conn.Begin: %w", err)
	}

	defer func() {
		if err != nil {
			_ = tx.Rollback(ctx)

			return
		}

		err = tx.Commit(ctx)
	}()

	rows, err := tx.Query(ctx, `
        select * from orders
        where id = @id
        for update
    `, pgx.NamedArgs{
		"id": id,
	})
	if err != nil {
		return fmt.Errorf("tx.Query: %w", err)
	}

	data, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[OrderData])
	if err != nil {
		return fmt.Errorf("pgx.CollectRows: %w", err)
	}

	if err := updFn(ctx, &data); err != nil {
		return err
	}

	if _, err := tx.Exec(ctx, `
		update orders ...
		where id = @id
	`, pgx.NamedArgs{
		"id": id,
	}); err != nil {
		return fmt.Errorf("tx.Exec: %w", err)
	}

	return nil
}

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

func handleHttp(ctx context.Context, orderId string) (any, error) {
	if err := repositories.Orders.FindAndUpdate(ctx, orderId, confirmOrder); err != nil {
		var timeErr delivery.InvalidTimeError
		if errors.As(err, &timeErr) {
			return nil, InvalidRequestError(map[string]any{
				"next_time": timeErr.NextTime,
			})
		}

		return nil, fmt.Errorf("ordersRepo.FindAndUpdate: %w", err)
	}

	return nil, nil
}

func confirmOrder(ctx context.Context, data *orders.OrderData) error {
	order := orders.NewOrder(*data, delivery.DefaultClient)
	if _, err := order.Confirm(ctx, time.Now(), time.Now()); err != nil {
		return fmt.Errorf("order.Confirm: %w", err)
	}

	*data = order.OrderData

	return nil
}

Это может показаться сложным, если вы не привыкли к инверсии управления. Но такой подход оставляет ваш код относительно независимым от инфраструктуры.

Паттерны ООП

Вы можете реализовать на го любые паттерны ооп. Но обратите внимание на то, что абстрактные названия, которые вы можете увидеть на диаграммах классов этих паттернов, не подходят для вашего кода. Не БилдерБулок производит Булки, а Пекарь, не МутаторЦены даёт скидку, а Скидка.

Поддержка

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

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

Непрерывный рефакторинг

Чтобы поддерживать код в актуальном состоянии, рефакторинг надо проводить постоянно. Не откладывайте рефакторинг на какую-то отдельную сессию по погашению техдолга, делайте его как только заметите в нём необходимость. Кроме булочек стали продавать ещё и кофе? — переименуйте типы и переменные. Появился новый партнёр по доставке? — выделите абстракцию и разнесите детали интеграции по конкретным реализациям. Не ждите когда вас попросят или вам разрешат.

Заключение

  • Доменный язык

  • UML

  • Псевдокод

  • Независимый чистый домен

  • Непрерывный рефакторинг

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

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


  1. NickNill
    11.05.2025 18:01

    Ой столько проблем с этими DTO... По 10 прокси методов необходимо лишь бы перегнать в нужный формат


  1. evgeniy_kudinov
    11.05.2025 18:01

    type deliveryClient interface {
    	Create(ctx context.Context, t time.Time, orderId string) (trackId string, err error)
    }

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

    И постоянное у всех вижу использование Repository и с методами не как для работы с коллекцией и определенной для 1 сущности. Смотришь по методам — там или DAO, или типичный Storage, но никак не репозиторий. Даже не знаю, почему так упорно везде пытаются пихать Repository. 

    И лично мне не нравится, что из пакета постоянно структуру делают экспортируемой. Да, я в курсе, что даже Goland IDE ругается на это (с чего ли?), и все так делают. Но когда из пакета торчат только разрешенные методы, мы снижаем случайно в другом пакете сделать жесткую зависимость и со временем превратить проект в «комок грязи» по "чистой архитектуре" с папочками по ДДД. 

    А так все по делу изложено и логично.