Сталкивались ли вы с болью при управлении порядком запуска и остановки зависимостей в вашем Go-сервисе?
Разработка больших сервисов неизбежно приводит к необходимости управлять множеством зависимостей. В этом контексте мы говорим о долгоживущих компонентах, чья работа обеспечивается отдельными горутинами: как правило, это блокирующий метод (например, Start), внутри которого крутится цикл обработки.
Примерный сценарий жизненного цикла сервиса выглядит так:
При запуске критически важно, чтобы пул соединений с БД, кэш и очереди были полностью готовы до того, как HTTP-сервер откроет порт и начнет принимать входящий трафик.
С graceful shutdown ситуация обратная: порядок должен быть строго зеркальным. Сначала нужно перестать принимать новые запросы, дождаться завершения текущих, остановить воркеры, и только потом разрывать соединения с инфраструктурой. Иначе мы получаем неприятные ошибки подключения и даже потерянные транзакции в момент деплоя.
Если эти проблемы вам не знакомы, смело закрывайте вкладку. Скорее всего, эта статья не принесет вам пользы.
Но если вы ищете способ автоматизировать эту рутину, сохранив код чистым - добро пожаловать под кат.
Как это обычно решается
Чаще всего разработчики идут по пути ручного связывания. Вы пишете код инициализации прямо в main, используя errgroup и много defer. Это решение отлично работает на малых масштабах, но с ростом проекта становится хрупким: стоит поменять местами два defer (например, закрытие базы и остановку воркера), как ваш graceful shutdown становится не таким и изящным.
Второй путь - DI-фреймворки (например, Uber Fx). Они действительно управляют зависимостями за вас. Но мало того, что такие инструменты, как правило, обладают весьма спорным синтаксисом, так они еще и «пролезают» по всему коду, делая его зависимым от конкретной библиотеки.
Третий путь: GOscade
Как сохранить явное связывание зависимостей, но полностью избавиться от ручного управления порядком старта и остановки? Идея проста: оставить инициализацию явной, но полностью автоматизировать порядок выполнения.
Небольшое уточнение перед примерами. Визуализация, которую вы увидите ниже - это отдельный проект, который лежит в репозитории библиотеки. Он отображает работу на синтетическом наборе компонентов и позволяет интерактивно посмотреть, как goscade управляет запуском, остановкой и различными сценариями. Этот инструмент удобен для понимания и экспериментов, но не является частью ядра библиотеки и никак не влияет на её работу в ваших приложениях.

Вы инициализируете структуры и передаете в них зависимости, как обычно. Никаких специальных реестров или сложной конфигурации.
Библиотека берет эти структуры и автоматически строит граф зависимостей, анализируя их поля через рефлексию.
Компоненты запускаются строго в топологическом порядке (от независимых к зависимым).
При остановке гарантируется корректный обратный порядок.
В итоге ваш доменный код остается чистым и мало что знает о библиотеке, а main.go избавляется от хрупкой логики управления горутинами.
Важно уточнить: рефлексия используется ровно один раз - на этапе инициализации приложения для построения графа. На производительность работающего сервиса это никак не влияет.
Лучше один раз увидеть код, чем сто раз услышать про графы
package main
import (
"context"
"log"
"github.com/ognick/goscade/v2"
)
// 1. Database - независимый компонент
type Database struct{}
func (d *Database) Run(ctx context.Context, ready func(error)) error {
// Здесь происходит реальное подключение к БД
ready(nil) // Сигнал: "Готов, можно запускать зависимые компоненты"
<-ctx.Done() // Ждем до сигнала остановки
// Закрытие соединений...
return nil
}
// 2. Server - зависит от Database
type Server struct {
DB *Database // <--- Goscade увидит эту связь через рефлексию
}
func (s *Server) Run(ctx context.Context, ready func(error)) error {
// Этот код выполнится ТОЛЬКО после того, как Database вызовет ready(nil)
ready(nil) // Сигнал: "Сервер принимает трафик"
<-ctx.Done() // Работаем до сигнала остановки
// Остановка...
return nil
}
func main() {
// Инициализируем Lifecycle менеджер
lc := goscade.NewLifecycle(log.Default(), goscade.WithShutdownHook())
// Создаем компоненты
db := &Database{}
server := &Server{DB: db} // Явно передаем зависимость
// Регистрируем (порядок не важен)
goscade.Register(lc, server)
goscade.Register(lc, db)
// Запускаем
goscade.Run(context.Background(), lc, func() {
log.Println("Все компоненты запущены!")
})
}
Как вы видите, единственное, что навязывает требуется от зависимостей - имплементировать метод Run:
Run(ctx context.Context, readinessProbe func(error)) error

Вы вызываете readinessProbe(nil), когда компонент полностью готов к работе.
Если же на этапе старта произошла ошибка - передаете её в коллбек, и библиотека корректно прервет запуск всей цепочки.

Библиотека продолжает следить за компонентами и после успешного старта.
Если в процессе работы метод Run любого из них завершится с ошибкой, весь граф будет остановлен в правильной обратной последовательности
Кроме того, библиотека берет на себя рутину, которую обычно приходится копипастить из проекта в проект:
Перехват сигналов ОС: Опция
WithShutdownHook()автоматически подписывается наSIGINTиSIGTERM. Вам не нужно создавать каналы дляos.Signalвручную.Таймауты на старт и стоп: Вы можете ограничить время на инициализацию или завершение приложения, чтобы зависший драйвер не вешал деплой бесконечно.
Управление через контекст: Вся работа построена на стандартном
context.Context. Если вы отмените родительский контекст, вся цепочка сервисов корректно остановится.
Забирайте в свои проекты, экспериментируйте. Если найдете баги или придумаете, как сделать лучше - велкам в Issues и PR ? github.com/ognick/goscade
Комментарии (6)

vkomp
15.12.2025 10:29Всё не настолько плохо, чтобы тащить новую зависимость. Мне не зашел "uber/fx". И за несколько дней разобрался в теме - задача красиво остановить сильно сложнее, чем запустить. Пучок каналов, несколько контекстов, waitgroups для внешних сервисов - и готово. Решение на пару сотен строк, разбросанных по модулям.
Я за отсутствие магии в разработке, если что.
Ognick Автор
15.12.2025 10:29Я как раз и отталкивался от того, что «красиво остановить сильно сложнее, чем запустить», и что в реальных проектах это обычно вырастает в очень похожий код, размазанный по модулям.
Такой код часто не самый простой в отладке и сопровождении, поэтому хотелось собрать lifecycle в одном выделенном месте. Кроме того, при ручном управлении обычно приходится явно прокидывать зависимости между реализациями, что со временем увеличивает связанность.
Но если в конкретном проекте это удобно держать вручную - это действительно лучше, чем тянуть ещё одну библиотеку.

gohrytt
15.12.2025 10:29Ух ты, мы с вами буквально одну и ту же проблему решили, только я нигде не публиковал кроме корп репозитория. Чуть проще:
package graceful import ( "context" "time" "go.uber.org/multierr" ) type ( GroupConfiguration struct { Timeout time.Duration `name:"TIMEOUT" default:"30s"` } TimeoutOption time.Duration Group struct { configuration *GroupConfiguration ctx context.Context cancel context.CancelFunc errors chan error closers []closer wait uint64 } Resource[T_resource any] struct { group *Group resource *T_resource } Task[T_resource any] func(context context.Context, resource *T_resource) error closer struct { resource any task any wrapper func(ctx context.Context, resource, task any, errors chan error) } ) func New(ctx context.Context, configuration *GroupConfiguration) *Group { ctx, cancel := context.WithCancel(ctx) return &Group{ configuration: configuration, ctx: ctx, cancel: cancel, errors: make(chan error, 1), } } func WithOptions(ctx context.Context, options ...any) *Group { configuration := &GroupConfiguration{ Timeout: 30 * time.Second, } for i := range options { switch typed := options[i].(type) { case TimeoutOption: configuration.Timeout = time.Duration(typed) } } ctx, cancel := context.WithCancel(ctx) return &Group{ configuration: configuration, ctx: ctx, cancel: cancel, errors: make(chan error, 1), } } func (group *Group) Context() context.Context { return group.ctx } func (group *Group) Wait() error { <-group.ctx.Done() ctx, cancel := context.Background(), context.CancelFunc(nil) if group.configuration.Timeout > 0 { ctx, cancel = context.WithTimeout(ctx, group.configuration.Timeout) } for _, closer := range group.closers { go closer.wrapper(ctx, closer.resource, closer.task, group.errors) } errors := []error(nil) cycle: for group.wait > 0 { select { case <-ctx.Done(): break cycle case err := <-group.errors: if err != nil { errors = append(errors, err) } } group.wait -= 1 } if cancel != nil { cancel() } return multierr.Combine(errors...) } func AddResource[T_resource any](group *Group, resource *T_resource) Resource[T_resource] { return Resource[T_resource]{ group: group, resource: resource, } } func (resource Resource[T_resource]) OnStart(task Task[T_resource]) Resource[T_resource] { resource.group.wait += 1 go func(resource Resource[T_resource], task Task[T_resource]) { err := task(resource.group.ctx, resource.resource) resource.group.cancel() resource.group.errors <- err }(resource, task) return resource } func (resource Resource[T_resource]) OnClose(task Task[T_resource]) Resource[T_resource] { resource.group.wait += 1 wrapper := func(ctx context.Context, resource, task any, errors chan error) { errors <- task.(Task[T_resource])(ctx, resource.(*T_resource)) } resource.group.closers = append(resource.group.closers, closer{ resource: resource.resource, task: task, wrapper: wrapper, }) return resource }package notifier import ( "context" "errors" "fmt" "os" "os/signal" ) type ( Notifier struct { signals []os.Signal receiver chan os.Signal } ) var ( ErrSignalReceived = errors.New("signal received") ) func New(signals []os.Signal) *Notifier { return &Notifier{ signals: signals, receiver: make(chan os.Signal, 1), } } func OnStart(ctx context.Context, notifier *Notifier) (err error) { signal.Notify(notifier.receiver, notifier.signals...) select { case <-ctx.Done(): return nil case signal := <-notifier.receiver: return fmt.Errorf("%w: %s", ErrSignalReceived, signal.String()) } } func OnClose(ctx context.Context, notifier *Notifier) (err error) { signal.Stop(notifier.receiver) return nil }Очень сильно упростило жизнь менее опытных коллег и меня когда за этими менее опытными коллегами надо переделать graceful shutdown.

vkomp
15.12.2025 10:29Не очень вчитывался, но у меня вроде посложнее:
- на внешний сигнал стопа закрываем прием новых запросов (у меня http-сервер), доделываем имеющиеся запросы, сервер по завершении обработки завершается и кидает ок в канал
- по сигналу закрываются соединения с ресурсами, чтобы не потерять данные,
- и потом выход из main (или жесткий шатдаун по истечении второго времени).
И потому есть контекст на управление сервером, есть контекст запросов внутри сервера, и есть контекст на приложуху с ресурсами - данные в одну сторону. И каналы для ответов. Потому долго ковырялся.
F0rzend
Рефлексия отталкивает, конечно… неужели нельзя было сделать без неё?..
Ognick Автор
Рефлексия здесь - это трудный компромисс) Она используется только на этапе сборки графа на старте, чтобы автоматически определить зависимости между компонентами и убрать ручное связывание.