Введение: Почему архитектура — это не роскошь, а необходимость
Когда вы только начинаете писать на Go, кажется, что всё просто: горутины летают, каналы гудят, а компилятор радует скоростью. Но проходит месяц-другой, и ваш pet-проект превращается в монстра с package main на 3000 строк и молитвами «лишь бы не упало». Знакомо?
Go даёт мощные инструменты, но не навязывает жёстких рамок. Это и плюс, и минус. Без чёткой архитектуры вы быстро запутаетесь в собственных горутинах. Именно здесь на помощь приходят архитектурные паттерны в Go — проверенные решения типовых проблем проектирования.
В этой статье мы разберём четыре ключевых паттерна, которые реально упростят жизнь: Repository, Service Layer, Worker Pool и Pipeline. Вы увидите примеры кода, поймёте, когда какой паттерн применять, и сможете сразу внедрить их в свои проекты. Поехали!
1. Repository Pattern: отделяем бизнес-логику от базы данных
Первый паттерн, который должен освоить каждый Go-разработчик — Repository. Его суть проста: создаём прослойку между бизнес-логикой и хранилищем данных (БД, файлы, внешние API).
Зачем это нужно?
- Тестируемость: можно легко подменить реальную БД на мок.
- Гибкость: переезд с PostgreSQL на MongoDB? Меняем только репозиторий.
- Чистота кода: handler не знает, откуда берутся данные — это задача репозитория.
Пример реализации
Допустим, у нас есть модель пользователя:
type User struct {
ID int64
Name string
Email string
}
Определяем интерфейс репозитория:
type UserRepository interface {
GetByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id int64) error
}
И его реализацию для PostgreSQL:
type PostgresUserRepo struct {
db *sql.DB
}
func (r *PostgresUserRepo) GetByID(ctx context.Context, id int64) (*User, error) {
user := &User{}
err := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id).
Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, fmt.Errorf("get user by id: %w", err)
}
return user, nil
}
Теперь ваш handler или сервис работает только с интерфейсом UserRepository, а не с конкретной БД. Это и есть архитектурный паттерн Repository в Go в действии.
2. Service Layer: бизнес-логика под контролем
Если Repository отвечает за данные, то Service Layer — за бизнес-правила. Этот паттерн собирает разрозненные операции в осмысленные действия.
Проблема без Service Layer
Часто вижу код, где handler делает всё: валидирует запрос, проверяет права, пишет в БД и логирует. Это нарушает принцип единственной ответственности (SRP).
Как это исправить?
Вводим сервисный слой:
type UserService interface {
Register(ctx context.Context, name, email string) (*User, error)
GetProfile(ctx context.Context, userID int64) (*User, error)
}
type userService struct {
repo UserRepository
mail MailSender
}
func (s *userService) Register(ctx context.Context, name, email string) (*User, error) {
// 1. Валидация
if name == "" || email == "" {
return nil, ErrInvalidInput
}
// 2. Бизнес-логика: проверяем, не занят ли email
existing, _ := s.repo.GetByEmail(ctx, email)
if existing != nil {
return nil, ErrEmailAlreadyExists
}
// 3. Создаём пользователя
user := &User{Name: name, Email: email}
if err := s.repo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
// 4. Побочный эффект: отправляем приветственное письмо
go s.mail.SendWelcome(user.Email, user.Name)
return user, nil
}
Теперь handler только вызывает userService.Register(), а вся архитектура Go-приложения становится прозрачной и тестируемой.
3. Worker Pool: укрощаем горутины
Go славится лёгкостью конкурентности, но запуск тысячи горутин без контроля — прямой путь к панике. Worker Pool — паттерн, который ограничивает количество одновременно работающих горутин.
Когда применять?
- Обработка очередей задач (например, отправка email).
- Парсинг большого количества страниц.
- Работа с rate-limited API.
Реализация Worker Pool на Go
type Pool struct {
jobs chan func()
quit chan struct{}
wg sync.WaitGroup
}
func NewPool(workers int) *Pool {
p := &Pool{
jobs: make(chan func(), 100),
quit: make(chan struct{}),
}
for i := 0; i < workers; i++ {
p.wg.Add(1)
go p.worker(i)
}
return p
}
func (p *Pool) worker(id int) {
defer p.wg.Done()
for {
select {
case job := <-p.jobs:
job()
case <-p.quit:
return
}
}
}
func (p *Pool) Submit(job func()) {
p.jobs <- job
}
func (p *Pool) Shutdown() {
close(p.quit)
p.wg.Wait()
}
Использование:
pool := NewPool(5)
for i := 0; i < 20; i++ {
taskID := i
pool.Submit(func() {
fmt.Printf("Task %d выполняется\n", taskID)
time.Sleep(time.Second)
})
}
pool.Shutdown()
Этот паттерн Worker Pool в Golang спасает от перегрузки системы и делает конкурентность предсказуемой.
4. Pipeline: потоковая обработка данных
Pipeline — идеальный паттерн для сценариев, где данные проходят цепочку преобразований. Каждый этап — отдельная горутина, соединённая каналами.
Классический пример: конвейер обработки данных
func generate(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)