gitaspen docs

Стиль кода бэкенда

Как выглядит код внутри слоя: имена, типизация, исключения, асинхронность, записи в журнал, комментарии, размер единиц. Раскладка по слоям, иерархия маршрутов, конверт ответа, три модели данных, идемпотентность, транзакции и таймауты — в 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_*, возвращает boolis_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): значение совпадает со строкой контракта, сравнение со строкой работает, в журнал попадает читаемое имя, а не число.

Разрешённые переходы описаны в самом типе. Метод перехода проверяет, допустим ли следующий статус из текущего, и отказывает, если нет. Проверка, размазанная по вызывающему коду, расходится: один сценарий её делает, второй забывает.

python
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, унаследованное от подходящего встроенного типа:

python
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 строк, один уровень абстракциивнутри спрятан отдельный шаг — он выделяется функцией
обработчик API5–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, закомментированного кода и пути файла в первой строке;
  • обработчик короткий, повторяющаяся обвязка вынесена;
  • запреты по слоям выдерживают поиск по импортам.
Инструкция не помогла?

Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.