Привет, Хабр. Меня зовут Ярослав, в сети — SwairIt. Полтора месяца назад я начал писать обычный todo-лист на FastAPI, а в итоге под одним доменом getdoday.ru выросла небольшая студия из пяти продуктов: todo-приложение, кабинет для репетиторов, школьное Q&A, тренажёр билетов ПДД и Telegram-игра. Всё это — один FastAPI-монолит без единой строки React, ~76 000 строк кода и 1200+ тестов.
В этой статье я разберу то, что считаю полезным для других:
-
как один FastAPI-проект держит сразу несколько продуктов и не превращается в кашу;
-
почему я выбрал HTMX вместо React и о чём не пожалел;
-
четыре грабли Telegram Mini App, на которые ушли часы, и
monkey-patchDNS, оживший бота на проде; -
неочевидное ограничение биллинга на 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).
Каждый продукт — отдельный «срез» одного кода. Дальше расскажу, как это устроено, но сначала — про стек, потому что именно он делает такую плотность возможной.
Стек: почему не React
Я учу 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-страницы на /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 — часть, которой я доволен больше всего, и одновременно та, где граблей оказалось больше, чем кода. Разберу четыре, на которые ушли часы.
Грабля №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 ПДД — устроены вокруг компаундящегося контента. Идея простая: бесплатная, открытая и индексируемая часть отвечает на вечные поисковые запросы и приводит органический трафик годами, а монетизация живёт в приватном слое инструментов подготовки.
Для ПДД я взял официальный набор экзаменационных билетов ГИБДД (открытый материал, который воспроизводят все ПДД-сервисы), нормализовал его в свою схему и засеял 1600 вопросов с иллюстрациями по двум категориям. Каждый вопрос — отдельная индексируемая страница; на каждой странице вопроса отдаётся разметка schema.org/Question, чтобы поисковики показывали ответ в выдаче. sitemap.xml собирается из базы и охватывает обе категории.
Поверх бесплатного контента — платный слой за Stars: персональный тренажёр ошибок, симулятор экзамена по официальным правилам, статистика слабых тем. Бесплатная часть остаётся открытой и индексируемой — именно она и есть двигатель привлечения.
Дев-процесс
Качество я держу инструментами, а не силой воли:
-
mypy --strictна всёмapp/— типы обязательны,Anyточечно и осознанно. -
ruff(правилаE, F, I, UP, B, S, A, RUF) + форматтер. -
Свой линтер шаблонов: ловит, например, опасные кавычки в
x-dataAlpine и слишком мелкий текст. -
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-patchDNS решаем, но нужно понимать слои. Уhttpx → httpcore → anyioразный resolution; патч наsocket.getaddrinfoработает не везде, дляanyioпридётся переопределятьconnect_tcpу бэкендаhttpcore. -
themeParamsв Telegram Mini App нельзя копировать вслепую. В светлой теме пользователя вы получите белый фон под палитру, рассчитанную на тёмный. Надёжнее — фиксированная тема плюс явный переключатель. -
Telegram Stars — single-merchant. Если строите что-то платное, сразу заложите, что деньги идут на баланс вашего бота и вы продаёте свой продукт; маркетплейс «одни платят, другие получают» так не сделать.
Если хотите задать вопрос — пишите в комментариях или в issues на GitHub.
Спасибо, что дочитали.
ссылка на оригинал статьи https://habr.com/ru/articles/1044600/