Всем привет! Хочу рассказать, как мы применили чистую архитектуру в платежной платформе.
Сегодня наша платежная платформа представляет собой целый агрегатор самых разных финансовых решений, хотя продукт достаточно молодой, ему не более 1,5 лет.
Немного предыстории
Идея о разработке с нуля как отдельной системы возникла, когда у бизнеса появилась потребность изолировать платёжные методы и то, что с ними связано, а в дальнейшем еще и предоставлять B2B решение. Поэтому сегодня мы взаимодействуем с основным продуктом как с внешним клиентом, предоставляя API
для интеграции. Разработка происходит итеративно для того, чтобы обеспечить высокий темп и доставлять функционал постоянно.
За время жизни нашей платформы значительно расширилась кодовая база и количество сервисов. Сегодня в платформе есть:
- Больше 10 сервисов, которые отвечают за основную функциональность системы;
- Около 50 сервисов для интеграции платёжных провайдеров;
- Система реконсиляции, которая помогает автоматизировать сверку состояния транзакций из внутреннего биллинга с выгрузками из внешних платёжных систем;
- Бэк-офисы для внутреннего использования и для внешних клиентов.
По организации кода наши сервисы прошли такие этапы, как:
- Толстые модели;
- Применение MVC подхода с выделением бизнес-логики в контроллеры.
Вместе с тем, у бизнеса появляются новые требования, под которые система не была запланирована изначально. Несмотря на то, что в нашей команде работают опытные инженеры (у всех более 10 лет опыта), высокий темп разработки и различия в подходах так или иначе приводит к ряду трудностей и вопросов. Ключевая проблема состоит в разнице реализации бизнес-логики и расползании кода по разным сервисам. А основной вопрос заключается в том, как организовать код в конкретном сервисе, и в каком сервисе должен находиться конкретный код.
В нашей компании некоторые команды уже успешно применяют подходы "Чистой архитектуры" (Чистая архитектура глазами Python разработчика), и мы также обратили внимание на эту концепцию. Чтобы договориться, как её внедрять, мы провели несколько встреч, на которых озвучили проблемы и постарались выработать их решения.
Чистая архитектура
Книга Роберта Мартина, на мой взгляд, это не набор готовых рецептов, а скорее концепция и сборник рекомендаций, на что стоит обращать внимание при разработке системы, чтобы она была максимально поддерживаемой, тестируемой, готовой эволюционировать со временем. Также в его книге много названий для абстракций, таких как Adapter, Gateway, Repository, Presenter, которые часто перетягивают своё внимание при реализации подходов "Чистой архитектуры".
Поэтому, вспомнив выражение, что "плохая абстракция хуже, чем дублирование кода" и пересмотрев выступление Seven Ineffective Coding Habits by Kevlin Henney, мы решили не торопиться добавлять дополнительные термины, которые сделали бы наш код "чище". Первым делом мы разобрались с центральной частью диаграммы "Чистой архитектуры", а именно, что для нас есть Entity и Use Cases.
Для выделения сущностей мы воспользовались правилом от Роберта Мартина: “определять сущность как тó, что помогает заработать или сохранить деньги”. Итак, Entity в нашем случае это набор критически важных бизнес-данных в сочетании с критически важными бизнес-правилами — то, без чего бизнес не смог бы существовать. А сценарием использования стало то, что автоматизирует эти бизнес-правила и образует следующий слой.
Похожее правило применимо и для отдельного сервиса. Там к сущности можно отнести не только что-то имеющее отношение к деньгам, но и то, без чего сервис не появился бы. Есть, конечно, и исключения, ведь не в каждом сервисе сразу получается выделить такую "сервисообразующую" вещь, да и не каждый сервис несёт важное значение с точки зрения бизнеса или архитектуры. Он может выполнять чисто утилитарную функцию, преобразовывать данные, собирать метрики или запускать какую-то сложную операцию в бэкграунде.
Кричащая архитектура
По итогу всех этих изменений мы хотели реализовать такую структуру системы и сервисов, которая бы “разговаривала” на языке бизнеса и пользовательских историй. А новый программист имел бы возможность изучить варианты использования, даже не зная, как будут предоставляться эти услуги, в смысле фреймворков и технологий.
В нашем случае на роль центральной бизнес-сущности отлично подходят транзакции или инвойсы (invoices). Чем больше таких записей в базе данных со статусом Accepted, тем прибыльнее наш "бизнес". Роль сценариев использования выполняют различные виды платежей, например, депозиты, выводы, платежи с помощью карты, возврат карточных платежей, платежи с перенаправлением пользователя на стороннюю платёжную систему и т.п.
Далее мы подошли к одной из сложнейших задач в computer science — наименование переменных. Для примера приведу один из вариантов изменения структуры сервиса биллинга, который реализует основную логику создания платежей.
Это пример, как было до, с разделением по функциональным слоям:
billing/
__init__.py
db.py
models.py
handlers.py
server.py
helpers.py
utils.py
routes.py
И предложение по изменению с разбиением на бизнес-фичи и группировкой вертикально:
billing/
__init__.py
invoices/ # Entity's methods
__init__.py
create.py
search.py
update.py
payments/ # Business cases
__init__.py
cards.py
pushes.py
standard.py
routing/ # Business cases
__init__.py
channels.py
methods.py
routing.py
Конечно, не сразу удаётся выбрать хорошее название и разграничить, к каким частям относится тот или иной флоу. Но по мере того, как растет понимание требований, и то, как именно используются основные сущности, структура сервиса также будет меняться. Покрытие тестами и слабое зацепление (Low coupling) должны помочь в этом.
Следующим шагом мы решили сосредоточиться на зависимостях, которые не относятся к бизнес-требованиям. Стали выделять и изолировать другие слои, которые Мартин называет не значащими деталями, например, слой работы с базой данных, слой с валидацией данных, слой для формирования ответов API
. Мы начали инкапсулировать работу с базой данных за "интерфейсами".
Ещё одним правилом разделения на слои является то, что границы слоев должны пересекать только простые объекты или DTO. Мы не стали реализовывать модели ORM в виде простых классов или дата классов и писать слой для преобразования из одного в другое. Мы отложили это решение и для простоты начали рассматривать модели ORM как те самые "простые" объекты.
На уровне кода связывание высокоуровневых политик с низкоуровневыми деталями происходит с применением инверсии зависимостей (Dependency Inversion Principle).
Инверсия зависимостей
Возможность скрытия деталей реализации за интерфейсом и инверсия зависимостей основаны на применении полиморфизма. Давайте вспомним одно из его определений. Полиморфизм — возможность предоставления единого интерфейса для сущностей разного типа. На уровне кода это осуществляется в виде предоставления методов для взаимодействия со структурой данных, сервисом, реализацией определённого поведения или какой-то низкоуровневой политикой, например БД. Так мы завязываемся не на конкретную реализацию, а на декларацию какого-то поведения в виде интерфейса.
Особенности Python
Здесь хочется обсудить особенности и возможности Python при реализации абстракций.
Многие принципы в Python носят характер договорённостей и обычно достаточно нестрогих, которые при желании можно нарушать. Самый классический пример это инкапсуляции в классах. Также можно вспомнить import this
, которая выстраивает философскую идею языка.
Ещё одно свойство языка, которое позволяет реализовывать лаконичный и читаемый код, это утиная типизация. Используя данную типизацию, у нас есть возможность реализовать полиморфное поведение без использования наследования и иерархии классов. Например, если мы хотим реализовать тип данных с возможностью сложения, достаточно реализовать магический метод `add`. А что это будет, строки или кватернионы, уже не важно. Также в Python
объявление интерфейса может происходить и без использования классов.
Часто при реализации каких-то паттернов происходит копирование подходов языка, на котором он появился или для которого он более характерен. Как пример — создание интерфейса с использованием абстрактного класса.
class AbstractStorage:
def create(self, data):
raise NotImplementedError
def get_by_id(self, id_):
raise NotImplementedError
Но согласно утиной типизации, класс уже декларирует новый тип с интерфейсом даже без необходимости наследования от AbstractStorage
. Тем не менее, он может быть необходим как комментарий, подсказка разработчику или статическому анализатору о том, что существует интерфейс, который нужно реализовать.
# Python pseudocode
from billing import models
class InvoiceStorage:
def __init__(self, db):
self.db = db
def create(self, data):
invoice = models.Invoice(**data)
self.db.session.add(invoice)
self.db.commit()
return invoice
def get_by_id(self, id_):
return self.db.session.query.get(id=id_)
К слову, не всегда есть необходимость реализовывать интерфейсы, используя классы. Если у нас нет определённого состояния, которое нужно сохранять и изменять, или данный объект будет существовать лишь в одном экземпляре, объявление функций в модуле может быть достаточным для создания интерфейса.
Пример предоставления интерфейсов с помощью модулей:
# directories structure
billing/
invoices/
__init__.py
create.py
payments/
cards/
__init__.py
refund.py
deposit.py
withdrawal.py
__init__.py
basic.py
manual.py
direct.py
__init__.py
api.py
# payments/__init__.py
from . import basic, cards, manual, direct
# payments/cards/__init__.py
from . import refund, deposit, withdrawal
# payments/cards/deposit.py
from billing import invoices
def create(data: dict):
return invoices.create.new(data)
...
# payments/manual.py
from billing import invoices
def create(data: dict):
return invoices.create.new(data)
...
# api.py
from billing import invoices, payments
def basic(request):
payload = request.json()
invoice = payment.basic.create(payload)
response_data = invoices.view.minimal(invoice)
return web_response(json=response_data)
def cards_deposit(request):
payload = request.json()
invoice = payment.cards.deposit.create(payload)
response_data = invoices.view.detailed(invoice)
return web_response(json=response_data)
...
Этот пример не предполагает применение какого-то фреймворка, и request
здесь иллюстрирует внешний запрос. Это может быть http
или grpc
, RPC
или REST
, детали нам не важны. То, что у каждого типа платежей есть метод create
это тоже деталь, которая может отличаться для конкретного случая, а методы могут называться по-разному, чтобы быть более "говорящими". Главное, что хочется показать — мы имеем возможность определить пакет или модуль, который реализует какую-то высокоуровневую политику вроде создания платежей, а посредством импортов мы можем как предоставить интерфейс для взаимодействия, так и скрыть детали реализации. Это уже вопрос договорённостей.
Организация кода
Остаётся вопрос: где расположить и как группировать код в те самые низкоуровневые компоненты, которые отвечают за взаимодействие с базой данных или веб-фреймворком? Здесь, как говорится, "дьявол в деталях" имплементации.
Есть несколько подходов, и один из часто встречающихся это горизонтальное разбиение (Package by Layer), при котором группируют код, основываясь на том, для чего технически он нужен.
Часто можно увидеть такую структуру:
app/
models.py # ORM models
views.py. # WEB views
controllers.py # Business logic
utils.py
schemas.py. # Validation schemas for requests and responses
main.py
Или, используя термины из примеров "Чистой архитектуры", DDD
или других похожих фреймворков:
app/
entities/
usecases/
services/
adapters/
repositories/
main.py
Из плюсов такого подхода — можно просто что-то начать и запустить и, используя устоявшиеся термины, сгруппировать код. Из минусов — такая структура не "кричит" о своём практическом назначении, и по мере ее усложнения всё равно придется задумываться о более дробной организации.
Ещё один подход— группировка по бизнес-фичам, доменам или общему назначению кода (Package by Features). Получается, что код группируется вертикально относительно технического предназначения.
Пример вертикального разбиения:
billing/
__init__.py
invoices/ # Entity and Methods
__init.py
db.py. # Specific queries
models.py # ORM models
create.py # Entity's method
search.py
update.py
payments/ # Use Cases
cards/
__init__.py
deposit.py # Use Case
refund.py
validate.py # Validate data for use case
view.py # Create data for response
__init__.py
basic.py # Use Case
manual.py
direct.py
validate.py # Validate data for use case
view.py # Create data for response
routing/ # Use Case
__init__.py
db.py
models.py
routing.py
При таком подходе уже более понятно назначение проекта. Вместе с тем, не все бизнес-фичи имеют внешний интерфейс для вызова через API
, и не все флоу обращаются к базе данных, поэтому есть возможность поместить такие модули только в те пакеты, в которых это необходимо. Минусы у такого подхода также возникают при росте сложности системы. Например, при взаимодействии разных флоу с базой данных сложно определить, к какому домену относится та или другая модель ORM.
Ко всему этому, существуют и другие подходы: Port and Adapters, Hexagonal architecture, которые созданы для того, чтобы отделить код, связанный с предметной областью от деталей технической реализации.
Как всегда идеальный вариант вырабатывается в результате практики и постоянного эволюционирования системы. Главный критерий, на мой взгляд — структура должна оставаться максимально выразительной. Так, на данный момент мы пока не выбрали единственного подхода и используем оба.
А вот пример с группировкой по фичам и отдельного слоя взаимодействия с базой данных:
billing/
invoices/ # Entity's Methods
__init__.py
db.py # DB specific queries
create.py
search.py
update.py
payments/ # Use Cases
__init__.py
basic.py
cards.py
manual.py
validate.py # Methods to validate data for Use Cases
view.py # Methods to create data for responses from Use Cases
__init__.py
api.py # Web views
db.py # DB connection and session
models.py # ORM models
# __init__.py
from . import search, create, update
# invoices/create.py
from . import db
def new(status):
invoice = db.create_invoice(status=status)
return invoice
# invoices/search.py
from . import db
def by_id(id_):
return db.select(id=id_)
# payments/manual.py
from billing import invoices
from . import validate
def create(data):
data = validate.manual_invoice(data)
invoice = invoices.create.new(status="ACCEPTED", data)
return invoice
Заключение
Не все из принципов "Чистой архитектуры" ещё нашли место в нашем проекте. Начав изменения с выделения бизнес-флоу и убирая детали фреймворка в отдельные модули, уже видны плюсы этого подхода: более тестируемый и ясный код. А использование выразительности Python позволяет не добавлять преждевременных абстракций и сосредоточиться на реализации интерфейсов и взаимодействиях между частями системы. Постепенно меняя нашу платформу, хочется спроектировать систему с хорошим разбиением на классы, а также создать модули с определёнными границами, понятными обязанностями и управляемыми зависимостями. Впрочем, не забывая и про принципы YAGNI и KISS.