1. Структура микросервисного проекта на Go

От хаоса к порядку: Каркас надежного микросервиса

«Простота — это не отправная точка, это награда за понимание сущности вещей».
— Брайан Керниган

Поздравляю, коллега! Мы преодолели колоссальный путь через фундаментальную теорию распределенных систем: изучили консенсус Raft, сетевой стек Linux, оркестрацию в Kubernetes и паттерны отказоустойчивости. Теперь начинается самый долгожданный раздел — «Практика». Отныне мы будем проектировать и писать живой, промышленный код на Go.

Один из самых сильных психологических шоков для инженеров, приходящих в Go из экосистем Java (Spring Boot), C# (.NET Core) или PHP (Symfony/Laravel) — это полное отсутствие навязанной структуры каталогов и фреймворков. Go намеренно не говорит вам, в какие папки раскладывать файлы. В языке нет аннотаций, нет встроенного DI-контейнера на тяжелой рефлексии и нет обязательного «магического» ORM, диктующего структуру таблиц.

С одной стороны, такой минимализм дарит пьянящую свободу. С другой — открывает ящик Пандоры: без архитектурной дисциплины каждый разработчик начинает изобретать собственный велосипед, превращая проект через полгода активной разработки в пресловутый «большой комок грязи» (Big Ball of Mud) с циклическими зависимостями (import cycle not allowed) и нетестируемым кодом.

В этой вводной практической лекции мы спроектируем надежную, легко поддерживаемую структуру микросервиса на Go, основанную на принципах Гексагональной архитектуры (Ports and Adapters), исследуем низкоуровневую семантику директории internal в компиляторе Go и разберем, как абстракции влияют на побег переменных в кучу (Escape Analysis).


Standard Go Project Layout: Базовый скелет

Хотя команда создателей языка Go сознательно отказывается утверждать жесткий стандарт структуры каталогов, в глобальном Open Source сообществе сложился признанный индустриальный консенсус — golang-standards/project-layout.

Для распределенных микросервисов на Go эталонный скелет проекта выглядит следующим образом:

Bash
my-microservice/
├── cmd/                        # Точки входа в приложение (main-пакеты)
│   ├── payment/                # Имя основного демона/сервиса
│   │   └── main.go             # Composition Root: чтение ENV, сборка DI, запуск
│   └── worker/                 # Вспомогательный бинарник (consumer очереди)
│       └── main.go
├── internal/                   # Приватный код: бизнес-логика и реализация
│   ├── domain/                 # Доменные сущности и чистые бизнес-правила
│   ├── app/                    # Use Cases / Сценарии и порты (интерфейсы)
│   └── infrastructure/         # Адаптеры (БД, gRPC, HTTP клиенты, Kafka)
│       ├── postgres/
│       ├── kafka/
│       └── http/
├── pkg/                        # Публичный код, разрешенный к импорту извне
│   └── paymentclient/          # Официальный Go SDK клиент к нашему сервису
├── api/                        # Protobuf-файлы, gRPC-контракты, OpenAPI спецификации
├── deployments/                # Dockerfile, Helm-чарты, Kubernetes-манифесты
├── Makefile                    # Скрипты сборки, генерации кода и линтинга
├── go.mod
└── go.sum

Зачем нужен cmd/?

В реальной практике один микросервис нередко компилируется в несколько независимых бинарников:

  • cmd/payment/main.go — основной синхронный HTTP/gRPC API сервер;
  • cmd/worker/main.go — асинхронный потребитель сообщений из Kafka или RabbitMQ;
  • cmd/migrator/main.go — утилита для накатывания миграций базы данных перед деплоем.

Все эти исполняемые файлы переиспользуют одну и ту же предметную область и инфраструктурные адаптеры из директории internal/, но собираются независимыми командами go build ./cmd/payment и упаковываются в раздельные компактные Docker-образы.


Магия директории internal/

Многие разработчики создают папку internal/ просто по привычке, не осознавая, что за ней стоит фундаментальный механизм безопасности.

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Repo["Репозиторий github.com/company/payment"]
        CMD["cmd/payment/main.go"]:::entry -->|"✅ Разрешено"| Internal["internal/app"]
        Internal -->|"✅ Разрешено"| Domain["internal/domain"]
    end

    subgraph External["Внешний микросервис github.com/company/orders"]
        OrderApp["cmd/orders/main.go"] -.->|"❌ Ошибка компилятора: use of internal package not allowed!"| Internal
        OrderApp -->|"✅ Разрешено"| Pkg["pkg/paymentclient"]
    end
  

Гексагональная архитектура (Ports and Adapters)

Классический паттерн MVC (Model-View-Controller), пришедший из монолитных веб-фреймворков нулевых годов, пасует перед сложностью современных микросервисов. Он провоцирует размазывание бизнес-логики по SQL-запросам (Active Record) или создание монструозных контроллеров, в которых смешаны парсинг HTTP, транзакции БД и отправка писем.

В Go безусловным лидером является Гексагональная архитектура (Ports and Adapters), развивающая идеи Чистой Архитектуры Роберта Мартина.

Фундаментальный закон архитектуры: Стрелки зависимостей всегда направлены внутрь — от деталей инфраструктуры к чистой бизнес-логике.

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph InfrastructureLayer["Инфраструктурный слой: Адаптеры (internal/infrastructure)"]
        HTTPHandler["HTTP Handler (REST)\nВходящий адаптер"]
        gRPCServer["gRPC Server\nВходящий адаптер"]:::network
        PGRepo["PostgreSQL Repo\nИсходящий адаптер"]:::process
        KafkaPub["Kafka Publisher\nИсходящий адаптер"]:::process
    end

    subgraph ApplicationLayer["Прикладной слой: Use Cases и Порты (internal/app)"]
        PaymentService["Payment Service\nБизнес-сценарий (Use Case)"]
        RepoPort["interface TransactionRepository\nПорт хранилища"]:::data
        EventPort["interface EventPublisher\nПорт публикации"]:::accent
    end

    subgraph DomainLayer["Доменный слой: Сущности (internal/domain)"]
        TransactionEntity["struct Transaction\nИнварианты и бизнес-правила"]:::process
    end

    HTTPHandler -->|"Вызывает метод"| PaymentService
    gRPCServer -->|"Вызывает метод"| PaymentService

    PaymentService -->|"Использует сущность"| TransactionEntity
    PaymentService -->|"Зависит от интерфейса"| RepoPort
    PaymentService -->|"Зависит от интерфейса"| EventPort

    PGRepo -.->|"Реализует контракт"| RepoPort
    KafkaPub -.->|"Реализует контракт"| EventPort
  

1. Доменный слой (internal/domain)

Доменный слой — это абсолютное ядро вашей системы. Здесь располагаются чистые структуры данных Go (struct) и методы, реализующие фундаментальные бизнес-правила и инварианты.

Священное правило домена: Доменный слой не знает абсолютно ничего об окружающем мире! В нем нет тегов json:"...", нет SQL-запросов, нет импортов сторонних драйверов, HTTP или gRPC. Единственный допустимый импорт — стандартная библиотека (errors, time, math).

Go
package domain

import (
    "errors"
    "time"
)

var (
    ErrInsufficientFunds = errors.New("insufficient funds for transaction")
    ErrInvalidAmount     = errors.New("transaction amount must be strictly positive")
    ErrTransactionClosed = errors.New("cannot cancel already processed transaction")
)

type TransactionStatus string

const (
    StatusPending   TransactionStatus = "PENDING"
    StatusProcessed TransactionStatus = "PROCESSED"
    StatusCancelled TransactionStatus = "CANCELLED"
)

// Transaction представляет неделимую сущность транзакции
type Transaction struct {
    ID        string
    AccountID string
    Amount    int64
    Status    TransactionStatus
    CreatedAt time.Time
}

// Process инкапсулирует бизнес-правило обработки
func (t *Transaction) Process() error {
    if t.Amount <= 0 {
        return ErrInvalidAmount
    }
    if t.Status == StatusCancelled {
        return ErrTransactionClosed
    }
    t.Status = StatusProcessed
    return nil
}

2. Прикладной слой (internal/app)

Прикладной слой (Application Layer / Use Cases) оркестрирует выполнение пользовательских сценариев. Он координирует работу: загружает сущность через репозиторий, вызывает доменные методы, сохраняет результат и публикует событие во внешнюю шину.

Именно здесь объявляются Порты (Интерфейсы).

Go
package app

import (
    "context"
    "fmt"
    "myapp/internal/domain"
)

// TransactionRepository — это порт хранилища, определенный потребителем!
type TransactionRepository interface {
    GetByID(ctx context.Context, id string) (*domain.Transaction, error)
    Save(ctx context.Context, tx *domain.Transaction) error
}

// EventPublisher — порт для отправки доменных событий
type EventPublisher interface {
    PublishTransactionProcessed(ctx context.Context, tx *domain.Transaction) error
}

// PaymentService — сценарий использования (Use Case)
type PaymentService struct {
    repo      TransactionRepository
    publisher EventPublisher
}

// NewPaymentService принимает абстракции портов (DI)
func NewPaymentService(repo TransactionRepository, pub EventPublisher) *PaymentService {
    return &PaymentService{
        repo:      repo,
        publisher: pub,
    }
}

// ExecutePayment выполняет бизнес-сценарий оплаты
func (s *PaymentService) ExecutePayment(ctx context.Context, id string) error {
    tx, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return fmt.Errorf("failed to fetch transaction: %w", err)
    }

    if err := tx.Process(); err != nil {
        return err // Доменная ошибка валидации
    }

    if err := s.repo.Save(ctx, tx); err != nil {
        return fmt.Errorf("failed to persist transaction: %w", err)
    }

    if err := s.publisher.PublishTransactionProcessed(ctx, tx); err != nil {
        // Логируем предупреждение, но не ломаем транзакцию (или используем Outbox)
    }

    return nil
}

3. Инфраструктурный слой (internal/infrastructure или internal/adapter)

Инфраструктурный слой соединяет абстрактные порты приложения с конкретным железом, сетями и библиотеками:

  • internal/adapter/postgres — репозиторий, исполняющий сырые SQL-запросы к PostgreSQL;
  • internal/adapter/kafka — продюсер событий в брокер Apache Kafka;
  • internal/adapter/http — контроллер HTTP REST, принимающий запросы от пользователей.
Go
package postgres

import (
    "context"
    "database/sql"
    "fmt"
    "myapp/internal/domain"
)

// TransactionRepo — конкретный адаптер базы данных
type TransactionRepo struct {
    db *sql.DB
}

func NewTransactionRepo(db *sql.DB) *TransactionRepo {
    return &TransactionRepo{db: db}
}

// Save реализует метод интерфейса TransactionRepository без ключевых слов implements!
func (r *TransactionRepo) Save(ctx context.Context, tx *domain.Transaction) error {
    const query = `
        INSERT INTO transactions (id, account_id, amount, status, created_at)
        VALUES ($1, $2, $3, $4, $5)
        ON CONFLICT (id) DO UPDATE 
        SET status = EXCLUDED.status;
    `
    _, err := r.db.ExecContext(ctx, query, tx.ID, tx.AccountID, tx.Amount, string(tx.Status), tx.CreatedAt)
    if err != nil {
        return fmt.Errorf("postgres error on save: %w", err)
    }
    return nil
}

func (r *TransactionRepo) GetByID(ctx context.Context, id string) (*domain.Transaction, error) {
    // Выборка из БД и маппинг в доменную структуру...
    return nil, nil
}

Mechanical Sympathy: Интерфейсы и Escape Analysis

Интерфейсы — мощнейший инструмент архитектурного разделения. Однако каждый Senior-разработчик обязан понимать, какую цену платит процессор и сборщик мусора Go за полиморфизм.

В рантайме Go переменная интерфейса представлена внутренней структурой iface (размером в 2 машинных слова — 16 байт на x86-64):

  1. Указатель на itab (тип данных и таблица виртуальных методов fun);
  2. Указатель на сами данные data.
classDiagram
    class iface {
        +itab* tab (Метаданные типа и таблица методов)
        +unsafe.Pointer data (Указатель на реальный объект)
    }
    class itab {
        +interfacetype* inter
        +_type* _type
        +uint32 hash
        +uintptr[1] fun (Таблица адресов функций)
    }
    iface --> itab
  

Сборка воедино: Dependency Injection в main.go

В отличие от экосистем Java или C#, где контейнеры внедрения зависимостей используют сканирование пакетов во время старта и аннотации (вроде @Autowired), в Go доминирует концепция явного внедрения зависимостей (Explicit DI).

Точка сборки называется Composition Root и располагается непосредственно в файле cmd/payment/main.go. Весь граф зависимостей инициализируется снизу вверх:

Go
package main

import (
    "context"
    "database/sql"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    _ "github.com/lib/pq"

    httpAdapter "myapp/internal/adapter/http"
    "myapp/internal/adapter/kafka"
    "myapp/internal/adapter/postgres"
    "myapp/internal/app"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)

    // 1. Инициализация инфраструктурных драйверов
    db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        slog.Error("Failed to open database", "err", err)
        os.Exit(1)
    }
    defer db.Close()

    // 2. Сборка графа зависимостей (DI снизу вверх)
    // Исходящие адаптеры
    repo := postgres.NewTransactionRepo(db)
    publisher := kafka.NewEventPublisher([]string{os.Getenv("KAFKA_BROKER")})

    // Прикладной уровень (Use Cases)
    paymentService := app.NewPaymentService(repo, publisher)

    // Входящие адаптеры (Транспорт)
    paymentHandler := httpAdapter.NewPaymentHandler(paymentService)

    // 3. Запуск HTTP-сервера с поддержкой Graceful Shutdown
    mux := http.NewServeMux()
    mux.HandleFunc("POST /api/v1/payments", paymentHandler.Handle)

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

    go func() {
        slog.Info("Starting HTTP server", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("Server error", "err", err)
        }
    }()

    // Ожидание сигнала завершения
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    slog.Info("Shutting down server gracefully...")
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    _ = srv.Shutdown(ctx)
}

Вопросы для собеседования


Резюме

  1. Standard Project Layout: Разделяйте точки входа (cmd/), приватную реализацию (internal/) и публичные SDK (pkg/).
  2. internal/ — компиляторная броня: Используйте директорию internal/ для защиты домена и сценариев использования от несанкционированного импорта другими сервисами.
  3. Гексагональная архитектура: Зависимости обязаны смотреть внутрь. Домен не зависит ни от чего. Прикладной слой зависит только от домена и объявляет порты (интерфейсы). Адаптеры реализуют эти порты.
  4. Идиоматичный DI: Применяйте явное связывание зависимостей в main.go (Composition Root). При разрастании графа используйте compile-time кодогенератор google/wire.
  5. Помните об Escape Analysis: Интерфейсы скрывают конкретный тип от компилятора и часто приводят к побегу структур в кучу. В горячих вычислительных циклах избегайте лишних абстракций.

Мы создали стройный и надежный архитектурный каркас для одиночного микросервиса. Но в реальной компании таких сервисов создаются десятки. Рано или поздно у команд возникает непреодолимое желание вынести «общий код» (клиенты к базам, утилиты логирования, валидаторы) в единую общую библиотеку. К каким скрытым катастрофам монорепозиториев и версионирования это приводит? В следующей лекции мы разберем главный холивар индустрии: 2. Shared libraries vs copy paste.