2. Что такое архитектура. Уровни абстракции и границы системы

«Управление сложностью — вот суть программирования. Чтобы обуздать сложность в масштабе системы, необходимо проводить четкие границы и строго соблюдать явные контракты между модулями».
— Брайан Керниган

Когда начинающий разработчик слышит слово «архитектура», его воображение часто рисует красочные презентационные схемы в Miro: аккуратные прямоугольники микросервисов, ветвящиеся стрелочки брокера RabbitMQ и парящее облако Kubernetes.

Все это — важные элементы промышленной инфраструктуры. Но настоящая программная архитектура начинается задолго до того, как контейнеры отправятся в кластер. Она рождается в тот момент, когда вы решаете, как разделить систему на независимые пакеты, какие интерфейсы сделать публичными контрактами, а какие детали реализации скрыть от внешнего мира, и как организовать внедрение зависимостей, чтобы код не превратился в монолитный клубок спагетти.

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


Определение архитектуры

В классическом труде Ленни Басса, Пола Клементса и Рика Казмана «Software Architecture in Practice» дается строгое академическое определение:

Архитектура программной системы — это совокупность структур, необходимых для рассуждений о системе. Эти структуры включают программные элементы, отношения между ними и свойства обоих.

Если перевести эту академическую формулировку на язык практикующего бэкенд-инженера, архитектура отвечает на четыре ключевых вопроса:

  1. Из каких компонентов (пакетов, библиотек, сервисов) состоит система?
  2. Как эти компоненты взаимодействуют друг с другом и с внешним миром (протоколы, контракты, сериализация)?
  3. Какие принципы и ограничения управляют их проектированием и эволюцией во времени?
  4. Какие нефункциональные характеристики (задержка, пропускная способность, отказоустойчивость, безопасность) система способна гарантировать под нагрузкой?

Уровни абстракции в архитектуре

Архитектура не монолитна: она проявляется на разных масштабах масштабирования. Зрелый инженер обязан уметь мгновенно переключать оптику — от транзисторов и инструкций процессора до глобальных межсервисных топологий.

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 Enterprise["1. Уровень предприятия (Enterprise Architecture)"]
        E1["Бизнес-домены<br>Стратегия интеграции систем<br>Data Mesh & Master Data"]:::process
    end

    subgraph System["2. Уровень системы (System Architecture)"]
        S1["Микросервисы vs Монолит<br>Брокеры Kafka / RabbitMQ<br>Базы данных & Внешние API"]:::network
    end

    subgraph Application["3. Уровень приложения (Application Architecture)"]
        A1["Слоистая архитектура<br>Clean Architecture / Ports & Adapters<br>Use Cases & Репозитории"]:::process
    end

    subgraph Module["4. Уровень модуля / пакета (Package Architecture)"]
        M1["Go Packages: internal & pkg<br>Интерфейсы на стороне потребителя<br>Инкапсуляция структур"]:::process
    end

    subgraph Code["5. Уровень кода (Implementation Level)"]
        C1["Функции и методы<br>Примитивы sync.Mutex & каналы<br>Обработка ошибок & escape analysis"]:::accent
    end

    Enterprise --> System --> Application --> Module --> Code
  
  1. Уровень предприятия (Enterprise Architecture) — масштаб взаимодействия различных платформ, департаментов и команд. Здесь принимаются фундаментальные стандарты: выбор единых протоколов авторизации (SSO/OAuth2), форматы метаданных, сквозная безопасность и парадигма Data Mesh.
  2. Уровень системы (System Architecture) — фокус System Design интервью. Топология отдельных сервисов, баз данных, брокеров очередей и топологии сетевых связей. Например: выбор между синхронным REST/gRPC или асинхронным Event-Driven обменом через Kafka (19. Синхронное vs асинхронное взаимодействие сервисов, 20. RPC vs REST vs Messaging).
  3. Уровень приложения (Application Architecture) — внутренняя анатомия конкретного исполняемого сервиса. Здесь властвуют Clean Architecture (14. Clean Architecture и Dependency Rule), Hexagonal Architecture (13. Hexagonal Architecture. Ports and Adapters) и классическая слоистая трехзвенка (15. Layered Architecture. Классическая трехслойка). На этом уровне Go-инженер распределяет код по директориям cmd, internal и pkg (16. Standard Go Project Layout и его ограничения).
  4. Уровень модуля / пакета — дизайн конкретного пакета Go. В языке Go этот уровень критически важен из-за компиляторных правил и неявного удовлетворения интерфейсов (Structural Subtyping). Здесь закладываются границы инкапсуляции и публичный экспортируемый API пакета.
  5. Уровень кода — микроуровень: реализация отдельной функции, выбор конкурентного примитива (sync.Mutex или небуферизованный канал), обработка ошибок и избежание утечек горутин.

Границы системы

Главная задача архитектора — проводить границы. Архитектор непрерывно очерчивает невидимые демаркационные линии, разделяя сущности:

  • Что находится внутри ответственности сервиса, а что делегируется внешним системам?
  • Какие типы данных принадлежат чистому бизнес-домену, а какие являются деталями инфраструктурных драйверов?
  • Какие изменения изолированы внутри одного пакета internal, а какие ломают внешний контракт?
flowchart LR
    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 Domain["Чистый Домен (Бизнес-правила)"]
        UC["Use Cases & Сущности"]:::process
        RepoInt["интерфейс UserRepository\n(Определен в UseCase!)"]
    end

    subgraph Infra["Инфраструктура (Внешний мир)"]
        PG["PostgresUserRepo\n(sql.DB)"]
        HTTP["HTTP Handlers\n(net/http)"]
    end

    UC --> RepoInt
    PG -.->|"Реализует (DIP)\nDuck Typing"| RepoInt
    HTTP --> UC
  

Границы материализуются через строгие контракты:

  • HTTP / REST эндпоинт (URL, JSON Schema, RFC 7807) — граница между клиентом и сервером.
  • Protobuf-спецификация — строгая бинарная граница между gRPC-микросервисами.
  • Интерфейс Go — граница между логикой предметной области и деталями хранилища.
  • Топик Kafka со схемой событий — граница между генератором событий (Producer) и подписчиком (Consumer).

Почему границы важны в Go?

Язык Go намеренно спроектирован с минималистичным набором выразительных средств, но они идеально заточены под проведение чистых границ:

  1. Неявные интерфейсы (Duck Typing).
    В отличие от C# (: IInterface) или Java (implements), компилятор Go проверяет соответствие типов неявно. Это позволяет объявлять интерфейс в пакете-потребителе, а не в пакете-реализации. В этом кроется секрет принципа инверсии зависимостей (Dependency Inversion Principle, DIP):
Go
// Пакет usecase (бизнес-логика) объявляет, ЧТО ему требуется
package usecase

import "context"

type User struct {
    ID   string
    Name string
}

// Интерфейс объявляется там, где он используется!
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
    Save(ctx context.Context, user *User) error
}

type UserUseCase struct {
    repo UserRepository
}

func NewUserUseCase(repo UserRepository) *UserUseCase {
    return &UserUseCase{repo: repo}
}
Go
// Пакет postgres (инфраструктура) реализует КАК это устроено
package postgres

import (
    "context"
    "database/sql"
    "myapp/internal/usecase"
)

type UserRepo struct {
    db *sql.DB
}

func NewUserRepo(db *sql.DB) *UserRepo {
    return &UserRepo{db: db}
}

// Метод совпадает по сигнатуре, явного упоминания usecase.UserRepository нет!
func (r *UserRepo) FindByID(ctx context.Context, id string) (*usecase.User, error) {
    // SQL реализация выборки из PostgreSQL
    return &usecase.User{}, nil
}

func (r *UserRepo) Save(ctx context.Context, user *usecase.User) error {
    // SQL реализация сохранения
    return nil
}
  1. Защита через internal.
    Каталог с именем internal на уровне компилятора Go запрещает импорт содержащихся в нем пакетов любым внешним модулям. Это железобетонный барьер против несанкционированного связывания кода.

  2. Сквозной контекст context.Context.
    Проброс ctx через все слои — это стандартный способ очертить границы дедлайнов, таймаутов и трассировки между асинхронными компонентами.


Mechanical Sympathy на уровне архитектуры

При проведении границ между компонентами инженер обязан помнить о физических ограничениях аппаратуры:

  • Сетевой вызов vs вызов функции:
    Локальный вызов функции внутри одного процесса на процессоре занимает ~1–3 наносекунды (при попадании в кэш инструкций L1i). Сетевой RPC-вызов даже внутри одного дата-центра требует от 0.5 до 5 миллисекунд (в сотни тысяч раз дольше!). Сетевой прыжок включает переключение в ядро Linux, сериализацию, рукопожатие TCP, прохождение коммутаторов и десериализацию.
  • Аллокации и сериализация:
    Превращение структур Go в JSON или Protobuf нагружает CPU, выделяет буферы в куче (Heap) и создает давление на Garbage Collector.
  • Накладные расходы горутин:
    Горутины дешевы, но не бесплатны. Каждая зависшая в ожидании сетевого ответа горутина удерживает как минимум 2 КБ стека и заставляет планировщик тратить ресурсы на учет ее состояния.
flowchart LR
    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
    Call["Вызов логики"]:::warning --> Type{"Где проходит граница?"}
    Type -->|"Внутри процесса (In-Process)"| Mem["Память: Регистры & L1 Cache\nВремя: 2 наносекунды\nНадежность: 100%"]
    Type -->|"Через сеть (Out-of-Process)"| Net["Сетевой сокет -> Сериализация -> Кабель -> Роутер\nВремя: 5 миллисекунд (в 2 500 000 раз медленнее)\nНадежность: Частичные сбои и таймауты"]
  

Границы и эволюция системы

Качественно очерченные границы обеспечивают безболезненную эволюцию архитектуры во времени:

  • Свободная замена хранилищ:
    Если база данных укрыта за интерфейсом UserRepository, мы можем безболезненно заменить PostgreSQL на MongoDB, Redis или in-memory заглушку для unit-тестов, не изменив ни единой строчки бизнес-логики.
  • Версионирование контрактов:
    Четкая граница в виде версионированного API (/v1/users, /v2/users) позволяет плавно мигрировать клиентов без простоев (46. Версионирование сервисов и API).
  • Безболезненный распил монолита:
    Если монолит изначально строился как модульный с четкими интерфейсными границами, выделение перегруженного модуля в независимый микросервис сводится к замене вызова локальной функции на сетевой клиент, реализующий тот же самый интерфейс.

Итог

Архитектура — это не дань переменчивой моде на новые инструменты, а строгая дисциплина управления системной сложностью. Она опирается на проведение незыблемых границ и формулирование контрактов на всех пяти уровнях абстракции. Язык Go с его неявными интерфейсами, защищенными пакетами internal и минимализмом предоставляет безупречные инструменты для воплощения этих принципов в жизнь.

В следующей лекции мы перейдем от философии границ к практической отправной точке любого системного проектирования: 3. Функциональные и нефункциональные требования. Именно они определяют, где именно должны пройти границы и какие инженерные компромиссы будут оправданы.