Create your own
Lesson illustration

Разделение кода на пакеты: экспорт и зависимости

Здравствуйте. В прошлом уроке вы восстановили идиоматичную обработку ошибок: 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, даже если её имя начинается со строчной буквы. Область видимости не ограничена одним файлом: она ограничена пакетом.

How to Write Go Code

Прочитайте раздел официальной документации 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 только для того, чтобы «красивее разложить код»: это создаёт отдельный модуль и новую границу зависимостей, а не просто папку.

Диаграмма показывает модуль `example.com/mymodule` внутри одного репозитория: `go.mod` находится в корне, а каждый вложенный каталог `package1` и `package2` содержит отдельный Go-пакет с несколькими исходными файлами.

Экспорт: заглавная буква определяет 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"

Этот путь собирается из:

  1. пути модуля из go.mod;
  2. относительного пути к каталогу пакета.

По умолчанию обращаться к сущностям нужно по имени, указанному после 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 — не помеха, которую надо обходить случайными переносами файлов, а сигнал о неясной границе ответственности.

Если возник цикл, действуйте последовательно:

  1. Определите, какой пакет действительно владеет правилом или типом.
  2. Оставьте зависимость у того кода, который использует эту возможность.
  3. Если оба пакета правда используют общий небольшой тип, вынесите его в отдельный пакет только при наличии устойчивой общей концепции.
  4. Не создавайте пакет 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