Стиль кода бэкенда
Как выглядит код внутри слоя: имена, типизация, исключения, асинхронность, записи в журнал, комментарии, размер единиц. Раскладка по слоям, иерархия маршрутов, конверт ответа, три модели данных, идемпотентность, транзакции и таймауты — в BMBP. Формат записи журнала, уровни и срок хранения — в наблюдении за системой. Хранение самих секретов — в работе с секретами. Здесь только то, что решается при написании модуля.
13 минутКак выглядит код внутри слоя: имена, типизация, исключения, асинхронность, записи в журнал, комментарии, размер единиц. Раскладка по слоям, иерархия маршрутов, конверт ответа, три модели данных, идемпотентность, транзакции и таймауты — в BMBP. Формат записи журнала, уровни и срок хранения — в наблюдении за системой. Хранение самих секретов — в работе с секретами. Здесь только то, что решается при написании модуля.
Примеры — на Python: язык, на котором описанная раскладка обкатана (BMBP, раздел о стеке реализации). Правила, кроме синтаксических частностей, от языка не зависят.
Место в цепочке
| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| код разложен по слоям, фабрики зависимостей заведены (BMBP); контракт ответа задан | как пишется отдельный модуль внутри слоя | тестирование: зависимости подменяются без правки кода, отказы проверяются по типу |
Что предыдущий этап обязан обеспечить: каталог по слоям и явную сборку зависимостей (get_* в
api, build_* ниже). Без них запреты по слоям нечего проверять, а подмена зависимости в тесте
требует подмены модулей.
Что этот документ оставляет следующему: модули, в которые зависимость приходит параметром, а отказ выражен типом исключения. Тест такого модуля не поднимает базу и не читает окружение.
Именование
| Что | Правило | Пример |
|---|---|---|
| Модуль | роль в слое | service, repo, router, schemas, dependencies, models, client, mapper, config |
| Функция-действие | глагол предметной области | place_order, verify_code, revoke_session |
| Функция-предикат | is_* / has_*, возвращает bool | is_blocked, has_active_session |
| Конструктор значения | существительное или new_* | new_id, now_iso |
| Приватный помощник | ведущее подчёркивание | _connect, _parse_payload |
| Метод хранилища | фиксированный словарь глаголов | get_by_id, list_orders, save_order, update_order, delete_order |
Словарь методов хранилища закреплён: get_* — чтение одной записи по ключу, list_* — выборка,
save_* — создание либо обновление, delete_* — удаление. Одинаковые операции во всех
репозиториях называются одинаково, поэтому незнакомый репозиторий читается без изучения.
Имя не повторяет контекст. В классе UserRepo достаточно get_by_id: get_user_by_id_from_db
повторяет то, что уже сказано классом и слоем.
Одно понятие — одно слово во всём сервисе. Если в модели хранения account, то в DTO, схеме и
маршруте тоже account. Синонимы вперемешку («account», «profile», «user» об одном и том же)
заставляют держать в голове таблицу соответствий.
Модуль не называется utils, helpers, common. Такое имя ничего не запрещает положить
внутрь, и модуль растёт без предела. Общий фундамент слоя оформляется каталогом со служебным
префиксом (_shared), но каждый файл в нём называется своей ролью.
Типизация и схемы
Аннотации обязательны на параметрах и возвращаемых значениях, включая -> None. Слой должен
проверяться статически: без аннотаций нарушение границы обнаруживается только при выполнении.
Необязательное — X | None, одна форма записи на весь сервис. Две записи одного и того же в
соседних модулях создают впечатление, что различие есть.
Домен — типизированная структура, а не словарь. Словарь не даёт ни проверки опечатки в имени
поля, ни списка полей при чтении. Неизменяемое значение объявляется frozen=True, часто
создаваемое — со slots=True.
Перечисление — строковое (StrEnum): значение совпадает со строкой контракта, сравнение со
строкой работает, в журнал попадает читаемое имя, а не число.
Разрешённые переходы описаны в самом типе. Метод перехода проверяет, допустим ли следующий статус из текущего, и отказывает, если нет. Проверка, размазанная по вызывающему коду, расходится: один сценарий её делает, второй забывает.
def transition(self, next_status: OrderStatus) -> None:
if next_status not in ALLOWED[self.status]:
raise ValueError(f"invalid transition: {self.status} -> {next_status}")
self.status = next_statusЗависимость объявляется протоколом в core, реализация лежит в infrastructure. core
типизируется структурным интерфейсом хранилища; реализаций может быть две — на базе и в памяти.
Тест подставляет вторую как обычный аргумент, без подмены модулей на уровне импортов.
Ограничения значений — в схеме, не в обработчике. Длина, диапазон, обязательность и формат
объявляются полем схемы (min_length, max_length, ge, le). Такая проверка попадает в
документацию API и даёт единообразный отказ; проверка if len(name) > 80 внутри обработчика не
делает ни того, ни другого.
Вход и выход настроены по-разному:
| Входящая схема | Исходящая схема | |
|---|---|---|
| лишние поля | запрещены | игнорируются |
| приведение типов | выключено | не применяется |
| зачем так | опечатка в имени поля и строка вместо числа должны быть отказом, а не тихо принятым значением | модель хранения может иметь поля, которых нет в ответе |
Список в ответе — с фабрикой по умолчанию (default_factory=list): пустой список вместо null
избавляет потребителя от лишнего ветвления. Изменяемое значение по умолчанию задаётся только
фабрикой — литерал [] или {} в сигнатуре разделяется всеми вызовами.
Настройки
Настройки читаются один раз, в одном модуле, и передаются вниз параметром. Модуль в core не
обращается к переменным окружения: иначе его нельзя выполнить в тесте, не подменив окружение
процесса.
У секрета нет значения по умолчанию в коде. Отсутствие переменной должно останавливать старт — подставленное умолчание работает и в рабочей среде, где отсутствие настройки уже никто не заметит. Адреса соседних систем тоже приходят из настроек: адрес, вписанный в код, переезжает вместе с ним во все среды.
Исключения
Ожидаемый отказ — доменное исключение из core, унаследованное от подходящего встроенного
типа:
class NotFoundError(LookupError): ...
class ConflictError(ValueError): ...
class AuthenticationError(PermissionError): ...Наследование от встроенного типа позволяет вызывающему поймать группу
(except (ValueError, LookupError)), не перечисляя все доменные классы и не обновляя перечисление
при появлении нового.
Доменное исключение не знает кода ответа. Соответствие «исключение → код» задаётся в api
одним переводчиком на сервис; собирать ответ об ошибке в каждом обработчике не нужно (форма ответа
— BMBP, «Фабрика эндпойнтов»).
Отсутствие записи при чтении — не исключение. Хранилище возвращает X | None. Исключение
поднимает тот слой, для которого отсутствие записи нарушает сценарий: одному вызову «нет записи»
означает отказ, другому — что нужно создать новую.
Перехват — узким типом. except Exception уместен в двух местах: в общем обработчике
приложения и на границе фоновой задачи. Оба логируют со стеком и не продолжают работу с неполным
результатом.
Причина сохраняется: raise Translated(...) from error. Без from исходный стек теряется, и в
журнале остаётся только внешняя ошибка без места, где всё началось.
Отказ соседа различается по виду. «Сосед ответил отказом» и «соседа нет» — разные ситуации: первая означает отказ клиенту, вторая — временную недоступность и другой код наружу. Клиент внешней системы возвращает два разных исключения, а не одно общее.
Запрещено:
except …: passи молчаливыйreturn Noneвместо отказа;- перехват с записью в журнал и продолжением по тому же пути, как будто отказа не было;
- исключение как штатный поток управления;
- транспортное исключение из
infrastructure(BMBP, инвариант 5).
Асинхронность
async def — только там, где есть await. Асинхронная функция без ожидания навязывает
вызывающему асинхронный вызов и ничего не даёт взамен.
Блокирующий вызов выносится в поток: await asyncio.to_thread(...). Блокирующая операция в
цикле событий останавливает обработку всех запросов процесса, а не только собственного.
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await client.get(url, headers=headers)Клиент, созданный на каждый запрос и не закрытый, удерживает соединения до исчерпания лимита
дескрипторов. Ожидание чужого ответа ограничивается и там, где таймаут клиенту не передашь:
asyncio.wait_for(..., timeout=…).
Ресурс освобождается контекстным менеджером, а не парой «открыл — закрыл» в разных ветках: при исключении вторая половина пары не выполняется.
Остановка фоновой работы — в три шага: сигнал завершения, ожидание с таймаутом, принудительное завершение. Без третьего шага остановка сервиса зависает на одной задаче.
Общий изменяемый ресурс под конкурентной записью — под блокировкой. Асинхронность не отменяет
гонок: переключение происходит на каждом await.
Работа при импорте не выполняется. На верхнем уровне модуля остаются объявления и дешёвые константы. Подключение к базе, чтение файлов и сетевые вызовы делаются в функции: иначе импорт модуля — в том числе при сборе тестов и генерации документации — требует полного рабочего окружения.
Записи в журнал
Формат записи, уровни и срок хранения — в наблюдении за системой. Со стороны кода:
- настройка журнала одна на процесс и делается в точке входа; прикладные модули только пишут;
- логгер берётся по имени модуля (
logging.getLogger(__name__)) — источник записи виден без вписывания названия в текст сообщения; - аргументы передаются логгеру, а не собираются в строку заранее:
logger.info("order accepted %s", order_id). При выключенном уровне строка не форматируется; - отказ пишется со стеком (
exc_info=True): по одному тексту сообщения место не находится; print— не журнал: вывод идёт мимо уровня и формата, его нельзя выключить и трудно собрать;- процесс пишет в стандартный вывод, файлы из кода не открываются: сбор и ротацию делает среда;
- шумные сторонние логгеры приглушаются в настройке, одним местом, а не подавлением по вызову.
Чего в записи нет
Пароли, токены и ключи в журнал не попадают — это общее правило (наблюдение за системой). Со стороны кода к ним добавляется то, что в журнал попадает чаще всего по недосмотру:
| Не пишется | Что писать вместо |
|---|---|
| одноразовый код подтверждения, ссылка восстановления | идентификатор попытки и её исход |
| номер телефона, адрес почты, полное имя, адрес | идентификатор учётной записи |
| тело запроса и заголовки целиком | имена полей, размер, код ответа |
| значение ключа идемпотентности или сессии | необратимый отпечаток либо идентификатор |
Одноразовый код, попавший в журнал, перестаёт быть одноразовым: журнал доступен шире и живёт дольше, чем канал доставки кода. Персональные данные попадают в журнал вместе с диагностикой и уезжают туда, где хранение персональных данных не предполагалось.
Правило совпадает с тем, как секреты хранятся: в базе лежит необратимый отпечаток, сравнение идёт
по отпечатку и постоянным по времени сравнением (hmac.compare_digest). Значение существует
ровно в момент проверки — ни в хранилище, ни в журнале его нет.
Комментарии
Комментарий объясняет причину или ограничение, а не действие. «Почему выбран такой порядок», «почему предел именно такой», «что произойдёт при отказе» — полезны; пересказ следующей строки устаревает раньше неё.
- Путь файла в первой строке модуля не пишется: он виден в дереве и расходится с реальностью при первом переносе.
- Закомментированный код удаляется — прежние варианты хранит история репозитория.
- Документирующая строка нужна там, где поведение не следует из имени: инварианты, единицы измерения, побочные эффекты, поведение при отказе. Пересказ сигнатуры не нужен.
TODOсопровождается условием снятия. Без условия пометка остаётся навсегда и перестаёт читаться.
Размер
| Единица | Ориентир | Что означает превышение |
|---|---|---|
| функция | до 40 строк, один уровень абстракции | внутри спрятан отдельный шаг — он выделяется функцией |
| обработчик API | 5–20 строк | в обработчик просочилась логика |
| класс-сервис | одна доменная зона | зона делится, межзональное уходит в сценарий |
| модуль | до 300 строк | в модуле больше одной роли |
Ориентиры — признаки, а не запреты: длинное перечисление допустимых переходов или каталог кодов ошибок остаются одним куском по смыслу.
Повторяющаяся обвязка выносится. Если каждый метод сервиса начинается одинаковым try/except
с одинаковой записью в журнал, обвязка переносится в одно место. Модуль, собранный из
повторяющихся блоков, растёт линейно от числа сценариев и правится в десятке мест сразу.
def create_order(self, *, customer_id: str, items: list[Item], comment: str = "") -> Order: ...Вызов читается без обращения к сигнатуре, а перестановка параметров в объявлении не меняет смысл вызовов молча. Больше четырёх-пяти параметров — передаётся объект, а не список аргументов.
Что не появляется в коде слоя
Инварианты слоёв заданы в BMBP; здесь — как они выглядят при поиске по коду.
| Слой | Не появляется | Как проверить |
|---|---|---|
api | запросы к базе и ORM, вызовы внешних клиентов, бизнес-правила, сборка ответа об ошибке по месту | поиск импортов моделей и репозиториев, имени драйвера базы в api/ |
core | импорт транспортного фреймворка, чтение заголовков и куки, конструирование HTTP-ответов, обращение к окружению | поиск имени веб-фреймворка и os.environ в core/ |
infrastructure | бизнес-правила, транспортные исключения, вызовы сервисов и сценариев core | поиск импортов core.services, core.solutions в infrastructure/ |
shared | импорт любого другого слоя, состояние процесса, обращения к сети и базе | поиск импортов остальных слоёв в shared/ |
Проверка выполняется поиском по импортам и запускается вместе с тестами: нарушение границы — такой же отказ сборки, как несобравшийся тип.
Чеклист перед ревью
- у всех функций аннотированы параметры и результат;
- зависимости приходят параметром, окружение читается только в модуле настроек;
- ожидаемый отказ выражен доменным исключением, код ответа назначает
api; - нет перехвата
Exceptionвне общего обработчика и границы фоновой задачи; - у каждого внешнего вызова есть таймаут, клиент закрывается;
-
async defбезawaitотсутствует, блокирующие вызовы вынесены в поток; - в записях журнала нет кодов, персональных данных, тел запросов и заголовков;
- нет
print, закомментированного кода и пути файла в первой строке; - обработчик короткий, повторяющаяся обвязка вынесена;
- запреты по слоям выдерживают поиск по импортам.
Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.