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

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

Грабля №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)

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

Комментарии (13)


  1. hardtop
    07.06.2026 17:40

    Почему выбрали htmx, когда в связке с Alpine.js есть https://alpine-ajax.js.org/ — тоже самое, просто единая идеология.


    1. SwairIt Автор
      07.06.2026 17:40

      да про alpine-ajax я когда стек собирал даже не знал, htmx был на слуху, уже немного юзал его вот и взял его.

      щас по ссылке глянул, выглядит удобно, тем более одна экосистема с alpine. на след проекте мб попробую, спасибо что подкинул


  1. purks
    07.06.2026 17:40

    Зашел с телефона, каждое приложение работает молниеносно, особенно я пукнул, когда зашёл в брейнрот 3д игру, просто в браузере со среднего тельчика 60 фпс гейминг, реально на реакте сайты не так классно работают, как на твоём стеке


    1. SwairIt Автор
      07.06.2026 17:40

      ахаха спасибо) да мне самому кайф что на телефоне всё мгновенно, ради этого и затевал весь стек — фронт лёгкий, тормозить нечему

      3д-игрулю и сам залипаю, рад что зашла)


    1. PechoraDev
      07.06.2026 17:40

      Ай как нехорошо создавать левые аккаунты, чтобы похвалить себя же…


      1. SwairIt Автор
        07.06.2026 17:40

        ахах ну всё, спалил мою армию ботов)) да не, это реальные челы. да и смысл — проект бесплатный, код открытый, накручивать нечего


      1. purks
        07.06.2026 17:40

        Зарегался чисто чтобы этот коммент написать. Но ты давай, старина, не души так сильно, очень сойджаком пахнет. То что на рандом сайтике умеешь определять новорег аккаунты не значит, что везде боты.


  1. crazyface
    07.06.2026 17:40

    Я сделал такой же стек, только фреймворк другой, но тоже server side rendering. Но alpinejs конфликтует с htmx. Ты не можешь одновременно иметь форлуп с шаблоном в alpinejs и подгружать что-то через htmx. Так что я взял stimulus для оживления html по мере добавления его в DOM. Stimulus был создан для этого.


    1. SwairIt Автор
      07.06.2026 17:40

      о да, похожее ловил) у меня alpine-форма с hx-post вообще не стреляла, htmx её будто не видел — на таких местах делал обычный fetch и сам перерисовывал

      в итоге просто развёл их по ролям: alpine на том что отрендерилось сразу, а куда htmx подгружает туда alpine уже не лезет. но stimulus тут честно чище, он же ровно под это и сделан — цепляется к новому dom по мере добавления. мб правда более правильный путь


  1. Buzzz
    07.06.2026 17:40

    Отличный разбор, отличный стек, хорошо продумал и спасибо,что поделился. Мне тоже предстоит сейчас бота для ТГ деплоит и настраивать костыли коннектора к ТГ.


    1. SwairIt Автор
      07.06.2026 17:40

      спасибо большое) удачи с ботом. главное сразу проверь какой из телеграмовских ip реально доступен с твоего хоста — у меня из трёх dc живым был один, остальные просто висели на connect

      и если будешь патчить dns — httpx резолвит мимо socket.getaddrinfo, через свой слой, на этом я дольше всего залип. если что пиши, помогу чем смогу


      1. Buzzz
        07.06.2026 17:40

        Да, стоит такая задача. А где закупал vps? Может я бы взял твой сорри за основу))) Сейчас нам как никогда надо держаться всем вместе.


        1. SwairIt Автор
          07.06.2026 17:40

          vps на di-net.ru (DINET, та самая Digital Network), Москва, под fastpanel. только учти: костыли с коннектором зависят не столько от провайдера, сколько от роутинга конкретного хоста до телеги — у всех по-разному, где-то патч вообще не нужен

          а код бери конечно, он на mit ровно для этого) надо будет помочь поднять — пиши. держимся)