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

{"type":"reading","par_intro":"Прочитайте раздел официальной документации Go: он чётко разводит понятия пакета, репозитория, модуля и пути импорта.","par_directions":"В разделе **“Code organization”** прочитайте <span data-type=\"resource_reading_textrange\" data-resource-subitem-id=\"cc0cac03\" data-range-start=\"Go programs are organized into packages. A package is a collection of source files in the same directory that are compiled together.\" data-range-end=\"Packages in the standard library do not have a module path prefix.\">основное объяснение</span> целиком. Обратите внимание на два правила: файлы одного каталога компилируются как пакет, а путь модуля из `go.mod` является префиксом импортов его пакетов.","learning_duration":"8 minutes","url":"https://go.dev/doc/code","title":"How to Write Go Code","isV2":true,"blockId":"70ec8c52-d85b-4a3b-98b7-5215ba1d9c81","lessonId":"91822767-7345-4b65-b629-6d69294be584"}



Важно не смешивать уровни:

ПонятиеПримерНазначение
Репозиторийtask-manager/ в GitHubХранение исходного кода и сопутствующих файлов
Модульстрока module github.com/yourname/task-managerЕдиница версионирования и разрешения зависимостей
Пакетinternal/task, package taskЕдиница компиляции и область видимости кода
Файлinternal/task/task.goФизическое место для части кода пакета

Обычно у backend-сервиса один репозиторий и один модуль в корне. Не стоит создавать новый go.mod только для того, чтобы «красивее разложить код»: это создаёт отдельный модуль и новую границу зависимостей, а не просто папку.

{"type":"image","url":"https://go.dev/doc/modules/images/source-hierarchy.png","caption":"Диаграмма показывает модуль `example.com/mymodule` внутри одного репозитория: `go.mod` находится в корне, а каждый вложенный каталог `package1` и `package2` содержит отдельный Go-пакет с несколькими исходными файлами.","isV2":true,"blockId":"25f3001d-e4b2-491d-8356-0c908a9e5514","lessonId":"91822767-7345-4b65-b629-6d69294be584"}




Экспорт: заглавная буква определяет 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{} всё ещё может существовать как нулевое значение. Конструктор делает правильный путь создания явным, но сам по себе не запрещает все некорректные состояния, которые допускает язык.

{"type":"video","title":"Understanding Packages, Exports, and Imports in Go with Code Examples","learning_duration":304,"video_id":"TPHk7qpBvOs","par_intro":"Посмотрите фрагменты видео “Understanding Packages, Exports, and Imports in Go with Code Examples” от Code & Learn. Оно закрепит механику на коротких примерах с каталогами, объявлениями `package` и импортами.","par_directions":"Начните с <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"52e73326\" data-range-start=\"38\" data-range-end=\"107\">создания пакета</span>: здесь показано правило одного обычного пакета на каталог. Затем посмотрите <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"b2e5e02d\" data-range-start=\"213\" data-range-end=\"336\">экспорт и импорт</span>, уделяя внимание связи заглавной буквы с доступностью идентификатора из другого пакета. Завершите <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"b7fca24f\" data-range-start=\"336\" data-range-end=\"448\">именованием пакетов</span>: особенно полезна рекомендация не создавать универсальные пакеты `util` и `helper`.","video_duration":668,"isV2":true,"blockId":"6f6c9978-acca-4715-9c24-22413cd49e17","lessonId":"91822767-7345-4b65-b629-6d69294be584"}



Импортируют пакеты, а не файлы

Импорт всегда задаётся путём пакета:

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

{
  "type": "exercise",
  "id": "fb1f9750-6b55-43d5-bffb-f2f5f3c4a565"
}

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; при этом внешний модуль не сможет импортировать весь пакет.

{
  "type": "exercise",
  "id": "f83ae6c6-e7dd-4b26-9230-413c35f16ce0"
}

Направление зависимостей и запрет циклов

Разделение на пакеты нужно не ради количества папок. У пакета должна быть своя ответственность и понятное направление зависимостей.

В текущем примере направление простое:

  • 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, потому что они не позволяют тесту случайно опереться на скрытую реализацию.

{
  "type": "exercise",
  "id": "c26e9dcd-9553-4a53-a3e6-513ad678b8ec"
}

Закрепление в коде

Создайте описанную структуру и перенесите логику задачи из одного 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