Пять продуктов в одном FastAPI-монолите: HTMX вместо React, грабли Telegram Mini App и биллинг на S…
- понедельник, 8 июня 2026 г. в 00:00:08
Привет, Хабр. Меня зовут Ярослав, в сети — SwairIt. Полтора месяца назад я начал писать обычный todo-лист на FastAPI, а в итоге под одним доменом getdoday.ru выросла небольшая студия из пяти продуктов: todo-приложение, кабинет для репетиторов, школьное Q&A, тренажёр билетов ПДД и Telegram-игра. Всё это — один FastAPI-монолит без единой строки React, ~76 000 строк кода и 1200+ тестов.
В этой статье я разберу то, что считаю полезным для других:
как один FastAPI-проект держит сразу несколько продуктов и не превращается в кашу;
почему я выбрал HTMX вместо React и о чём не пожалел;
четыре грабли Telegram Mini App, на которые ушли часы, и monkey-patch DNS, оживший бота на проде;
неочевидное ограничение биллинга на Telegram Stars и паттерн, который его обходит;
как устроен дев-процесс: mypy --strict, ruff, CI и автодеплой за минуту.
Пишу я в паре с Claude Code — терминальным AI-агентом. Не скрываю этого и ниже честно расскажу, как именно выстроен такой процесс. Поехали.

getdoday.ru — это витрина, с которой ведут ссылки на отдельные продукты. Все они работают в одном процессе, делят базу, инфраструктуру и одного бота:
Doday Tasks (/) — кросс-платформенный todo: веб-кабинет, Telegram Mini App и чат-бот. Приоритеты P1–P4, дедлайны, повторения, проекты, секции, kanban, быстрый ввод на естественном языке.
Lessio (/lessio) — публичная страница и кабинет для репетиторов: услуги, расписание, запись клиентов и оплата через Telegram Stars.
Razbery (/qa/) — школьное Q&A с разборами: 16 предметов, 5–11 класс. Не «готовый ответ», а объяснение. Растёт за счёт органического поиска.
Doday ПДД (/pdd/) — тренажёр официальных билетов ГИБДД: 1600 вопросов двух категорий (АВМ и CD), экзамен по правилам, марафон, поиск, статистика ошибок.
Tap Tower (/taptower) — небольшая Telegram-игра (Mini App).
Каждый продукт — отдельный «срез» одного кода. Дальше расскажу, как это устроено, но сначала — про стек, потому что именно он делает такую плотность возможной.
Я учу JavaScript медленнее, чем Python, и в момент, когда нужно быстро довезти что-то рабочее, изучение React стало бы тормозом. Поэтому стек я выбирал по принципу «максимум функциональности при минимуме боли на фронте». Получилось так:
Слой | Что выбрал | Почему |
|---|---|---|
Backend | FastAPI + async SQLAlchemy 2.0 + Pydantic v2 | Типы везде, |
База | PostgreSQL 16 (asyncpg) + Alembic | Production-grade, миграции, а не SQLite |
Шаблоны | Jinja2, server-side render | Никакой гидратации, быстрый first paint |
Интерактив | HTMX 2 | Свапы кусков HTML по запросу — SPA-ощущение без JSON-API и без бандла |
Микросостояние | Alpine.js |
|
Стили | Tailwind (CDN) | Ноль конфигурации и сборки |
Auth | Свой на argon2 + itsdangerous | Cookie-сессии, без JWT |
Логи | structlog (JSON) | Грепаем по |
Инструменты | uv + ruff + mypy --strict + pre-commit | Зелёный линт на каждом коммите |
CI/деплой | GitHub Actions + cron-poll |
|
Самый спорный выбор — Tailwind через CDN в проде. Да, это медленнее, чем собранный и очищенный CSS, и вес стилей великоват. Но ноль конфигурации, отсутствие сборки и node_modules окупают это на текущей стадии; на сборку через PostCSS я перейду, когда это станет узким местом.
А вот HTMX оказался приятным открытием. Я переписал половину интерфейса с ручного fetch + polling на hx-get + hx-swap и получил более отзывчивый UI, чем у части React-приложений, которые видел до этого. Причина простая: нет сериализации в JSON, нет дифа виртуального DOM, нет парсинга JS — приходит готовый кусок HTML и заменяет узел. На мобильных это особенно заметно.
Чтобы пять продуктов в одном репозитории не превратились в спагетти, я держусь двух правил.
Первое — структура по фиче, а не по слою. Не общие папки models/, routers/, services/, а самодостаточные модули:
app/ auth/ {router,service,models,schemas,deps}.py tasks/ {router,service,models,schemas}.py billing/ {router,service,models,products,stars}.py lessio/ ... qa/ ... pdd/ {router,service,models,seo,seed_load}.py telegram/ bot.py main.py # тут роутеры собираются вместе
Всё, что относится к одной фиче, лежит рядом. Новый продукт — это новая папка app/<feature>/ и пара строк в main.py:
app.include_router(pdd_router) # HTML-страницы на /pdd app.include_router(pdd_api_router) # JSON-эндпоинты на /api/pdd
Второе — общая инфраструктура переиспользуется, а не копируется. Авторизация (app/auth/deps.py), биллинг на Stars, рассылка писем, генерация sitemap.xml, бот — это общие модули. Когда я делал ПДД, мне не пришлось заново писать ни авторизацию, ни оплату, ни SEO-обвязку: продукт просто подключился к уже готовым кускам. Роутеры монтируются на разные префиксы (/pdd, /qa, /lessio), и каждый продукт получает свой кусок URL-пространства.
Правило «роутер ходит только в сервис, а не в ORM напрямую» помогает держать слои чистыми: вся работа с базой — в service.py, а роутер только собирает контекст и рендерит шаблон.
Mini App — часть, которой я доволен больше всего, и одновременно та, где граблей оказалось больше, чем кода. Разберу четыре, на которые ушли часы.

Telegram WebApp SDK отдаёт themeParams: цвета фона, текста и акцента из темы пользователя. Кажется логичным взять их и применить к своему интерфейсу, чтобы Mini App «выглядел нативно». Это ловушка.
Если у пользователя Telegram в светлой теме, вы получите bg_color: #ffffff. И все ваши rgba(255,255,255,0.06) для shimmer-эффекта, тонкие границы rgba(255,255,255,0.08), чипы под тёмный фон — на белом превращаются в нечитаемую кашу. Я один раз слепо скопировал themeParams и получил сломанный интерфейс у каждого второго пользователя.
Решение: зафиксировать собственную тему, а пользователю дать явный переключатель «тёмная / светлая / системная». Светлую я переписал с нуля на палитре slate, а не выводил из чужих цветов. Выбор хранится в localStorage; при переключении я зову tg.setHeaderColor(...) и tg.setBackgroundColor(...), чтобы перекрасился и chrome-бар Telegram поверх Mini App. Анти-мерцание — через inline-скрипт в <head> до первого paint.
Бот живёт на VPS. Системный DNS на проде отдаёт для api.telegram.org адрес, который заблокирован у провайдера, а доступный IPv4 не возвращается. В итоге httpx внутри python-telegram-bot честно резолвит хост, попадает на недоступный адрес, висит в connect-timeout — и бот молча перестаёт отвечать.
Проверка показала, что нужный IPv4 у Telegram есть: curl --resolve api.telegram.org:443:149.154.167.220 https://... работает. Просто DNS отдаёт не то. Sudo на проде нет, /etc/hosts не поправить.
Первое решение — monkey-patch на резолвер. Важная тонкость: патчить надо в двух местах, потому что синхронный и асинхронный резолв идут разными путями:
import asyncio.base_events import socket from typing import Any _TELEGRAM_API_IPS = ("149.154.167.220",) _FORCED_HOSTS = {"api.telegram.org"} def _force_ipv4_resolve() -> None: # 1. socket.getaddrinfo — синхронный резолв (curl-подобные и sync-библиотеки) sync_orig = socket.getaddrinfo def _v4_sync(host: Any, *args: Any, **kwargs: Any) -> Any: if host in _FORCED_HOSTS: port = args[0] if args else kwargs.get("port", 0) return [ (socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port)) for ip in _TELEGRAM_API_IPS ] return sync_orig(host, *args, **kwargs) socket.getaddrinfo = _v4_sync # 2. asyncio.BaseEventLoop.getaddrinfo — асинхронный резолв # (httpx → httpcore → anyio идут через event-loop.getaddrinfo, а НЕ socket) async_orig = asyncio.base_events.BaseEventLoop.getaddrinfo async def _v4_async(self: Any, host: Any, port: Any = 0, *a: Any, **k: Any) -> Any: if host in _FORCED_HOSTS: return [ (socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port)) for ip in _TELEGRAM_API_IPS ] return await async_orig(self, host, port, *a, **k) asyncio.base_events.BaseEventLoop.getaddrinfo = _v4_async # type: ignore[method-assign]
Но и этого не хватило: httpx через httpcore использует собственный resolution, который патчи на getaddrinfo игнорировал. Пришлось спуститься на слой ниже и подменить сетевой бэкенд httpcore, чтобы для api.telegram.org TCP-соединение шло на рабочий IP, а SNI и проверка сертификата оставались по исходному имени хоста — то есть TLS остаётся валидным, ничего не отключаем:
import httpx from httpcore._backends.auto import AutoBackend from telegram.request import HTTPXRequest def _make_telegram_request() -> HTTPXRequest: class _HardcodedIPBackend(AutoBackend): async def connect_tcp(self, host: Any, port: Any, *a: Any, **k: Any) -> Any: if host in _FORCED_HOSTS: # подменяем только TCP-адрес; SNI/сертификат уровнем выше # остаются api.telegram.org → соединение валидно и безопасно host = _TELEGRAM_API_IPS[0] return await super().connect_tcp(host, port, *a, **k) transport = httpx.AsyncHTTPTransport() transport._pool._network_backend = _HardcodedIPBackend() return HTTPXRequest(...) # передаём этот transport в бот
Главный вывод: у httpx → httpcore → anyio несколько уровней резолва, и патч на socket.getaddrinfo работает не везде. Для anyio-пути пришлось переопределять именно connect_tcp у бэкенда httpcore.
Когда я только применил патч, в списке было три IP (три дата-центра Telegram). Бот всё равно висел: ss -tnp показывал SYN-SENT к одному из адресов, а SYN-ACK не возвращался. Я проверил каждый IP с прода curl-ом — отвечал ровно один. На остальные у провайдера, видимо, асимметричная маршрутизация.
При этом httpx выбирал адрес из списка не по порядку, а как придётся, и регулярно попадал на «мёртвый» → connect-timeout → polling стоял. Я оставил один рабочий IP — бот ожил. Этот вечер стоил мне нескольких часов.
В python-telegram-bot v21 я повесил коллбэк на старт приложения через присваивание:
application = Application.builder().token(TOKEN).build() application.post_init = _post_init # ← молча игнорируется
Ни предупреждения, ни ошибки — просто setMyCommands не вызывался при старте, и команды бота не появлялись. Правильно — через билдер:
application = ( Application.builder().token(TOKEN).post_init(_post_init).build() )
Час дебага, пока не открыл исходники библиотеки. Мораль простая: у билдеров такого рода свойства обычно надо задавать до build(), а не после.
Самозанятому без юрлица в России удобнее всего принимать платежи через Telegram Stars: не нужны ни ОКВЭД, ни расчётный счёт. Но у этого канала есть неочевидное ограничение, в которое я уперся, когда захотел сделать что-то вроде маркетплейса.
Stars — это single-merchant. Платёж всегда зачисляется на баланс вашего бота и выдаёт «привилегию» именно плательщику. Вы не можете принять деньги покупателя и переслать их стороннему продавцу — для этого нужен лицензированный платёжный агент. Поэтому любая идея «площадки», где платят одни, а получают другие, отпадает сразу: код биллинга физически так не умеет. Остаётся честная модель — вы продаёте свой цифровой продукт конечному пользователю.
Чтобы продавать несколько продуктов независимо, я завёл единый платёжный модуль и обобщённый механизм прав доступа — entitlement. У каждого продукта в каталоге есть поле: что покупка выдаёт. Глобальный тариф (pro) трогает общий флаг пользователя; а для отдельной вертикали (например, «ПДД Pro») выдаётся именно её право — pdd_pro, не задевая остальные продукты:
@dataclass(frozen=True) class Product: code: str title: str stars_amount: int duration_months: int | None # None = бессрочно grants_tier: str | None = None # глобальный тариф (Doday Pro) grants_entitlement: str | None = None # право на одну вертикаль (pdd_pro)
А применение успешного платежа разветвляется по тому, что именно куплено. Существующие тарифные продукты ведут себя как раньше, а entitlement-продукты выдают своё право, не трогая глобальный тариф:
async def apply_successful_payment(session, *, payload, ...) -> None: product = get_product(...) # тарифные продукты (Doday Pro) — продлевают глобальный pro if product.grants_tier is not None: user.tier = product.grants_tier user.pro_until = _extend(user.pro_until, product.duration_months) # entitlement-продукты (ПДД) — выдают право на одну вертикаль, # не задевая глобальный тариф if product.grants_entitlement is not None: await _grant_entitlement(session, user.id, product)
Так «ПДД Pro» можно продавать и оценивать отдельно: своя цена, своя воронка, и при этом покупка Doday Pro не открывает ПДД и наоборот. Добавление новой платной вертикали в будущем — это новая строка в каталоге, а не переписывание биллинга.
Два продукта — Razbery и Doday ПДД — устроены вокруг компаундящегося контента. Идея простая: бесплатная, открытая и индексируемая часть отвечает на вечные поисковые запросы и приводит органический трафик годами, а монетизация живёт в приватном слое инструментов подготовки.

Для ПДД я взял официальный набор экзаменационных билетов ГИБДД (открытый материал, который воспроизводят все ПДД-сервисы), нормализовал его в свою схему и засеял 1600 вопросов с иллюстрациями по двум категориям. Каждый вопрос — отдельная индексируемая страница; на каждой странице вопроса отдаётся разметка schema.org/Question, чтобы поисковики показывали ответ в выдаче. sitemap.xml собирается из базы и охватывает обе категории.


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

Качество я держу инструментами, а не силой воли:
mypy --strict на всём app/ — типы обязательны, Any точечно и осознанно.
ruff (правила E, F, I, UP, B, S, A, RUF) + форматтер.
Свой линтер шаблонов: ловит, например, опасные кавычки в x-data Alpine и слишком мелкий текст.
pre-commit hook: формат + линт + типы + линтер шаблонов. Красный коммит не уходит в master.
CI на GitHub Actions: тесты и линт на каждый push.
Деплой — cron-poll раз в минуту делает git reset --hard origin/master, ставит зависимости, прогоняет миграции Alembic и перезапускает uvicorn. После git push прод обновляется примерно за минуту, без ручных шагов.
Отдельно про работу в паре с AI-агентом, раз уж я её не скрываю. Логика такая: я формулирую, какую фичу хочу; агент читает кодовую базу, предлагает дизайн; после моего «ок» — пишет код, прогоняет тесты, чинит ошибки и коммитит. Звучит как «всё сделал AI», но на практике важнее другое:
Решения принимаю я — какие фичи, в каком порядке, на каком стеке, какой UX. Агент предлагает варианты, я выбираю и останавливаю, если что-то идёт не по плану.
Я читаю каждый дифф — сначала изменения, потом тесты, потом ручная проверка в браузере.
Архитектура — моя — структура по фиче, mypy --strict, отказ от React, отдельный entitlement для биллинга. Это решения, которые агент исполняет.
Грабли всё равно ловить руками. Все четыре истории выше — это часы, проведённые в ss -tnp, journalctl и curl --resolve; и только потом — «вот фикс, примени».
Навык, который реально прокачивается, — это не «писать for i in range(n) по памяти», а декомпозировать задачу так, чтобы её можно было исполнить и проверить. Синтаксис я не запоминаю; я запоминаю архитектурные решения и читаю код, который попадает в проект.
~76 000 строк: ~33 000 Python в app/, ~25 000 Jinja-шаблонов, ~19 000 в тестах.
39 модулей в app/ — от auth до pdd.
1200+ тестов, зелёных на каждом push (GitHub Actions).
50 роутеров, смонтированных в одном приложении; 49 миграций Alembic.
5 продуктов под одним доменом, одна база, один бот.
Деплой ~60 секунд после git push; mypy --strict + ruff + линтер шаблонов в pre-commit, без исключений.
Парные задачи и делегирование внутри проектов — для команд из 2–3 человек.
Платный слой ПДД и Lessio: первый реальный сквозной платёж через Stars как проверка гипотезы «за это платят».
Возможно — десктоп через Tauri на той же кодовой базе: HTMX и Tailwind отлично рендерятся в webview.
HTMX даёт ощущение SPA без бандла. Server-rendered HTML + hx-swap на мобильных оказались быстрее, чем часть React-приложений, что я видел. Подходит не для всего, но для CRUD-интерфейсов — почти идеально.
monkey-patch DNS решаем, но нужно понимать слои. У httpx → httpcore → anyio разный resolution; патч на socket.getaddrinfo работает не везде, для anyio придётся переопределять connect_tcp у бэкенда httpcore.
themeParams в Telegram Mini App нельзя копировать вслепую. В светлой теме пользователя вы получите белый фон под палитру, рассчитанную на тёмный. Надёжнее — фиксированная тема плюс явный переключатель.
Telegram Stars — single-merchant. Если строите что-то платное, сразу заложите, что деньги идут на баланс вашего бота и вы продаёте свой продукт; маркетплейс «одни платят, другие получают» так не сделать.
Если хотите задать вопрос — пишите в комментариях или в issues на GitHub.
Спасибо, что дочитали.