Архитектурные паттерны: слои, MVC, микросервисы и как выбрать

Разработчик рисует на доске схему из блоков и стрелок Программирование и разработка

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

Что такое архитектурный паттерн и чем он отличается от паттерна проектирования

Архитектурным паттерном называют повторно применимое решение задачи уровня всей системы. Он определяет границы между компонентами и то, какие зависимости между ними допустимы. Паттерны проектирования (порождающие, структурные, поведенческие) работают уровнем ниже, например решают, как организовать создание объекта или подменить реализацию. Абстрактную фабрику можно использовать в любом слое, но слои она не задаёт: это решает архитектурный паттерн.

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

Основные архитектурные паттерны в одной таблице

Паттерн Что разделяет Когда подходит Чем платите
Многослойная архитектура представление, бизнес-логику, доступ к данным типовое приложение, требования ещё меняются новая функция задевает несколько слоёв
MVC, MVP, MVVM данные, отображение, обработку ввода приложения с пользовательским интерфейсом экран делится на две-три роли, каждая в своём классе или функции
Гексагональная и чистая архитектура ядро и внешний мир: базу, интерфейс, тесты логика живёт дольше фреймворка, нужны тесты без базы каждая внешняя зависимость получает интерфейс и адаптер
Каналы и фильтры обработку на независимые шаги шаги можно переставлять и запускать отдельно плохо ложится на схему «запрос-ответ»
Микросервисы приложение на сервисы со своими данными сложная предметная область, частые релизы, несколько команд сеть, согласованность данных, зрелый DevOps
Событийная архитектура производителей и потребителей событий одни события нужны нескольким подсистемам, реальное время порядок доставки, итоговая согласованность
CQRS чтение и запись чтений намного больше, чем записей две модели вместо одной, при раздельных базах чтение отстаёт от записи

Первые четыре строки обычно касаются устройства одного приложения, нижние три чаще встречаются в системах из нескольких сервисов.

Многослойная архитектура

Это классический архитектурный шаблон для корпоративных приложений. Код делят на три слоя: представление (обрабатывает HTTP-запросы и рисует страницу), слой бизнес-логики (проверки и расчёты) и доступ к данным (база данных, внешние API). Верхний слой может обращаться к нижнему, нижний к верхнему нет.

В закрытом варианте слой зовёт только соседа снизу. В открытом может перепрыгнуть через уровень. Закрытый держит зависимости в узде, но если средний слой лишь передаёт запрос дальше, вы получаете код, который ничего не делает, и лишние вызовы.

Ниже пример, проверенный на Python 3.14.3. Сервис скидок не знает, где хранятся заказы: ему передают объект с методами get и save. Интерфейс Repository называют портом, а классы, которые его реализуют, адаптерами. Это уже шаг к гексагональной архитектуре.

import sqlite3
from dataclasses import dataclass
from typing import Protocol


@dataclass
class Order:  # предметная область: правила без SQL и HTTP
    id: int
    amount: int  # сумма в копейках

    def apply_discount(self, percent: int) -> None:
        if not 0 < percent <= 50:
            raise ValueError("скидка должна быть от 1 до 50 %")
        self.amount = self.amount * (100 - percent) // 100


class Repository(Protocol):  # порт: что сценарию нужно от хранилища
    def get(self, pk: int) -> Order: ...
    def save(self, item: Order) -> None: ...


class DiscountService:  # слой бизнес-логики
    def __init__(self, repo: Repository):
        self.repo = repo

    def give(self, pk: int, percent: int) -> int:
        item = self.repo.get(pk)
        item.apply_discount(percent)
        self.repo.save(item)
        return item.amount


class MemoryRepo:  # адаптер для тестов
    def __init__(self):
        self.rows = {1: 120000}

    def get(self, pk):
        return Order(pk, self.rows[pk])

    def save(self, item):
        self.rows[item.id] = item.amount


class SqliteRepo:  # адаптер для базы данных
    def __init__(self, db):
        self.db = db

    def get(self, pk):
        sql = "SELECT id, amount FROM orders WHERE id = ?"
        return Order(*self.db.execute(sql, (pk,)).fetchone())

    def save(self, item):
        sql = "UPDATE orders SET amount = ? WHERE id = ?"
        self.db.execute(sql, (item.amount, item.id))


db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER)")
db.execute("INSERT INTO orders VALUES (1, 120000)")

for repo in (MemoryRepo(), SqliteRepo(db)):
    print(type(repo).__name__, DiscountService(repo).give(1, 10))

DiscountService(MemoryRepo()).give(1, 70)

Вывод (трассировка сокращена):

MemoryRepo 108000
SqliteRepo 108000
Traceback (most recent call last):
  ...
  File "orders.py", line 13, in apply_discount
    raise ValueError("скидка должна быть от 1 до 50 %")
ValueError: скидка должна быть от 1 до 50 %

DiscountService отработал одинаково с двумя хранилищами, и в нём не поменялась ни одна строка. Правило про скидку живёт в Order, поэтому ошибку бросает модель, а не SQL-код. В проде стоит проверить граничный случай: если заказа с таким номером нет, fetchone() вернёт None, и адаптер упадёт с TypeError: __main__.Order() argument after * must be an iterable, not NoneType. Такую проверку пишут в адаптере и пробрасывают наверх понятное исключение.

Когда слоёв становится тесно, лучше делить приложение сверху на модули по предметной области (например, заказы и оплата), а внутри каждого модуля оставлять слои. Три общие папки «контроллеры, сервисы, репозитории» на весь проект удобны, пока проект маленький.

Читайте также:  "Почему обучение программированию требует усилий — ключевые трудности и полезные советы для новичков"

Ноутбук с размытым кодом на экране и чашка рядом на рабочем столе

MVC, MVP и MVVM: паттерны для интерфейса

MVC (модель, представление, контроллер) описал Трюгве Реенскауг в 1978 году, версию для библиотеки Smalltalk-80 сделали другие разработчики. Идея в разделении ответственности: объекты предметной области отделены от объектов, которые пользователь видит на экране. Модель хранит данные и правила, вид (представление) показывает их и обновляется, когда модель изменилась, контроллер принимает действия пользователя и решает, что с ними делать.

Консольный пример на том же Python 3.14.3. Вид здесь сделан двумя функциями, контроллер одной: архитектура не требует, чтобы каждый компонент был классом.

class TodoModel:
    def __init__(self):
        self.items, self.listeners = [], []

    def add(self, text: str) -> None:
        text = text.strip()
        if not text:
            raise ValueError("пустая задача")
        self.items.append(text)
        for notify in self.listeners:
            notify(self.items)


def render(items):  # вид: только вывод
    print(f"Задач: {len(items)}")
    for n, task in enumerate(items, 1):
        print(f"  {n}. {task}")


def show_error(message):  # тоже вид
    print(f"Ошибка: {message}")


def handle(model, user_input):  # контроллер: принимает ввод
    try:
        model.add(user_input)
    except ValueError as e:
        show_error(e)


model = TodoModel()
model.listeners.append(render)  # вид подписан на модель
for line in ["купить хлеб", "   ", "починить тест"]:
    handle(model, line)
Задач: 1
  1. купить хлеб
Ошибка: пустая задача
Задач: 2
  1. купить хлеб
  2. починить тест

Строка из пробелов не попала в список: модель отвергла её, контроллер поймал исключение и попросил вид показать ошибку. Модель при этом ничего не знает ни о print, ни о контроллере, и её можно тестировать без экрана.

MVP появился в 1990-х. Действия пользователя виджеты передают презентеру, и он решает, как на них реагировать. Экран варианты MVP обновляют по-разному: от подписки на модель до варианта, где все обновления делает презентер. MVVM прочно устоялся в .NET, например в .NET MAUI. В нём представление знает о модели представления (ViewModel), та знает о модели, но в обратную сторону связей нет. ViewModel выставляет свойства и команды, к которым экран привязывается через связывание данных, поэтому её можно покрыть юнит-тестами без интерфейса.

Кто обрабатывает ввод Как обновляется экран
MVC контроллер вид подписан на модель и перерисовывается сам
MVP презентер по-разному: подписка на модель или все обновления через презентер
MVVM ViewModel через команды связывание данных: экран привязан к свойствам ViewModel

Два монитора: слева макет интерфейса из блоков, справа размытый код

Выбирать между ними обычно не приходится: архитектурные паттерны MVC и MVVM задаёт фреймворк, и спорить с ним дороже, чем следовать его соглашениям.

Гексагональная и чистая архитектура

Алистер Кокберн в 2005 году описал гексагональную архитектуру, она же «порты и адаптеры». Цель: приложение одинаково можно запустить из интерфейса, из другой программы, из автотеста или пакетного скрипта, а разрабатывать и тестировать отдельно от базы данных и устройств. Порт описывает разговор с внешним миром («сохранить заказ»), адаптер переводит его на язык конкретной технологии. В примере со скидкой Repository был портом, MemoryRepo и SqliteRepo адаптерами.

Роберт Мартин в 2012 году свёл похожие подходы (гексагональный, луковый) в чистую архитектуру. Четыре круга от центра: сущности, сценарии использования, адаптеры интерфейсов, фреймворки и драйверы. Главное правило одно: зависимости в исходном коде направлены только внутрь. Сущность не импортирует библиотеку ORM, сценарий не знает про HTTP.

Читайте также:  Полное руководство по использованию класса Event Trigger в программировании

Для CRUD-формы на пять полей такая обвязка избыточна. Она окупается, когда бизнес-логика сложная и переживёт смену фреймворка или базы.

Доска со схемой из шестиугольника и блоков вокруг него, соединённых стрелками

Клиент-серверная архитектура, каналы и фильтры

Клиент-сервер делит программу на две роли: клиент отправляет запрос, сервер обрабатывает его и возвращает ответ, как браузер и веб-сервер. Если страница и API открываются с разных источников (отличается домен, схема или порт), браузер сверяет ответ с разрешениями сервера, и без них вы увидите ошибку CORS.

Каналы и фильтры делят обработку на независимые шаги. Фильтр выполняет одно действие, канал только передаёт его результат следующему и никакой логики не содержит. Учебный пример под рукой в терминале Linux: каждая команда конвейера ничего не знает о соседях. Возьмём тестовый журнал веб-сервера в формате combined:

192.168.1.10 - - [05/Oct/2026:10:00:01 +0300] "GET / HTTP/1.1" 200 5120 "-" "Mozilla/5.0"
192.168.1.11 - - [05/Oct/2026:10:00:03 +0300] "POST /login HTTP/1.1" 302 0 "-" "Mozilla/5.0"
192.168.1.10 - - [05/Oct/2026:10:00:07 +0300] "GET / HTTP/1.1" 200 5120 "-" "Mozilla/5.0"
192.168.1.12 - - [05/Oct/2026:10:00:09 +0300] "GET /about HTTP/1.1" 200 2048 "-" "Mozilla/5.0"
192.168.1.11 - - [05/Oct/2026:10:00:12 +0300] "GET / HTTP/1.1" 200 5120 "-" "Mozilla/5.0"

Адрес страницы стоит седьмым полем, если делить строку по пробелам:

cut -d' ' -f7 access.log | sort | uniq -c | sort -rn
      3 /
      1 /login
      1 /about

Четыре фильтра посчитали, какие адреса чаще встречаются в журнале. Порядок здесь важен: uniq -c склеивает только соседние одинаковые строки, поэтому вход у него должен быть отсортирован. Без первого sort на этом журнале получится пять строк с единицами. Новый фильтр, например grep, можно вставить перед sort, и остальные команды этого не заметят. Тот же приём работает с процессами в Linux, когда вывод ps фильтруют через grep. Если результат нужно вернуть клиенту сразу, в ответ на тот же запрос, паттерн подходит плохо.

Микросервисная архитектура

Микросервисная архитектура делит приложение на небольшие автономные сервисы. Каждый отвечает за одну бизнес-возможность, хранит свои данные сам и общается с остальными через API. Сервис может писать и поддерживать одна небольшая команда, выкатывают его независимо.

Ограничения стиля жёсткие: сервис не лезет в чужую базу, общих схем данных нет. Взамен вы получаете независимые релизы и изоляцию отказов. Каждый сервис проще монолита, но система в целом сложнее: появляются сетевые задержки, поиск сервисов, версии API, которые нельзя ломать соседям, и мониторинг цепочек вызовов. Изменение данных в двух сервисах, скорее всего, не получится провести одной ACID-транзакцией, как в одной базе SQL Server: приходится мириться с итоговой согласованностью.

Не начинайте разработку с микросервисов. Пока предметная область плохо изучена, границы между сервисами легко провести неправильно, а расходы на сеть, развёртывание и мониторинг вы платите с первого дня. Модульный монолит, где каждый модуль устроен слоями, потом проще разделить по уже проверенным границам.

Серверная стойка с мигающими индикаторами и подключёнными сетевыми кабелями

Событийно-ориентированная архитектура

В событийно-ориентированной архитектуре есть производители, потребители и канал между ними, обычно брокер сообщений. Производитель публикует «заказ оплачен» и не знает, кто его слушает, например склад или рассылка. Новый потребитель добавляется без правки производителя.

Доставка устроена по одной из двух моделей. В модели «издатель-подписчик» сообщение рассылается текущим подписчикам и не хранится, поэтому новый подписчик старых не увидит. В потоковой модели всё пишется в журнал, и клиент может подключиться позже и перечитать его с нужного места.

Этот подход стоит использовать, когда одни и те же события нужны нескольким подсистемам или нужна обработка почти в реальном времени. Если хватает обычного синхронного вызова или бизнес-операция требует строгой согласованности между сервисами, событийная модель добавит сложность без пользы. Даже там, где события оправданы, придётся решать, как гарантировать доставку и что делать, если сообщение пришло дважды или не по порядку. В каждое событие стоит класть идентификатор корреляции, иначе цепочку обработки не найти в логах. Внутри одной программы такая развязка знакома по браузерному JavaScript: кнопка, на которую повесили обработчики через addEventListener, не знает, сколько их и что они делают. Если же обработчик ждёт ответа сервера, это уже работа с одной асинхронной операцией через async/await, и развязки производителя и потребителя там нет.

Паттерны распределённых систем

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

Читайте также:  TRY CATCH в SQL Server: обработка ошибок в T-SQL на примерах

Инженер подключает кабель к сетевому коммутатору в стойке

CQRS

CQRS разделяет операции записи (команды) и чтения (запросы) на две модели данных. Команда меняет данные, запрос только читает и никогда ничего не меняет. Модели можно держать в одной базе данных или в разных, тогда чтение масштабируют отдельно. Паттерн полезен, когда чтений намного больше, чем записей, или когда у предметной области сложная модель и интерфейс ведёт пользователя по шагам. При раздельных базах читатель может увидеть данные с задержкой.

Event Sourcing

Вместо текущего состояния данных хранится вся последовательность событий в журнале, куда можно только дописывать. Текущее состояние собирают, проигрывая события по порядку. Ошибочное событие не правят: добавляют компенсирующее. Так получается полный аудит, например для журнала платежей. Цена высокая: меняется работа с хранением, конкурентным доступом и схемами событий, а уйти с этого подхода потом дорого.

Sidecar

Вспомогательный компонент запускается в отдельном процессе или контейнере рядом с приложением и живёт столько же, сколько оно, как коляска мотоцикла. В него выносят логирование, мониторинг или сетевой прокси.

Монитор с размытыми графиками мониторинга и мини-компьютер рядом на столе

Circuit Breaker

«Автоматический выключатель» стоит между приложением и удалённым сервисом и перестаёт пропускать вызовы, когда число сбоев за период превысило порог. У него три состояния:

  • закрыт: вызовы проходят, сбои считаются;
  • открыт: вызов сразу возвращает ошибку, сервис получает время на восстановление;
  • полуоткрыт: проходит немного пробных вызовов, и по их результату выключатель закрывается или снова открывается.

Strangler Fig

Strangler Fig помогает постепенно заменить старую систему. Перед ней ставят фасад-прокси, который отправляет часть запросов в старый код, часть в новые сервисы. Функции переносят по одной, пока старая система не опустеет.

Когда эти паттерны лишние

Паттерн Не подойдёт, если
CQRS бизнес-правила простые и приложению хватает операций создания, чтения, обновления и удаления (CRUD)
Event Sourcing нужен простой CRUD без истории и аудита, или это прототип с коротким сроком жизни
Sidecar приложение маленькое, или компонент нужно масштабировать отдельно от приложения
Circuit Breaker обращения идут к локальным ресурсам вроде структур в памяти, или им пытаются заменить обработку исключений в бизнес-логике
Strangler Fig запросы к старой системе нельзя перехватить, или систему проще заменить целиком

Как выбрать архитектурный паттерн

  1. Опишите, что должна делать система, и требования, которые нельзя нарушить: время выхода, нагрузку, число команд, согласованность данных.
  2. Начните с самого простого стиля, который их выдерживает. Обычно это монолит со слоями или с модулями по предметной области.
  3. Для интерфейса используйте паттерн, который диктует фреймворк: MVC, если на нём построен ваш веб-фреймворк, MVVM в .NET MAUI.
  4. Изолируйте базу и внешние API за интерфейсами там, где логика сложная. Это дешёвый задел на будущее разделение.
  5. Переходите к микросервисам, событиям и CQRS, когда видите конкретную проблему: модуль нужно масштабировать отдельно, команды мешают друг другу на релизах, чтение душит запись.

Признак того, что архитектура слишком проста для предметной области: зависимостями никто не управляет и структура расползается. Такой код называют «большим комом грязи».

Двое разработчиков обсуждают схему из блоков на доске в небольшом офисе

Частые вопросы

Чем архитектурный паттерн отличается от паттерна проектирования из GoF?

Масштабом. Архитектурный паттерн определяет, на какие крупные части делится вся система и как они связаны. Паттерны проектирования GoF (одиночка, фабричный метод, наблюдатель и другие) решают задачу на уровне нескольких классов и могут встречаться в любом слое.

MVC это архитектурный паттерн или паттерн проектирования?

Обычно его относят к архитектурным: он делит приложение на три роли. В самом MVC при этом используются паттерны проектирования: например, вид подписан на модель по схеме «Наблюдатель».

С чего начать новичку?

С многослойной архитектуры и MVC: их проще всего разобрать на собственном коде. Сделайте небольшой проект, где контроллер не содержит SQL, а бизнес-логика не знает про HTTP. Пример со скидкой выше подойдёт как основа: добавьте к нему обработчик HTTP на любом фреймворке и проверьте, что DiscountService по-прежнему ничего не импортирует из веб-слоя.

Можно ли сочетать несколько паттернов в одном проекте?

Да, так и делают. Микросервис может быть устроен слоями, его веб-часть построена по MVC, а с соседями он обменивается сообщениями через брокер.

Оцените статью
bestprogrammer.ru
Добавить комментарий