Лирическое предисловие

В конечном счете, программист — это ремесленник, а не художник. И это вовсе не унизительно. Да, он дизайнер, он инженер. Но он и ремесленник, конечная задача которого - сделать максимально удобное и минимально дорогое изделие для заказчика. И если художник может сказать мемное "Я так вижу!", то для программиста - это будет маркер профнепригодности.
К чему это я?
Программный продукт может иметь большой жизненный цикл, на протяжении которого он будет расти и развиваться. И если вы сэкономили время, что-то упростив в начале разработки, оно потом может с лихвой аукнуться позже. Ровно потому, что "жадный платит дважды".
Истины, конечно, азбучные. Но! Я не раз замечал, что многим программистам не свойственна эмпатия к себе подобным. И если им свой код кажется вполне понятным, то тех, кому, он не заходит, они подсознательно считают недостаточно профессиональными. И что греха таить - за собой такое тоже замечал.
Поэтому даже если Open closed Principle соблюдается, то стремление сделать код понятным любому джуну - присутствует увы, нечасто.
По этому поводу, недавно вычитал такой афоризм у Германа Горелкина:

"Стремление писать умный код присуще почти всем инженерам; это воспринимается как доказательство компетентности.
Но разработка программного обеспечения - это то, что происходит, когда вы добавляете время и других программистов.
В такой среде ясность - это не вопрос стиля, а снижение операционных рисков."

Обычное предисловие

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

Как хотелось бы

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

  1. Создать компонент БД

  2. Подключиться к БД

  3. Создать компонент сервера

  4. Запустить сервер

  5. ...

  6. Остановить сервер

  7. Закрыть БД

И почему-то очень хотелось, чтобы технические нюансы создания и запуска компонент прятались "под капотом", а именно в main была только простая, читабельная логика. Это снижает когнитивную нагрузку, уменьшает количество потенциальных ошибок и соответствует принципу разделения ответственности.

Как не хотелось бы

Но нет. В моём случае, в проекте использовалась популярная библиотека github.com/oklog/run которая запускала в горутинах основные компоненты приложения. И ничего плохого в этом нет, пока вам неважен порядок запуска горутин. Но вот если один компонент должен запускаться строго после запуска другого - вот тут начинались самые неприятные моменты, которые были решены в коде довольно "костыльно".
Последовательное создание зависимостей - тоже не самый оптимальный момент. Стоит нечаянно нарушить порядок и ты получишь nil pointer exception. Благо, с уничтожением оных в Go проблем нет - сборщик мусора рулит (но и расслабляет)! Если зависимостей 3-4 - это не беда. Но вот если их под десяток... Учитывая, что main не покрыта тестами, а на smoke test надежда небольшая - это всё не добавляет радости и ощущения надежности.

Варианты решения

Внедрение зависимостей

ИМХО, оптимальный вариант - использовать например фреймворк dig от компании Uber. Но большие дяди пишут, что данный подход не соответствует философии языка: простота, читаемость, отсутствие скрытой логики.
Ок, делаем в соответствии с философией - без магии.

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

type App struct {
	config          *config.Config
	repository      repository.Repository
	usecase         *usecase.Usecase
	rest            *rest.Rest
}

func New()(*App, error) {

	a := &App{}

    cfg, err := config.New()
    if err != nil {
        return nil, fmt.Errorf("failed to load config: %w", err)
    }

    a.config = cfg

    sqlSet, err := sqlset.New(queries.QueriesFS)
    if err != nil {
        return nil, fmt.Errorf("failed to load queries: %v", err)
    }

    a.repository = postgres.New(sqlsetpgxhelper.New(sqlSet))

    a.usecase = usecase.New(a.repository)

    ...

	return a, nil
}

И всё вроде бы хорошо и красиво, но если вы вдруг нечаянно нарушите порядок инициализации компонент и к примеру, забудете создать a.repository ДО создания a.usecase - приложение упадет с nil pointer exception и не факт, что это произойдет при его старте. Когда приложение содержит много компонент, приходится тщательно следить за графом построения зависимостей. И крайне желательно добавлять в конструкторы каждой из них проверку входных параметров на nil. И это раздражает. И хочется сделать, чтобы оно "как-нибудь само".

Чтобы исключить любые потенциальные ошибки порядка создания зависимостей, я предлагаю использовать паттерн lazy singleton, при котором объект создается ровно оди раз по первому требованию. У данного паттерна есть существенный недостаток: Lazy в приложениях часто приводит к поздним падениям и сложной диагностике. Но если использовать этот паттерн только для основных зависимостей которые гарантировано инициализируются при старте приложения на этапе bootstrap - этот недостаток нивелируется. Получается lazy по механике, но eager по факту исполнения.

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

type App struct {
	config          *config.Config
	repository      repository.Repository
	usecase         *usecase.Usecase
	rest            *rest.Rest
}

func (a *App) Config() *config.Config {
	if a.config == nil {
		var err error
		a.config, err = config.New()
		if err != nil {
			panic(fmt.Errorf("failed to load config: %w", err))
		}
		a.config = cfg
	}

	return a.config
}

func (a *App) Repository() repository.Repository {
	if a.repository == nil {
		sqlSet, err := sqlset.New(queries.QueriesFS)
		if err != nil {
			panic(fmt.Errorf("failed to load queries: %v", err))
		}

		a.repository = postgres.New(sqlsetpgxhelper.New(sqlSet))
	}

	return a.repository
}

func (a *App) Usecase() *usecase.Usecase {
	if a.usecase == nil {
		a.usecase = usecase.New(a.Repository())
	}

	return a.usecase
}

func (a *App) Rest() *rest.Rest {
	if a.rest == nil {
		a.rest = rest.New(a.Config().Port, a.Usecase())
	}

	return a.rest
}

Используем геттеры примерно так:

func (a *App) Start(cancel context.CancelCauseFunc) error {
	if err := a.Repository().Connect(a.Config().DBConnStr, 5*time.Second); err != nil {
		return fmt.Errorf("failed to connect to database: %v", err)
	}

	a.Rest().Start(cancel)

	return nil
}

Первое, что не понравилось в таком коде - паники. Поскольку геттер должен возвращать одно значение, при ошибке создания объекта, приходится вызывать panic. И это не очень красивое решение. Хорошо, используем recovery, не вопрос. Создадим особый тип ошибки, чтобы его можно было распознать в recovery и превратить в исходную ошибку в main.
Выглядеть это будет примерно так:

type ErrorPanic struct {
	Err error
}

func (e ErrorPanic) Error() string {
	return e.Err.Error()
}

func panicError(err error) {
	panic(ErrorPanic{Err: err})
}

func (a *App) Recover() {
	if r := recover(); r != nil {
		if err, ok := r.(ErrorPanic); ok {
			slog.Error("failed to initialize application", slog.String("err", err.Error()))

			return
		}

		panic(r)
	}
}

А использоваться - так:

func main() {
	a := app.New()
	defer a.Recover()

    ...

}

Отлично! Проблема решена! Я долго так думал. Пока в одном из кейсов не натолкнулся на периодические странности в поведении приложения. Всё оказалось просто - я не подумал о потокобезопасности. В моей парадигме, все геттеры должны вызываться в главном потоке. Но "должны" - не значит "будут". Когда из двух горутин приложение одновременно обращается к одному геттеру, возникает состояние гонки (race condition). И иногда, объект создается более одного раза. Самое противное, конечно тут это слово "иногда"...

И тут на помощь нам придет замечательный примитив синхронизации sync.Once. Поправив буквально несколько строк получаем такое:

type App struct {
	config     *config.Config
	configOnce sync.Once

	repository     repository.Repository
	repositoryOnce sync.Once

	usecase     *usecase.Usecase
	usecaseOnce sync.Once

	rest     *rest.Rest
	restOnce sync.Once
}

func (a *App) Config() *config.Config {
	a.configOnce.Do(func() {
		var err error
		a.config, err = config.New()
		if err != nil {
			panicError(fmt.Errorf("failed to load config: %w", err))
		}
	})

	return a.config
}

func (a *App) Repository() repository.Repository {
	a.repositoryOnce.Do(func() {
		sqlSet, err := sqlset.New(queries.QueriesFS)
		if err != nil {
			panicError(fmt.Errorf("failed to load queries: %v", err))
		}

		a.repository = postgres.New(sqlsetpgxhelper.New(sqlSet))
	})

	return a.repository
}

func (a *App) Usecase() *usecase.Usecase {
	a.usecaseOnce.Do(func() {
		a.usecase = usecase.New(a.Repository())
	})

	return a.usecase
}

func (a *App) Rest() *rest.Rest {
	a.restOnce.Do(func() {
		a.rest = rest.New(a.Config().Port, a.Usecase())
	})

	return a.rest
}

Тут ну всё прекрасно. Но лично мне режет глаз необходимость задавать два поля на каждый компонент приложения - ссылку на объект и свой sync.Once. Интуиция и тяга к прекрасному подсказывает, что правильно объединить это в одну сущность. А еще лучше - сделать шаблон, дженерик. Пробуем...
Дженерик:

type Lazy[T any] struct {
	once sync.Once
	val  T
	ctor func() T
}

func NewLazy[T any](ctor func() T) *Lazy[T] {
	return &Lazy[T]{ctor: ctor}
}

func (l *Lazy[T]) Get() T {
	l.once.Do(func() {
		l.val = l.ctor()
	})
	return l.val
}

Использование:

type App struct {
	config     *Lazy[*config.Config]
	repository *Lazy[repository.Repository]
	usecase    *Lazy[*usecase.Usecase]
	rest       *Lazy[*rest.Rest]
}

Инициализация:

func NewApp() *App {
	app := &App{}

	app.config = NewLazy(func() *config.Config {
		cfg, err := config.New()
		if err != nil {
			panic(fmt.Errorf("failed to load config: %w", err))
		}
		return cfg
	})

	app.repository = NewLazy(func() repository.Repository {
		sqlSet, err := sqlset.New(queries.QueriesFS)
		if err != nil {
			panic(fmt.Errorf("failed to load queries: %w", err))
		}
		return postgres.New(sqlsetpgxhelper.New(sqlSet))
	})

	app.usecase = NewLazy(func() *usecase.Usecase {
		return usecase.New(app.repository.Get())
	})

	app.rest = NewLazy(func() *rest.Rest {
		return rest.New(app.config.Get().Port, app.usecase.Get())
	})

	return app
}

Сделал. Посмотрел. Подумал. Откатил. Кода получается не меньше, а больше. Код стал менее явным. Слишком большая плата за исключение второго поля, ИМХО. И это уже ближе к DI-фреймворкам, которые, как мы помним - не вписываются в философию языка.

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

А аргументы про "неявный граф зависимостей" и "рекурсивную инициализацию" - они справедливы. Но кто сказал, что это - зло? Более того - я ж этого и добивался! Ну а если кто-то, пользуясь lazy выстрелит себе в ногу и соорудит циклическую ссылку зависимостей... Ну я даже не знаю, что сказать... Тут скорее уже вопрос больше о профпригодности такого программиста. Да и всплывет оно в конце-концов при первом же запуске приложения.

Старт приложения

Как известно, родной Go-шный HTTP-сервер, как и его потомки имеет блокирующий метод запуска. И наверное, зачем-то это нужно. Ну или это такой минимализм, полуфабрикат - все что нужно сверх этого, каждый допишет так, как ему удобно. Запуск в фоне должен быть явным выбором, мол.
Тем не менее, это не повод использовать run.group для всей main из примера:

ln, _ := net.Listen("tcp", ":8080")
g.Add(func() error {
	return http.Serve(ln, nil)
}, func(error) {
	ln.Close()
})

Впрочем, в самых простейших случаях - это наверное оправдано. Но в этой статье речь не о них.
Чтобы main была интуитивно понятной и легко читаемой (и модифицируемой), по моему глубокому убеждению, компоненты сервера должны всегда стартовать последовательно, синхронно, не блокируя приложение. Чтобы мы могли легко видеть и управлять очередностью запуска компонент (чего не позволяет использование run.group). И, когда всё гарантировано успешно стартовало, мы можем рапортовать наверх с помощью Kubernetes readinessProbe - я готов!
Поэтому, запускаем наш HTTP-сервер в фоне:

type Rest struct {
	srv     *fuego.Server
	port    int16
	stopCh  chan struct{}
	usecase *usecase.Usecase
	once    sync.Once
}

func (r *Rest) Start(cancel context.CancelCauseFunc) {
	ready := make(chan struct{})

	go func() {
		slog.Info("Starting HTTP server...")
		close(ready)

		err := r.srv.Run()
		if err != nil && err != http.ErrServerClosed {
			cancel(err)
		}

		close(r.stopCh)
	}()

	<-ready // waiting for the goroutine to start
}

func (r *Rest) Stop() error {
	if r.srv == nil {
		return nil
	}

	slog.Info("Stopping HTTP server...")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	var err error
	r.once.Do(func() {
		err = r.srv.Shutdown(ctx)
	})
	<-r.stopCh

	return err
}

Отлично. Но что, если нам нужно по каким-то причинам гарантировано удостовериться, что HTTP-сервер стартовал и готов обслуживать клиентов? Увы, единственный 100% способ убедиться в этом - это пинговать сервер. Никто не мешает нам сделать это:

func (r *Rest) Ping(ctx context.Context) error {
	const (
		pingTimeout    = 5 * time.Second
		requestTimeout = 200 * time.Millisecond
		retryTimeout   = 100 * time.Millisecond
	)

	ctx, cancel := context.WithTimeout(ctx, pingTimeout)
	defer cancel()

	client := http.Client{
		Timeout: requestTimeout,
	}

	url := "http://" + r.srv.Addr + "/ping"

	for {
		req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)

		resp, err := client.Do(req)
		if err == nil {
			resp.Body.Close() //nolint:errcheck

			if resp.StatusCode == http.StatusOK {
				return nil
			}

			err = fmt.Errorf("unexpected status code: %d", resp.StatusCode)
		}

		select {
		case <-ctx.Done():
			if err != nil {
				return err
			}

			return ctx.Err()
		case <-time.After(retryTimeout):
		}
	}
}

func (a *App) CheckHealth(ctx context.Context) error {
	return a.Rest().Ping(ctx)
}

Вот в принципе и всё. Все методы запуска и остановки приложения убраны "под капот" структуры App что вполне логично. Там же живет контейнер зависимостей. Остался финальный штрих - нарисовать красивую, чистую главную функцию main:

func main() {
	a := app.New()
	defer a.Recover()

	ctx, cancel := context.WithCancelCause(context.Background())
	defer cancel(nil)

	ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
	defer stop()

	slog.Debug("application starting...")
	err := a.Start(cancel)
	if err != nil {
		slog.Error("failed to start the application", utils.LogErr(err))

		return
	}
	defer func() {
		err = a.Stop()
		if err != nil {
			slog.Error("error occurred while stopping the application", utils.LogErr(err))
		}

		slog.Debug("application stopped")
	}()

	err = a.CheckHealth(ctx)
	if err != nil {
		slog.Error("failed to check health", utils.LogErr(err))
		cancel(err)

		return
	}

	slog.Debug("application started")

	err = a.Wait(ctx)
	if err != nil {
		slog.Error("shutdown due to error", utils.LogErr(err))
	}

	slog.Debug("application stopping...")
}

Не знаю, как вам, но мне такая main радует глаз. Коротко, чисто, интуитивно понятно. Код открыт для расширения и не требует лишнего напряжения нервных клеток для своего понимания.
Впрочем, вполне возможно я что-то упустил или забыл предусмотреть. Буду рад любым вашим конструктивным предложениям и замечаниям.
Полный исходный код проекта доступен тут: github.com/istovpets/go-base-project

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


  1. manyakRus
    20.02.2026 13:46

    такой код мне совсем не радует глаз,
    должно быть только 1 строчка кода для подключения (+отключение) куда угодно,
    например так:

    func main() { stopapp.StartWaitStop() mssql_gorm.StartDB() postgres_gorm.StartDB() nats.StartNats() camunda.StartCamunda() liveness.Start() stopapp.Wait_GracefulShutdown() }


    как в моём стартере:
    https://github.com/ManyakRus/starter


    1. stoi Автор
      20.02.2026 13:46

      Красиво. Но где здесь внедрение зависимостей? Тут запуск каких-то абсолютно независимых друг от друга компонент.


      1. manyakRus
        20.02.2026 13:46

        Мой пример кода делает всё то же самое,
        что и ваш огромный main,
        а именно:

        • Подключение к PostgreSQL

        • Подключение REST

        • Подключение Liveness (CheckHealth)

        • Отключение с Graceful Shutdown.


          Вы напрасно создаёте слишком много кода и зависимостей.


        1. stoi Автор
          20.02.2026 13:46

          Вы не ответили на вопрос. В вашем примере кода нет внедрения зависимостей.


        1. stoi Автор
          20.02.2026 13:46

          А как писать тесты без зависимостей? )


    1. stoi Автор
      20.02.2026 13:46

      Александр, мне кажется, основная цель вашего комментария - реклама собственного проекта... ((


    1. AllFiction
      20.02.2026 13:46

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

      Я не представляю как с этим вообще удобно работать, что в IDE что точечно в гитхабе. По ресурсам все тоже печально, можно было бы не заморачиваться с единым main а просто прописывать init() в ваших "модулях" и все. Один хрен никакого внешнего взаимодействия меж модулями нет, только прямые инициализации


      1. stoi Автор
        20.02.2026 13:46

        Спасибо за конструктивную, аргументированную критику, за добрые слова! И вам не хворать! ))


        1. AllFiction
          20.02.2026 13:46

          да я про manyakRus если что

          там даже в три экрана корневое дерево не влезло


          1. stoi Автор
            20.02.2026 13:46

            А... Понял. Но вам всё равно не хворать! ))


      1. outlingo
        20.02.2026 13:46

        Ну вот инит это такое себе как мне кажется. Это синглетоны, причём неявные. Они почти всегда зло. В определённых кругах существует мнение, что создаваться все должно явно, неявное создание есть зло, а объект должен быть готов к использованию сразу после завершения конструктора. Тогда все эти проблемы исчезают. У вас в любом месте все готово что вы создали и вам передали, и никаких проблем более не будет, кроме случая явной ошибки вызова rpc.


        1. oeditus
          20.02.2026 13:46

          объект должен быть готов к использованию сразу после завершения конструктора

          А если инициализация занимает час?


  1. anaxita
    20.02.2026 13:46

        sqlSet, err := sqlset.New(queries.QueriesFS)
        if err != nil {
            return nil, fmt.Errorf("failed to load queries: %v", err)
        }
    
        a.repository = postgres.New(sqlsetpgxhelper.New(sqlSet))
    
        a.usecase = usecase.New(a.repository)

    А что если в этом примере не пихать сразу поле в структуру а создать переменную, а в конце функции вернуть заполненный App, у которого вызвать метод Run скажем который запустить горутинки с серверами и брокерами?


    1. stoi Автор
      20.02.2026 13:46

      Я в начале статьи об этом и писал. В реальности получается main на 6 экранов и больше. Читается плохо, модифицируется плохо.
      Если сервис совсем простенький (как в моём примере - HTTP + БД) - да, можно не городить огород а делать всё как обычно, как вы и описали.
      Но вот сейчас я работаю не над микросервисом, а над монолитным проектом на Go и тут всё пихать в main - самоубийственно.


  1. yudinsv666
    20.02.2026 13:46

    Добрый день, не понимаю а почему не использовали fx: https://github.com/uber-go/fx
    Там из коробки есть fx.Lifecycle с хуками OnStart/OnStop, контролируемый порядок запуска зависимостей, graceful shutdown по сигналам, и health checks. По сути, то что в статье выстраивалось через lazy singleton + sync.Once. Аргумент про «философию языка» очень спорный fx активно используется в продакшене в самом Uber и в других крупных компаниях ( 7.4k stars говорит само за себя)

    давно перешли на него благодаря удобство и простоте, есть app.go куда подключается модули.

    func NewApp(
    	cfg *config.AppConfig) fx.Option {
    	return fx.Module("app",
    		NewAPI(cfg),
    		NewStorages(cfg),
    		NewProducerQueue(cfg),
    		NewUseCases(cfg),
    		fx.Invoke(telemetry.NewTracer),
    		fx.Invoke(metrics.RunPrometheusMetrics),
    		fx.Invoke(StartServer),
    	)
    }

    И вот так выглядит main

    ...
    application := fx.New(app.NewApp(&cfg))
    	if err = application.Err(); err != nil {
    		logger.Error("running application failed", zap.Error(err))
    		return
    	}
    	application.Run()
    ...


    P.S. не хотел обидеть, интересная статья, интересный ход мыслей по ходу статьи, но к концу как будто бы было написано половина fx)))


    1. stoi Автор
      20.02.2026 13:46

      Спасибо за ссылку и добрые слова! Не использовал fx по двум причинам - конкретно про этот фреймворк не знал и "данный подход не соответствует философии языка: простота, читаемость, отсутствие скрытой логики." - это я в начале упомянул.
      На самом деле, будь моя воля, я бы использовал fx. Но в команде есть тимлид и другие разработчики и у них мнение, увы, не всегда совпадает с моим.
      Кроме того цель данной статьи помимо прочего - познавательная. И если бы я просто написал - "берите fx и не думайте", это было бы не интересно. Велосеипеды в боевой разработке изобретать не нужно, но крайне полезно для профессионального развития.
      Обычно против DI фреймворков выдвигают два аргумента - магия это зло и - зачем тащить лишнюю зависимость. Но как по мне - fx калссный (uber вроде плохого не делает) и я бы использовал его. Ща пойду пробовать. Еще раз сапсибо за ссылку!