Пять продуктов в одном FastAPI-монолите: HTMX вместо React, грабли Telegram Mini App и биллинг на Stars

от автора

Привет, Хабр. Меня зовут Ярослав, в сети — 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

Главная getdoday.ru

Что живёт под одним доменом

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).

Каждый продукт — отдельный «срез» одного кода. Дальше расскажу, как это устроено, но сначала — про стек, потому что именно он делает такую плотность возможной.

Стек: почему не React

Я учу JavaScript медленнее, чем Python, и в момент, когда нужно быстро довезти что-то рабочее, изучение React стало бы тормозом. Поэтому стек я выбирал по принципу «максимум функциональности при минимуме боли на фронте». Получилось так:

Слой

Что выбрал

Почему

Backend

FastAPI + async SQLAlchemy 2.0 + Pydantic v2

Типы везде, mypy --strict зелёный, OpenAPI бесплатно

База

PostgreSQL 16 (asyncpg) + Alembic

Production-grade, миграции, а не SQLite

Шаблоны

Jinja2, server-side render

Никакой гидратации, быстрый first paint

Интерактив

HTMX 2

Свапы кусков HTML по запросу — SPA-ощущение без JSON-API и без бандла

Микросостояние

Alpine.js

x-data, x-show, x-model — небольшие JS-фрагменты прямо в HTML

Стили

Tailwind (CDN)

Ноль конфигурации и сборки

Auth

Свой на argon2 + itsdangerous

Cookie-сессии, без JWT

Логи

structlog (JSON)

Грепаем по chat_id, task_id

Инструменты

uv + ruff + mypy —strict + pre-commit

Зелёный линт на каждом коммите

CI/деплой

GitHub Actions + cron-poll

git push → прод обновляется автоматически за ~60 секунд

Самый спорный выбор — 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-страницы на /pddapp.include_router(pdd_api_router)   # JSON-эндпоинты на /api/pdd

Второе — общая инфраструктура переиспользуется, а не копируется. Авторизация (app/auth/deps.py), биллинг на Stars, рассылка писем, генерация sitemap.xml, бот — это общие модули. Когда я делал ПДД, мне не пришлось заново писать ни авторизацию, ни оплату, ни SEO-обвязку: продукт просто подключился к уже готовым кускам. Роутеры монтируются на разные префиксы (/pdd, /qa, /lessio), и каждый продукт получает свой кусок URL-пространства.

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

Telegram Mini App: четыре грабли

Mini App — часть, которой я доволен больше всего, и одновременно та, где граблей оказалось больше, чем кода. Разберу четыре, на которые ушли часы.

Mini App с быстрым вводом, кольцом прогресса и нижними табами

Mini App с быстрым вводом, кольцом прогресса и нижними табами

Грабля №1 — themeParams ломает вашу палитру

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.

Грабля №2 — api.telegram.org по IPv6 на проде = смерть бота

Бот живёт на 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_eventsimport socketfrom 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 httpxfrom httpcore._backends.auto import AutoBackendfrom telegram.request import HTTPXRequestdef _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.

Грабля №3 — у Telegram несколько дата-центров, и не все доступны с хостинга

Когда я только применил патч, в списке было три IP (три дата-центра Telegram). Бот всё равно висел: ss -tnp показывал SYN-SENT к одному из адресов, а SYN-ACK не возвращался. Я проверил каждый IP с прода curl-ом — отвечал ровно один. На остальные у провайдера, видимо, асимметричная маршрутизация.

При этом httpx выбирал адрес из списка не по порядку, а как придётся, и регулярно попадал на «мёртвый» → connect-timeout → polling стоял. Я оставил один рабочий IP — бот ожил. Этот вечер стоил мне нескольких часов.

Грабля №4 — post_init через присваивание атрибута молча игнорируется

В 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: single-merchant и entitlement

Самозанятому без юрлица в России удобнее всего принимать платежи через 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 не открывает ПДД и наоборот. Добавление новой платной вертикали в будущем — это новая строка в каталоге, а не переписывание биллинга.

SEO как мотор роста

Два продукта — Razbery и Doday ПДД — устроены вокруг компаундящегося контента. Идея простая: бесплатная, открытая и индексируемая часть отвечает на вечные поисковые запросы и приводит органический трафик годами, а монетизация живёт в приватном слое инструментов подготовки.

Razbery, школьное Q&A с разборами

Razbery, школьное Q&A с разборами

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

хаб Doday ПДД с выбором категории

хаб Doday ПДД с выбором категории
билет ПДД на мобильном: реальные вопросы с фото дорожных ситуаций

билет ПДД на мобильном: реальные вопросы с фото дорожных ситуаций

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

Lessio, публичная страница для репетиторов

Lessio, публичная страница для репетиторов

Дев-процесс

Качество я держу инструментами, а не силой воли:

  • 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.

Код проекта на GitHub (MIT)

Спасибо, что дочитали.

ссылка на оригинал статьи https://habr.com/ru/articles/1044600/