Здравствуйте. В прошлом уроке вы восстановили идиоматичную обработку ошибок: sentinel errors, errors.Is, оборачивание через %w и гарантированную очистку ресурсов с defer. Теперь соберём эти знания в код, который не превращается в один огромный main.go.
Для junior backend-разработчика это практический навык: сервис состоит из множества файлов и компонентов, но его границы должны оставаться понятными. Сегодня разберём, что в Go называют модулем и пакетом, как работает экспорт по регистру, зачем серверному проекту internal и cmd, а также как не допускать циклических зависимостей.
Модуль, пакет и каталог: три разных понятия
В Go модуль — набор связанных пакетов с единым файлом go.mod. Его строка module задаёт префикс путей импорта внутри проекта.
Например:
module github.com/yourname/task-manager
Если в этом модуле есть каталог internal/task, его путь импорта будет таким:
github.com/yourname/task-manager/internal/task
Пакет — набор Go-файлов в одном каталоге, которые компилируются вместе. Файлы не нужно импортировать друг в друга: они уже находятся в общей области видимости пакета.
internal/task/
task.go
validation.go
task_test.go
Обычные исходные файлы в этом каталоге начинаются одинаково:
package task
Функция normalizeTitle, объявленная в validation.go, доступна напрямую из task.go, даже если её имя начинается со строчной буквы. Область видимости не ограничена одним файлом: она ограничена пакетом.
Прочитайте раздел официальной документации Go: он чётко разводит понятия пакета, репозитория, модуля и пути импорта.
В разделе “Code organization” прочитайте основное объяснение целиком. Обратите внимание на два правила: файлы одного каталога компилируются как пакет, а путь модуля из go.mod является префиксом импортов его пакетов.
Важно не смешивать уровни:
| Понятие | Пример | Назначение |
|---|---|---|
| Репозиторий | task-manager/ в GitHub | Хранение исходного кода и сопутствующих файлов |
| Модуль | строка module github.com/yourname/task-manager | Единица версионирования и разрешения зависимостей |
| Пакет | internal/task, package task | Единица компиляции и область видимости кода |
| Файл | internal/task/task.go | Физическое место для части кода пакета |
Обычно у backend-сервиса один репозиторий и один модуль в корне. Не стоит создавать новый go.mod только для того, чтобы «красивее разложить код»: это создаёт отдельный модуль и новую границу зависимостей, а не просто папку.

Экспорт: заглавная буква определяет API пакета
В Go нет ключевых слов public, private или protected. Видимость идентификатора определяется первой буквой имени:
Task,New,ErrTaskNotFound,Title— экспортируемые идентификаторы;task,newTask,normalizeTitle,title— неэкспортируемые идентификаторы.
Экспорт относится к функциям, типам, константам, переменным, полям структур и методам. Идентификатор с заглавной буквы доступен пакетам, которые импортируют ваш пакет. Идентификатор со строчной буквы виден только внутри его пакета.
Посмотрите это на минимальном пакете для будущего task manager:
// internal/task/task.go
package task
import (
"errors"
"strings"
)
var ErrEmptyTitle = errors.New("task title is required")
type Task struct {
title string
}
func New(title string) (Task, error) {
normalized := normalizeTitle(title)
if normalized == "" {
return Task{}, ErrEmptyTitle
}
return Task{title: normalized}, nil
}
func (t Task) Title() string {
return t.title
}
func normalizeTitle(title string) string {
return strings.TrimSpace(title)
}
Здесь пакет осознанно предоставляет небольшой контракт:
Task— тип задачи;New— способ создать задачу;Title— способ прочитать заголовок;ErrEmptyTitle— условие, которое вызывающий код может различить черезerrors.Is.
При этом детали реализации остаются внутри:
- поле
titleнельзя изменить из другого пакета напрямую; normalizeTitleнельзя вызвать снаружи;- формат хранения заголовка можно изменить, не переписывая всех пользователей пакета.
Команда, находящаяся в другом пакете, использует только экспортируемую часть:
// cmd/task-api/main.go
package main
import (
"errors"
"fmt"
"os"
"github.com/yourname/task-manager/internal/task"
)
func main() {
created, err := task.New(" Prepare API ")
if err != nil {
if errors.Is(err, task.ErrEmptyTitle) {
fmt.Fprintln(os.Stderr, "title is required")
return
}
fmt.Fprintln(os.Stderr, "create task:", err)
return
}
fmt.Println(created.Title())
}
Обратите внимание на две разные формы доступа:
created, err := task.New("Prepare API")
Здесь task.New требуется, потому что New находится в другом пакете.
normalized := normalizeTitle(title)
А здесь квалификатор не нужен: normalizeTitle находится в том же пакете task, хотя объявлена в другом файле.
Экспортированный тип не обязан раскрывать все свои поля. Это полезно для инвариантов: задача не должна создаваться с пустым заголовком. Однако помните нюанс: экспортированное значение task.Task{} всё ещё может существовать как нулевое значение. Конструктор делает правильный путь создания явным, но сам по себе не запрещает все некорректные состояния, которые допускает язык.
Understanding Packages, Exports, and Imports in Go with Code Examples
Посмотрите фрагменты видео “Understanding Packages, Exports, and Imports in Go with Code Examples” от Code & Learn. Оно закрепит механику на коротких примерах с каталогами, объявлениями package и импортами.
Начните с создания пакета: здесь показано правило одного обычного пакета на каталог. Затем посмотрите экспорт и импорт, уделяя внимание связи заглавной буквы с доступностью идентификатора из другого пакета. Завершите именованием пакетов: особенно полезна рекомендация не создавать универсальные пакеты util и helper.
Импортируют пакеты, а не файлы
Импорт всегда задаётся путём пакета:
import "github.com/yourname/task-manager/internal/task"
Этот путь собирается из:
- пути модуля из
go.mod; - относительного пути к каталогу пакета.
По умолчанию обращаться к сущностям нужно по имени, указанному после package. Поэтому имя каталога и имя пакета почти всегда должны совпадать:
internal/task/ package task
internal/config/ package config
internal/httpapi/ package httpapi
Технически они могут различаться, но это делает код запутанным: в импорте будет один путь, а в вызовах — другое имя.
Выбирайте имя пакета по тому, что он предоставляет. Например, task, auth, config и postgres говорят о назначении. Имена utils, common, helpers не говорят почти ничего и постепенно становятся свалкой несвязанных функций.
Также Go запрещает неиспользуемые импорты. Если файл импортирует пакет, он обязан использовать его в этом файле. Это дисциплинирует зависимости: импорт не должен быть «на всякий случай».
internal и cmd: полезная основа серверного репозитория
Для самостоятельного backend-сервиса обычно не нужно создавать библиотеку, рассчитанную на импорт из других проектов. Внутренний код сервиса лучше расположить в каталоге internal.
Минимальная структура нашего портфолио-проекта может выглядеть так:
task-manager/
├── go.mod
├── cmd/
│ └── task-api/
│ └── main.go
└── internal/
└── task/
├── task.go
├── validation.go
└── task_test.go
Здесь:
cmd/task-api— точка входа бинарного приложения;cmd/task-api/main.goобъявляетpackage mainи содержитfunc main();internal/task— пакет с логикой задач;go.modнаходится в корне и описывает единый модуль.
cmd — распространённая договорённость команды, а не специальное слово Go. Если в будущем в репозитории появятся отдельные команды для миграций или фоновых обработчиков, у каждой будет собственная поддиректория и свой main.go.
internal, напротив, имеет специальное значение для компилятора. Пакет внутри internal разрешено импортировать только коду из дерева каталогов, которому принадлежит этот internal.
В нашем случае это означает, что такой импорт допустим:
import "github.com/yourname/task-manager/internal/task"
для кода внутри task-manager, например для cmd/task-api. Но другой модуль не сможет легально импортировать этот пакет, даже если в нём есть экспортированная функция task.New.
Это два независимых слоя ограничений:
| Ограничение | Что защищает |
|---|---|
| Строчная буква в имени | Код от других пакетов, включая пакеты этого же проекта |
Каталог internal | Пакет от использования вне разрешённого дерева каталогов |
То есть internal не отменяет правила экспорта. Пакет internal/task может экспортировать Task и New, чтобы ими пользовался cmd/task-api; при этом внешний модуль не сможет импортировать весь пакет.
Направление зависимостей и запрет циклов
Разделение на пакеты нужно не ради количества папок. У пакета должна быть своя ответственность и понятное направление зависимостей.
В текущем примере направление простое:
cmd/task-apiсобирает приложение и вызывает пакетtask;taskсодержит правила, относящиеся к задачам;taskне должен импортироватьcmd/task-api.
Пакет main — край приложения. Он запускает процесс, считывает конфигурацию, создаёт зависимости и передаёт управление остальному коду. Поэтому прикладной пакет не должен знать о main, аргументах командной строки или конкретном способе запуска.
Go запрещает циклические импорты. Такая пара зависимостей не скомпилируется:
package task импортирует package transport
package transport импортирует package task
Ошибка import cycle not allowed — не помеха, которую надо обходить случайными переносами файлов, а сигнал о неясной границе ответственности.
Если возник цикл, действуйте последовательно:
- Определите, какой пакет действительно владеет правилом или типом.
- Оставьте зависимость у того кода, который использует эту возможность.
- Если оба пакета правда используют общий небольшой тип, вынесите его в отдельный пакет только при наличии устойчивой общей концепции.
- Не создавайте пакет
commonлишь для того, чтобы компилятор перестал ругаться.
Позднее, когда появятся HTTP-обработчики, PostgreSQL и прикладные сценарии, вы начнёте выбирать более строгие архитектурные границы. Пока достаточно закрепить фундамент: точка входа зависит от внутренней логики, а не наоборот.
Пакеты и тесты
Для тестов у Go есть полезное исключение из правила «один пакет на каталог»:
package task
Тесты с таким объявлением находятся внутри пакета и могут проверять неэкспортируемые детали.
package task_test
Тесты с суффиксом _test находятся во внешнем тестовом пакете. Они импортируют task так же, как реальный пользователь, и могут обращаться только к его экспортируемому API.
Для доменных правил обычно удобно начинать с package task: так проще проверять внутренние вспомогательные функции. Для публичного контракта пакета особенно ценны тесты package task_test, потому что они не позволяют тесту случайно опереться на скрытую реализацию.
Закрепление в коде
Создайте описанную структуру и перенесите логику задачи из одного main.go в internal/task. После этого выполните команды из корня модуля:
go fmt ./...
go test ./...
go build ./...
go run ./cmd/task-api
go fmt ./... отформатирует все пакеты модуля, go test ./... найдёт и запустит тесты во всех пакетах, а go build ./... проверит, что они компилируются как единое целое. Последняя команда полезна после любого заметного перемещения файлов: она быстро обнаруживает неверный импорт, конфликт имён пакетов и циклическую зависимость.
Итоги
Теперь у вас есть базовая модель организации Go-кода:
- модуль определяется
go.mod; его путь служит префиксом импортов; - пакет — это Go-файлы одного каталога, компилируемые вместе;
- неэкспортируемые имена со строчной буквы видны всему пакету, а не одному файлу;
- имена с заглавной буквы формируют API для других пакетов;
- импортируют пакет по пути модуля и каталога, а не отдельный файл;
- имя пакета обычно совпадает с именем каталога и должно описывать его назначение;
internalзапрещает внешним модулям импортировать внутренний код сервиса;cmdудобно использовать для отдельных исполняемых программ;- зависимости должны быть направленными: Go не допускает циклов импорта.
На этом завершается быстрое восстановление базового Go. Дальше начнётся алгоритмический блок: вы разберёте Big O и научитесь уверенно объяснять временную и пространственную сложность решений на собеседовании.
Can't find a good explanation? Sign up and we'll make it for you
Sign up