---
title: "Структура большого CLI-проекта"
description: "Структура cli проекта go с Cobra: пакеты cmd по командам, домен в internal, приёмник зависимостей App, где жить Viper и чем плох глобальный rootCmd из main."
course: "Cobra: CLI-приложения на Go"
url: "https://golangmaster.ru/courses/cobra/lessons/21-struktura-proekta/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Структура большого CLI-проекта

*Курс «Cobra: CLI-приложения на Go», урок 21 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cobra/lessons/21-struktura-proekta/*

Структура cli проекта go перестаёт быть теорией в момент, когда cmd/root.go переваливает за восемьсот строк, а RunE команды add читает файлы, печатает таблицу и пишет хранилище одним телом. Разберём раскладку, по которой живут реальные CLI на Cobra: по файлу на команду, домен в internal и приёмник зависимостей вместо глобальных переменных.

Утилита tasks выросла незаметно: cmd/root.go перевалил за восемьсот строк, RunE команды add читает файл, парсит аргументы, печатает таблицу и пишет хранилище телом на девяносто строк, а viper дергается из шести разных файлов. Каждая правка списка задач трогает три пакета, а тесты из урока про [тестирование команд](/courses/cobra/lessons/20-testirovanie-cobra/) отказываются идти параллельно — все делят одни глобальные переменные. Это не проблема дисциплины, это проблема раскладки: у кода нет мест, где он обязан жить. Структура cli проекта go — это несколько твёрдых правил: одна команда — один файл в cmd, домен в internal, зависимости текут вниз через приёмник, и ни одной глобальной команды. Разложим tasks так, как это делают kubectl и hugo, и понесём получившийся каркас в итоговый проект курса.

### Организация кода Cobra: одна команда — один файл

Пакет cmd — витрина утилиты: здесь живут фабрики команд и больше ничего. Конвенция, которую генератор показал ещё на этапе установки, продолжается на любое число подкоманд: cmd/list.go, cmd/add.go, cmd/done.go — по файлу на команду, и в каждом одна фабрика newListCmd() *cobra.Command. root.go остаётся диспетчером: собирает дерево через AddCommand, объявляет персистентные флаги вроде --config и не знает деталей ни одной команды. При открытии чужого репозитория это экономит минуты: нужен list — открываете cmd/list.go, и весь его код перед глазами, от флагов до RunE. Генератор cobra-cli add умеет создавать такие заготовки сам, но после урока про фабрики вы напишете их быстрее руками — сразу с нужным приёмником зависимостей, а не с пакетными переменными, которые генератор подсовывает по умолчанию.

### cmd/list.go: фабрика команды

```go
// cmd/list.go, пакет cmd
func newListCmd(app *App) *cobra.Command {
        var limit int
        cmd := &cobra.Command{
                Use:   "list",
                Short: "Показать задачи",
                RunE: func(cmd *cobra.Command, args []string) error {
                        items, err := app.store.All()
                        if err != nil {
                                return fmt.Errorf("хранилище: %w", err)
                        }
                        if limit > 0 && limit < len(items) {
                                items = items[:limit]
                        }
                        return printList(cmd.OutOrStdout(), items)
                },
        }
        cmd.Flags().IntVarP(&limit, "limit", "n", 10, "сколько задач показать")
        return cmd
}
```

RunE — только склейка: достали задачи, отрезали лимит, отдали выводчику. Ни чтения файлов, ни правил домена здесь нет — это забота internal/tasks. Флаг объявлен на экземпляре команды, а не пакетной переменной: значения pflag персистентны, и общий var limit делил бы состояние между тестами. Ошибка хранилища оборачивается с %w — main отличит её по сентинелу и выберет код выхода.

Второй слой — internal: каталог, который компилятор прячет от соседних модулей. Импортировать github.com/you/tasks/internal/tasks может только код вашего же модуля — пользователи библиотеки до него не достанутся, а значит, вы вправе менять домен, не спрашивая никого. Направление импортов одно: cmd импортирует internal, internal не знает о cmd. Внутри internal/tasks лежит модель Task, хранилище Store и правила — например, что задача с пустым текстом не сохраняется даже при явной просьбе. Терминальные заботы — цвета, tabwriter, шаблоны справки — остаются в cmd: печать на экран не обязанность домена. Эта граница проверяется компилятором только при живой сборке модуля — песочница сайта многопакетные проекты не исполняет, здесь нужен локальный Go.

### internal/tasks: домен без терминала

```go
// internal/tasks/store.go, пакет tasks
type Task struct {
        ID      int
        Text    string
        Done    bool
        Created time.Time
}

type Store struct {
        path string
}

func NewStore(path string) *Store { return &Store{path: path} }

func (s *Store) All() ([]Task, error) {
        raw, err := os.ReadFile(s.path)
        if errors.Is(err, os.ErrNotExist) {
                return nil, nil // первый запуск: файла нет — это не ошибка
        }
        if err != nil {
                return nil, err
        }
        var tasks []Task
        if err := json.Unmarshal(raw, &tasks); err != nil {
                return nil, fmt.Errorf("хранилище %s: %w", s.path, err)
        }
        return tasks, nil
}
```

Домен не знает про флаги и терминал: Task, Store и правила — чистая логика, которую можно гонять без cobra. ErrNotExist превращается в пустой список: первый запуск — легальный сценарий, а не сбой, тот же вывод, что в уроке про конфиг-файлы. Ошибка парсинга обёрнута с %w и путём файла — пользователь увидит, какой именно JSON битый.

### Приёмник зависимостей: структура App

RunE фиксирована сигнатурой — внутрь не протолкнёшь ни хранилище, ни конфиг, и долгое время команды тянули данные из глобальных переменных. Решение — приёмник зависимостей: структура App, в полях которой живёт всё, что нужно командам, и конструктор NewApp, который её собирает. Команды получают *App параметром фабрики — newListCmd(app *App) — и в RunE работают только с app.store и app.cfg. Никакого фреймворка DI тут нет и не нужно: Go-код передаёт зависимости руками, от main вниз. Выигрыш виден в тестах: newRootCmd(app) с временным каталогом вместо реального хранилища — и тест гоняет команду по-настоящему, не задевая ваши данные. Подмена одной строки в тесте — вся цена связности проекта.

### cmd/app.go: связка App и дерева команд

```go
// cmd/app.go, пакет cmd
type App struct {
        store *tasks.Store
        cfg   *config.Config
}

func NewApp(store *tasks.Store, cfg *config.Config) *App {
        return &App{store: store, cfg: cfg}
}

func newRootCmd(app *App) *cobra.Command {
        root := &cobra.Command{Use: "tasks", SilenceUsage: true}
        root.AddCommand(newListCmd(app), newAddCmd(app), newDoneCmd(app))
        return root
}
```

App — обычная структура, конструктор — обычная функция: DI здесь руками, без фреймворков и рефлексии. newRootCmd строит свежее дерево на каждый вызов: main зовёт один раз, каждый тест — заново, и фабрика из урока про тестирование собирается поверх того же кода. Добавить команду — значит дописать одну строку AddCommand и создать один файл в cmd.

### Где жить Viper-инициализации

Всё, что Viper делает с источниками — чтение YAML, AutomaticEnv, BindPFlag, Unmarshal — обязано случиться ровно один раз, при старте, в одном месте. Я держу эту работу в отдельном внутреннем пакете internal/config: функция Load() читает файл, накладывает переменные окружения и возвращает готовую структуру Config, которую съедает NewApp. Анти-паттерн — команды, дёргающие viper.GetString в каждом RunE: приоритеты из урока про [приоритеты конфигурации](/courses/cobra/lessons/10-prioritety-konfiguraciya/) собираются в куче разных мест, а тест уже не может подменить источник. После Unmarshal конфиг иммутабелен: команды читают поля структуры, а не глобальный синглтон. Живому перезагрузу конфига посвящён [отдельный урок](/courses/cobra/lessons/24-viper-watch-sekrety/) — там мы отпустим это ограничение и посмотрим, чего оно потребует взамен.

### Проверка: место для загрузки конфига

**Вопрос:** Куда положить код, который читает YAML-конфиг и собирает структуру Config?

- В cmd/root.go — рядом с корневыми флагами
- В main.go перед Execute
- ✅ В internal/config: домену всё равно, откуда пришёл cmd
- В отдельную функцию каждой команды, где нужен конфиг

Загрузка конфига — не интерфейс пользователя, значит не cmd; дублировать её по командам — значит размазать приоритеты источников по репозиторию. Отдельный внутренний пакет config собирает всю Viper-работу в одну функцию Load, которую зовёт main, а её результат уходит в NewApp вместе с хранилищем.

> **Анти-паттерн: глобальный rootCmd из main.** Генератор старых версий рисовал соблазнительную схему: var rootCmd = &cobra.Command{...} на уровне пакета и init(), который навешивает подкоманды. Проект собирается, работает — и становится минным полем: порядок init между файлами неочевиден, две Execute в тестах делят персистентные флаги, SilenceUsage правится в двух местах, а фабрика из урока про тестирование непостроима в принципе. Я видел репозиторий, где добавление одной команды требовало правки четырёх файлов с init. Лечение одно: никаких пакетных rootCmd — только newRootCmd(app), возвращающая свежее дерево; main вызывает её для продакшена, тесты — для себя.

### Раскладка репозитория tasks целиком

- main.go — точка входа на двадцать строк: конфиг, App, Execute, код выхода
- cmd/ — по файлу на команду плюс app.go с приёмником зависимостей
- internal/tasks — домен: модель Task, хранилище Store, правила статусов
- internal/config — единственное место, где живёт Viper и собирается Config
- cmd/testdata — эталоны вывода для golden-тестов
- docs/ — сгенерированные man и markdown
- .goreleaser.yaml — релизная раскладка, достроится в уроке про сборку

### Что где живёт и почему

| Артефакт | Пакет | Почему именно здесь |
| --- | --- | --- |
| фабрика newListCmd и флаги | cmd | интерфейс пользователя: справка, парсинг, вывод |
| модель Task и Store | internal/tasks | домен без терминальных зависимостей, тестируется без cobra |
| чтение YAML и env | internal/config | один владелец источников конфигурации |
| os.Exit и коды выхода | main.go | процесс умирает в одном месте, RunE возвращает ошибки |
| шаблоны справки | cmd | функции --help меняются вместе с командами |

### main.go: тонкая точка входа

main в правильно разложенном проекте — на двадцать строк: собрать конфиг, собрать App, запустить Execute, перевести ошибку в код выхода. Логика в main — запах: если там появился if по типам ошибок длиннее трёх строк, место этой ветке в cmd или internal. Рядом с командами живут и тестовые конвенции: файлы cmd/*_test.go компилируются только в тестовом бинарнике и не попадают в релиз, а эталоны вывода лежат в cmd/testdata — go test найдёт их по относительному пути пакета. Каталог docs для man-страниц, README и .goreleaser.yaml достроятся в следующих уроках — раскладка нарочно оставляет под них место, чтобы позже не переезжать с места на место и не переписывать пути импортов.

### Циклы импортов и граница слоёв

Цикл импортов — самая частая авария при раскладке: пакет A импортирует B, B импортирует A, и сборка падает со словами import cycle not allowed. В CLI-проекте он обычно появляется, когда internal/tasks зачем-то понадобилась справка или цвета из cmd — сигнал, что уровень перепутан и домен полез в интерфейс. Лечится переносом общего кода вниз: константы статусов, сентинел-ошибки вроде ErrNotFound живут в internal, а cmd их только читает. Помощники вывода — наоборот, вверх, в cmd: домен не должен знать про ANSI-коды и tabwriter. Быстрая проверка архитектуры: поиск импортов по пакету internal не должен находить путей на cmd. За этим следит компилятор, но дольше всего границу держат руки и ревью — глаз цепляется на цикл быстрее, чем скрипт на запрет.

### Что выдаст сборка

```go
// internal/tasks/report.go
package tasks

import "github.com/you/tasks/cmd"
// при этом cmd импортирует пакет tasks
```

**Что выведет программа?**

- Компилируется: internal может импортировать всё
- ✅ Ошибка: import cycle not allowed
- Предупреждение go vet, но сборка проходит
- Бинарник соберётся и упадёт при первом вызове

Цикл импортов — ошибка компиляции, а не рантайма: Go запрещает круговые зависимости на уровне сборки. cmd импортирует tasks, tasks тянет cmd — круг замкнулся. Лечение — перенести общие типы вниз: в internal/tasks терминальному коду не место.

### Проверка: зачем фабрика дерева

**Вопрос:** Зачем newRootCmd(app) строить новое дерево на каждый вызов, вместо пакетной rootCmd?

- Так требует генератор cobra-cli
- Глобальная команда не поддерживает поле Version
- Разницы нет — это только стиль кода
- ✅ Глобальная делит значения флагов и подкоманды между всеми Execute, включая тесты

Значения pflag персистентны: --limit 5 из первого запуска достанется второму. Тесты из урока про тестирование требуют свежего дерева с подменённым хранилищем — на глобальной переменной такую фабрику не построишь. Побочный бонус: SilenceUsage и Version задаются в одном месте.

### Соберите main по шагам

Впишите конструктор хранилища, фабрику дерева и приём ошибки.

```go
store := tasks.New___(path)
app := NewApp(store, cfg)
root := new___(app)
___ := root.Execute()
```

Подсказка: Конструктор хранилища из internal/tasks, фабрика из cmd/app.go, Execute возвращает ошибку.

Правильные ответы в пропусках: `Store`, `RootCmd`, `err`

### Соберите цепочку запуска main

Расставьте: конфиг, хранилище, приёмник, дерево команд, запуск и выход.

Правильный порядок строк:

1. `cfg := config.Load()`
2. `store := tasks.NewStore(cfg.File)`
3. `app := NewApp(store, cfg)`
4. `root := newRootCmd(app)`
5. `err := root.Execute()`
6. `os.Exit(exitCodeFrom(err))`

### Карточки: раскладка CLI-проекта

- **cmd/** — Пакет-витрина CLI: по файлу на команду, в каждом фабрика newListCmd() *cobra.Command. root.go только собирает дерево AddCommand и держит персистентные флаги.
- **internal/** — Скрытый от соседних модулей слой: модель, хранилище, правила домена. Импортируется только кодом своего модуля — менять можно свободно.
- **App** — Приёмник зависимостей: struct App{store, cfg}, конструктор NewApp. Команды получают *App через фабрики и работают только с полями структуры.
- **newRootCmd** — Фабрика дерева команд: строит свежий корень с подкомандами на каждый вызов — изолирует тесты и не даёт флагам течь между Execute.
- **Цикл импортов** — import cycle not allowed: cmd импортирует internal, internal не знает о cmd. Общие константы — вниз в internal, цвета и справка — вверх в cmd.
- **Viper-инициализация** — Одно место при старте: internal/config.Load() читает YAML, накладывает env и возвращает структуру. В RunE — только поля cfg, не viper.Get.

### Что дальше

Каркас разложен, дальше — доводка до production: в уроке про [сигналы и контексты](/courses/cobra/lessons/22-signaly-kontekst/) утилита научится умирать красиво по Ctrl+C, а урок про [сборку и версии](/courses/cobra/lessons/23-sborka-versii/) оденет её в ldflags и goreleaser. Всё это сложится в [итоговый проект](/courses/cobra/lessons/25-itogovyj-proekt/) — менеджер задач с хранилищем, цветами и тестами. Обзор курса со списком тем — на странице [Cobra](/courses/cobra/). Если переносите существующую утилиту — переезжайте по одной команде за раз, компилятор укажет на каждое нарушение границы. И стандартная оговорка: раскладку из нескольких пакетов проверяет только живая сборка — соберите tasks командой go build ./... локально и убедитесь, что internal не тянет cmd.

## Коротко: что запомнить

- cmd/ — витрина: по файлу на команду, фабрика newListCmd() на каждую; root.go только собирает дерево.
- internal/ скрыт от соседних модулей: домен, хранилище и правила живут там и не знают о cmd.
- Приёмник зависимостей — struct App{store, cfg}: зависимости передаются руками от main вниз.
- Viper-инициализация — одно место при старте (internal/config.Load()); команды читают поля структуры, а не viper.Get.
- Глобальный rootCmd из main — анти-паттерн: ломает тесты, init-порядок и фабрику newRootCmd.
- Граница слоёв: cmd импортирует internal и никогда наоборот; общий код — вниз, цвета и справка — вверх.
- Раскладку проверяет только живая сборка go build ./... — песочница многопакетные проекты не исполняет.

## Домашнее задание

Разложите свою утилиту по схеме урока: выделите internal с моделью и хранилищем, соберите команды в cmd по файлу на команду, заведите App и newRootCmd(app). Соберите go build ./..., убедитесь, что internal не импортирует cmd, и переведите один тест с урока про тестирование на подмену хранилища через App.

## Вопросы и ответы по уроку

### Как разложить большой CLI-проект на Go?

Пакет cmd — по файлу на команду с фабрикой, домен — в internal (модель, хранилище, правила), загрузка конфига — в internal/config, main.go — тонкая точка входа. Зависимости передаются приёмником: структура App собирается конструктором NewApp и уходит в фабрики команд. Направление импортов одно: cmd импортирует internal.

### Почему глобальный rootCmd в пакетной переменной — плохо?

Значения флагов pflag переживают Execute, поэтому два запуска — реальный и тестовый — делят состояние; init-порядок файлов навешивает подкоманды неявно; фабрика newRootCmd(app) из урока про тестирование непостроима. Фабрика, возвращающая свежее дерево на каждый вызов, снимает все три проблемы сразу.

### Где инициализировать Viper в проекте Cobra?

В одном месте при старте — обычно internal/config.Load(): SetConfigFile, ReadInConfig, AutomaticEnv, Unmarshal в структуру. Команды работают с полями конфига из App и не вызывают viper.Get в RunE — иначе приоритеты источников размазываются по репозиторию и перестают тестироваться.
