---
title: "Структура крупного TUI-проекта"
description: "Структура проекта bubbletea: пакеты по экранам, styles.go, messages.go, домен без tea и чек-лист ревью TUI-кода — организация tui кода на Go."
course: "Bubble Tea: TUI-приложения в терминале"
url: "https://golangmaster.ru/courses/bubbletea/lessons/23-struktura-proekta/"
published: "2026-09-19"
updated: "2026-10-09"
minutes: 18
---

# Структура крупного TUI-проекта

*Курс «Bubble Tea: TUI-приложения в терминале», урок 23 из 25. ~18 минут. Обновлено 09.10.2026. Источник: https://golangmaster.ru/courses/bubbletea/lessons/23-struktura-proekta/*

Когда организация tui кода уходит из одного файла, структура проекта bubbletea решает всё: пакеты по экранам, централизованный styles.go, общий messages.go для событий и доменный слой без единого импорта tea. Разберём каталог, который переживает рост от панели на сто строк до многоэкранного приложения, покажем, откуда тестировать каждый слой, и закончим чек-листом ревью TUI-кода.

Панель задач выросла из одного файла в тысячу двести строк: в Update соседствуют клавиши списка, HTTP-загрузка, валидация формы и стили, а правка строки статуса роняет валидацию пароля в другом конце файла. Это не признак плохого кода — это признак хорошей выучки предыдущих уроков, слитых в одну кучу. Куча чинится картой, и карта для Bubble Tea давно устоялась: экраны живут в отдельных пакетах, стили — в одном styles.go, общие сообщения — в messages.go, а домен — задачи, хранилище, валидация — не знает о существовании терминала. Корневая модель собирает экраны и роутит события. Разберём каждый этаж по порядку, а в конце — чек-лист ревью, который спасает на код-ревью, когда дифф большой, а терпение короткое. Всё собирается локально через go build ./... — песочница тут бессильна, структура проверяется компилятором и тестами.

### Пакеты по экранам

Ключевое решение — резать по экранам, а не по слоям. Пакет update.go со всеми обработчиками программы — антипаттерн: правка списка и правка формы едут в один файл, конфликты в git гарантированы. Экран — самодостаточная фича: у него свой model, конструктор, Update и View, и он не импортирует соседей. Соседство ломает изоляцию мгновенно: экран списка, вызвавший функцию редактора, уже не выкинуть из сборки отдельно. Имена пакетов короткие и по делу: list, editor, help — в импортах это читается как list.Update, list.Model. Корень при этом один: он знает все экраны, а экраны не знают друг о друге. Тонкий вопрос — когда резать: не с первого дня. Приложение из двух экранов честно живёт в одном пакете ui; резать стоит, когда файлы перерастают пятьсот строк или когда второй экран переиспользует первый стиль. Преждевременная нарезка даёт десяток пакетов по три файла, и навигация по проекту становится дороже навигации по одному хорошему файлу.

### Дерево типичной панели

- taskpanel/main.go — точка входа: tea.NewProgram, опции альт-скрина, запуск
- internal/domain/task.go — задачи, статусы, файловое хранилище без tea
- internal/ui/root.go — корневая модель: экраны, режим, роутинг событий
- internal/ui/screens/list/ — экран списка: model.go, update.go, view.go
- internal/ui/screens/editor/ — экран редактирования задачи
- internal/ui/messages/messages.go — общие типы сообщений
- internal/ui/styles/styles.go — все стили Lip Gloss программы
- go.mod — модуль с зависимостями bubbletea, bubbles, lipgloss

### styles.go: стили в одном месте

Каждый экран, рисующий свой lipgloss.NewStyle, через месяц даёт четыре разных красных и три разных отступа — дрейф оттенков заметен только пользователю, который смотрит на экран целиком. Лекарство — один пакет styles: все стили программы объявлены переменными в одном файле, экраны их импортируют и не создают своих. Выигрыш не только в красоте: правка палитры — один коммит в одном файле; адаптивные ширины из урока про раскладку хранятся там же, рядом с базовыми стилями; и код-ревью цветной правки сводится к просмотру одного диффа. Тёмная тема терминала, NO_COLOR и ANSI-256 обсуждались в пятнадцатом уроке — здесь важно только место: стиль объявлен один раз. Спорная деталь, на которой я обжигался: слишком умный пакет styles с функциями-конструкторами и конфигами. Пока стили — простые переменные, всё предсказуемо; как только появляется конфигурация, отладка цвета требует чтения трёх файлов вместо одного. Держите styles.go тупым.

### Пакет styles целиком

```go
package styles

import "github.com/charmbracelet/lipgloss"

var (
        DocStyle = lipgloss.NewStyle().Margin(1, 2)

        Header = lipgloss.NewStyle().
                Bold(true).
                Foreground(lipgloss.Color("205"))

        StatusErr = lipgloss.NewStyle().
                Foreground(lipgloss.Color("196"))

        Selected = lipgloss.NewStyle().
                Bold(true).
                Foreground(lipgloss.Color("212"))
)
```

Все стили программы — один файл, ни один экран не зовёт lipgloss.NewStyle сам. Имена говорящие и не привязаны к экрану: Header используется и списком, и редактором. Правка палитры — правка этого файла и больше ничего.

### messages.go: общий язык экранов

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

### Типы общих событий

```go
package messages

import "time"

type tickMsg time.Time

type statusMsg string

type savedMsg struct {
        path string
        err  error
}
```

Три события программы: тик часов, строка статуса, факт сохранения. Ни одного импорта экранов — пакет строится за секунду и не создаёт циклов. Ошибка едет полем err по конвенции девятнадцатого урока.

> **Циклический импорт.** Экран списка захотел тип savedMsg, нашёл его в пакете editor — и компилятор отказался собирать: editor уже импортирует list ради конвертера. Лечение всегда одно: общий тип переезжает в messages. Цикл импорта — сигнал, что событие описали не как факт, а как команду чужому экрану.

### Домен без tea

Самый ценный этаж — internal/domain: задачи, их статусы, правила валидации, файловое хранилище. Ни одного импорта bubbletea — домен не знает, что его показывает терминал. Выгода тройная. Тесты: go test ./internal/domain/ гоняется за миллисекунды без рантайма интерфейса — это тесты из двадцать второго урока в лучшем виде. Переиспользование: CLI-команда add из курса Cobra может писать в то же хранилище, а в гипотетическом fyne-клиенте домен переезжает без правок — интерфейс меняется, предметная область нет. Границы: пока домен чист, IO живёт в его хранилище легально — там это и есть работа слоя; illegaly — когда файловая запись проникает в Update. Стык слоёв прост: команды-фабрики в UI-пакетах вызывают домен и упаковывают результат в messages; Update ничего не вычисляет сам, только кладёт факт в модель. Если правило «Update не ходит в файл» когда-нибудь нарушится, компилятор этого не заметит — заметит только чек-лист ревью ниже.

### Проверка: где жить хранилищу

**Вопрос:** Сохранение задачи в файл падает с ошибкой пути. В каком пакете ищете баг и его тест по правилам урока?

- В экране editor — сохранили оттуда
- ✅ В internal/domain — файловое хранилище живёт там и тестируется без tea
- В root.go — корень отвечает за всё
- В styles.go — рядом с остальными настройками

Запись файла — доменная работа: она живёт в internal/domain и тестируется чистым go test без рантайма интерфейса. Экран editor только упаковал вызов в команду и ждёт savedMsg. Если запись затесалась в Update — это тот самый баг, который чек-лист ловит.

### Главный model собирает экраны

Корень — единственный, кто знает всех. Поля: экраны, текущий режим, строка статуса. Init корня собирает команды Init'ов экранов и свои тики через tea.Batch. Update держит два этажа. Сначала перехват: события messages — savedMsg, тики, размер окна — корень разбирает сам, потому что они межэкранные. WindowSizeMsg рассылается всем экранам сразу — у каждого своя раскладка. Затем роутинг клавиш: активному экрану по mode, остальным — тишина; это та же маршрутизация по фокусу из урока про формы, только уровень выше. View — switch по режиму, каждый экран рисует свой кусок, корень доклеивает статус-бар. Заметьте, что экраны не обязаны возвращать tea.Model: у корневых методов подписи с собственными типами экранов — list.Update возвращает listModel, и корень без приведений. Программе tea нужен только корень, реализующий tea.Model, — внутренности вольны в сигнатурах.

### Роутинг событий в корне

```go
func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case messages.savedMsg:
                m.mode = modeList
                m.status = "Сохранено: " + msg.path

        case tea.WindowSizeMsg:
                m.list, _ = m.list.Update(msg)
                m.editor, _ = m.editor.Update(msg)
        }

        var cmd tea.Cmd
        switch m.mode {
        case modeEditor:
                m.editor, cmd = m.editor.Update(msg)
        default:
                m.list, cmd = m.list.Update(msg)
        }
        return m, cmd
}
```

Два этажа: сначала межэкранные события — сохранение переключает режим, размер рассылается всем; затем клавиши и хвосты уходят активному экрану. Экранные Update возвращают собственные типы, поэтому без приведений интерфейсов. tea.Model реализует только корень.

### Читаем роутинг

```go
// mode = modeEditor, пользователь сохранил задачу,
// из команды вернулся messages.savedMsg{path: "tasks.json"}.

case messages.savedMsg:
        m.mode = modeList
        m.status = "Сохранено: " + msg.path

// Что произойдёт дальше с этим же сообщением
// в нижнем switch?
```

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

- Editor получит его ещё раз — mode уже сменился на список
- Список получит сообщение в default-ветке и перечитает данные
- ✅ Сообщение пропадёт — case уже его обработал
- Корень завершит программу

В switch одно сообщение входит в один case: после обработки savedMsg выполнение покидает switch, а нижний роутинг получает то же msg — но этот код ниже, и сообщение пройдёт через активный экран. Сказать точнее: это и есть цена двухэтажного роутинга — падение сообщения или его двойная доставка решаются ранним return, и об этом стоит договориться в проекте один раз.

### Откуда тестировать

Структура задаёт и порядок тестов — снизу вверх. Домен: чистые тесты хранилища и валидации, файлы во временной папке, гоняются мгновенно. Экраны: Update каждого экрана по правилам двадцать второго урока — табличные тесты переходов, фиктивные события из messages; экран тестируется без корня и без соседей, это и есть проверка изоляции. Корень: роутинг — savedMsg переключил режим, WindowSizeMsg долетел до обоих экранов; здесь хватает нескольких тестов, потому что логики мало. Стили не тестируются вовсе, а итоговый экран получает один golden-снимок — раскладка панели является частью контракта. Границы теста совпадают с границами пакетов: go test ./internal/domain/ отрабатывает быстрее, чем стартует терминал, и это не метафора. Когда новому разработчику непонятно, с чего начать знакомство с проектом, порядок тестов — он же и порядок чтения: домен, экран, корень. А после тестов — последняя линия обороны: чек-лист ревью из семи пунктов, который я прогоняю по чужому диффу, пока чай не остыл. Он покрывает все этажи структуры и ловит почти каждую рану, встречавшуюся мне в TUI-проектах:

### Чек-лист ревью TUI-кода

1. Update не ходит в сеть и в файл: только состояния, модели и возврат команд
2. Команды — фабрики без побочных эффектов до вызова замыкания, побочные — внутри замыкания
3. Ни одного lipgloss.NewStyle вне styles.go: палитра живёт в одном файле
4. Домен собирается и тестируется без bubbletea: go test ./internal/domain/ зелёный
5. Экраны не импортируют друг друга: межэкранные события описаны фактами в messages
6. У каждого типа сообщения один производитель и одна ветка Update, дедлайны несут метку попытки
7. Табличные тесты на переходы клавиш и golden-снимок на главный экран есть в диффе

### Проверка: порядок чтения

**Вопрос:** Новый коллега открывает репозиторий панели впервые. В каком порядке по структуре урока разумнее всего читать код?

- main.go, styles.go, экраны, домен — сверху вниз по дереву
- ✅ Домен, затем экраны, затем корень — от чистого к связующему
- Корень, экраны, домен — по потоку выполнения
- Порядок не важен — пакеты независимы

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

### Соберите messages.go

Восстановите словарь событий: обёртка времени, строковый статус и структура сохранения с полем ошибки.

```go
package messages

type ___ time.Time

type statusMsg ___

type savedMsg struct {
        path string
        ___  error
}
```

Подсказка: Тик называется tickMsg, статус — это string, а ошибка в структуре по конвенции поля err.

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

### Соберите файл сообщений

Расставьте строки messages.go: структура сохранения с полями и двумя скалярными типами событий.

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

1. `type savedMsg struct {`
2. `        path string`
3. `        err  error`
4. `}`
5. `type statusMsg string`
6. `type tickMsg time.Time`

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

Структура — последний этаж перед выходом в мир: [урок про сборку и дистрибуцию](/courses/bubbletea/lessons/24-sborka-distribuciya/) соберёт эту программу под три платформы и расскажет, почему деградация частоты тиков спасает SSH-сессии. [Итоговый проект курса](/courses/bubbletea/lessons/25-itogovyj-proekt/) построен ровно по этому каталогу: список, редактор, статус-бар, файловое хранилище в домене. Порядок тестов отсюда — расшифровка [урока про тестирование](/courses/bubbletea/lessons/22-testirovanie-tui/): снизу вверх, от домена к корню. Обзор всего маршрута — на странице [Bubble Tea](/courses/bubbletea/). Проверка структуры — только локальная: go build ./... и go test ./... в реальном модуле. Практика: разложите свою панель по дереву из урока, сломайте нарочно правило — импортируйте list в editor — и прочитайте ошибку компилятора: она станет вашим лучшим аргументом за messages.go на будущем ревью.

### Карточки: словарь структуры

- **Пакет на экран** — Экран — фича: model, конструктор, Update, View в одном пакете. Пакеты не импортируют соседей; нарезка не раньше пятисот строк или второго переиспользования.
- **styles.go** — Все стили Lip Gloss — переменными в одном пакете. Дрейф оттенков невозможен, правка палитры — один дифф. Функции-конструкторы в нём — лишние.
- **messages.go** — Отдельный пакет с типами межэкранных событий. Сообщение описывает факт («сохранено»), а не команду («перечитай список») — иначе цикл импорта.
- **Домен без tea** — internal/domain: задачи, валидация, хранилище без импорта bubbletea. Тесты мгновенны, слой переиспользуется CLI и GUI без правок.
- **Корневая модель** — Единственный чайник всех экранов: перехватывает messages, рассылает WindowSizeMsg, роутит клавиши по режиму. Реализует tea.Model один.

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

- Резать по экранам, а не по слоям: пакет list со всем списком, пакет editor со всем редактором.
- styles.go держит палитру программы: ни одного lipgloss.NewStyle в экранах, правка цвета — один файл.
- messages.go — словарь фактов между экранами; команды чужому экрану рождают циклический импорт.
- Домен без импорта tea тестируется мгновенно и переезжает в CLI или GUI без правок.
- Корень перехватывает межэкранные события, рассылает размеры и роутит клавиши по режиму.
- Тесты и чтение идут снизу вверх: домен, экраны, корень; golden — только на главный экран.

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

Разложите свою панель по дереву урока: домен с файловым хранилищем, пакет стилей, messages с тремя общими типами, два экрана и корень с роутингом. Добейтесь зелёных go build ./... и go test ./internal/domain/ локально. Затем напишите тест корня: savedMsg переключает режим на список и пишет статус с путём — два утверждения в одном тесте.

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

### Сколько пакетов делать — проект ещё маленький?

Держите один пакет ui, пока файлы не перевалили за пятьсот строк или пока второй экран не начал переиспользовать первый. Нарезка раньше срока даёт пакеты по три файла и лишние импорты. Домен выносите первым — он чистый, без циклов, и его границы очевидны; экраны режьте, когда они реально обособились.

### Куда класть общие виджеты — кнопку статуса, которой пользуются два экрана?

В отдельный пакет components рядом со screens: маленькие подмодели без состояния программы. Следите за направлением: components импортируют styles и messages, но не экраны. Если виджет начал знать об экранах — это уже не виджет, а часть экрана, и ему место внутри пакета экрана.

### Корень разрастается — это нормально?

Нормально до предела: роутинг, перехват сообщений и статус-бар — его работа, и пара сотен строк там уместны. Лечится выносом: групповые операции — в промежуточную подмодель, глобальные клавиши — в функцию handleGlobal, возвращающую (модель, команду, обработано). Если корень начал знать детали домена — событие описали командой, а не фактом.
