Architecture
How the bot is built internally: the runtime containers, the layered components, and the per-update dependency-injection pipeline. This is the C4 Level 2 (Container) and Level 3 (Component) view. For the context, see System Overview; for step-by-step flows, see Runtime Flows.
Containers (deployable / runtime units)
Section titled “Containers (deployable / runtime units)”The bot runs as one process that concurrently runs three cooperating loops on a single asyncio event loop, plus its external stores.
C4Container
title Container view — abitly-bot process
Person(user, "Applicant / Admin")
System_Ext(tg, "Telegram Bot API")
Container_Boundary(proc, "abitly-bot (single Python process)") {
Container(poller, "Dispatcher (long-poll)", "aiogram 3", "getUpdates loop → middleware chain → routers/handlers")
Container(sched, "Scheduler", "APScheduler", "07:00 Europe/Kyiv daily open-day job")
Container(sender, "MessageSender", "asyncio + aiolimiter", "Bounded-concurrency outbound queue; 429 retry")
Container(health, "Healthcheck", "aiohttp", "GET /healthcheck → 200 OK (liveness probe)")
}
SystemDb_Ext(pg, "PostgreSQL (schema 'abitly')")
SystemDb_Ext(redis, "Redis")
Rel(user, tg, "Messages")
Rel(poller, tg, "Long-polls updates; sends replies")
Rel(sched, sender, "Enqueues daily reminders")
Rel(sender, tg, "Sends fan-out messages")
Rel(poller, pg, "Reads/writes via per-update session")
Rel(poller, redis, "FSM state; link-token redeem")
Rel(sched, pg, "Reads open days + monitors")
All four are started in src/abitly_bot/__main__.py. They share settings, the
Redis client, and the MessageSender singleton via the dispatcher’s workflow_data
(dp["settings"], dp["redis"], dp["sender"]).
The layered design
Section titled “The layered design”Within the dispatcher, code is organised in strict layers. Dependencies point downward only; each layer has one responsibility and a narrow interface.
flowchart TD
TG[Telegram update] --> MW[Middleware chain]
MW --> H["Handlers (handlers/)<br/>thin: Telegram I/O ↔ service calls"]
H --> S["Services (services/)<br/>fat: business logic, returns text/keyboard"]
S --> R["Repositories (repositories/)<br/>thin: data access only"]
R --> M["Models (db/models/)<br/>SQLAlchemy 2.0 mappings"]
S -. pure .-> P["score_service / ranking_service<br/>dependency-free functions"]
S --> T["texts/<br/>verbatim message strings"]
S --> K["keyboards/<br/>inline & reply markup"]
M --> DB[(PostgreSQL)]
Why this shape: handlers are nearly untestable in isolation, so logic lives in services
that return (text, keyboard) and never touch aiogram; repositories isolate data
access; score/rank are pure functions verified by golden fixtures. See
ADR 0002.
Where things live
Section titled “Where things live”| Path | Responsibility |
|---|---|
src/abitly_bot/__main__.py | Boot: healthcheck → DB fail-fast → engine/sessionmaker → bot+sender → dispatcher → scheduler → set_my_commands → long-poll. |
src/abitly_bot/config.py | Typed settings from env/.env (pydantic-settings); db_dsn, redis_url, admin_ids. |
src/abitly_bot/bot.py | Bot factory (HTML parse mode, no link preview) + the /setMyCommands list. |
src/abitly_bot/dispatcher.py | Dispatcher factory: Redis FSM storage + middleware + router wiring. |
src/abitly_bot/middlewares/ | Per-update DI chain — see below. |
src/abitly_bot/handlers/ | One router per feature area; thin adapters. |
src/abitly_bot/services/ | Business logic; returns rendered (text, keyboard) / result objects. |
src/abitly_bot/repositories/ | Thin data access (one class per aggregate). |
src/abitly_bot/db/models/ | SQLAlchemy 2.0 models mirroring the backend-owned schema. |
src/abitly_bot/db/engine.py | Async engine + sessionmaker; TLS context; search_path. |
src/abitly_bot/infra/ | Cross-cutting runtime: sender, scheduler, healthcheck, redis. |
src/abitly_bot/keyboards/ | Inline & reply keyboard builders. |
src/abitly_bot/texts/ | Verbatim Ukrainian message templates + date formatting. |
src/abitly_bot/filters/ | aiogram filters (IsAdmin). |
tests/ | Offline unit tests; tests/integration/ needs live DB (-m integration). |
Per-update dependency-injection pipeline
Section titled “Per-update dependency-injection pipeline”aiogram middlewares populate a data dict that is injected into handlers by kwarg
name. The chain is registered in dispatcher.py and runs in this order:
sequenceDiagram
participant TG as Telegram update
participant DbS as DbSessionMiddleware (outer)
participant Repos as ReposMiddleware
participant UU as UserUpsertMiddleware
participant H as Handler
TG->>DbS: update
DbS->>DbS: open AsyncSession (one txn)
DbS->>Repos: data["session"]
Repos->>Repos: build repos + services on the session
Repos->>UU: data[...repos/services...]
UU->>UU: upsert telegram_users; set data["tg_user"], ["tg_user_is_new"]
UU->>H: invoke with injected kwargs
H-->>DbS: result
alt handler raised
DbS->>DbS: session.rollback()
else ok
DbS->>DbS: session.commit()
end
DbSessionMiddleware(registered as outer so it wraps the whole chain): opens oneAsyncSessionper update, commits on success, rolls back on exception. Every update is a single transaction.middlewares/db_session.py.ReposMiddleware: constructs all repositories and services bound to that session and puts them indata(offer_service,profile_service, …). Handlers ask for exactly what they need:async def my_offers(message, tg_user, offer_service): ....middlewares/repos.py.UserUpsertMiddleware: upserts thetelegram_usersrow, refresheslast_interacted_at, and injectstg_user+tg_user_is_new(drives the one-time welcome).middlewares/user_upsert.py.
Critical interaction:
UserUpsertMiddlewarerunssession.get(TelegramUser)on every update. That is precisely why the notification-filter relationships arelazy="raise"— see ADR 0003.
Routing
Section titled “Routing”Routers are collected in handlers/__init__.py:collect_routers() and registered in a
deliberate order — specific command/callback routers first, the catch-all text
router last so it stays a fallback (mirrors the TS bot.on('text')):
admin → start → profile → offers → statistics → notifications → offer_callbacks → open_days → filters → offer_text (catch-all, LAST)offer_text matches any F.text: an offer URL renders stats, a non-offer URL replies
with the invalid-link hint, and other text is silently ignored.
State & singletons
Section titled “State & singletons”- FSM storage:
RedisStoragewith key prefixabitly_bot_fsm(dispatcher.py). Redis is shared with the backend; namespacing keeps the keyspaces apart (infra/redis.py). - Process singletons (in
dp[...]):settings,redis,sender. TheMessageSenderqueue/limiter is in-process — a key reason the bot runs as a single instance (ADR 0005). - Pure functions: score and rank hold no state.
Cross-cutting concerns
Section titled “Cross-cutting concerns”| Concern | Where | Notes |
|---|---|---|
| Transactions | DbSessionMiddleware | one session/txn per update |
| Config / secrets | config.py | SecretStr for token/passwords; env-driven |
| Rate limiting | infra/sender.py | aiolimiter + bounded concurrency (ADR 0005) |
| Scheduling | infra/scheduler.py | explicit Europe/Kyiv TZ; coalesce |
| TLS | db/engine.py | verification ON (ADR 0007) |
| Auth (admin) | filters/IsAdmin | id ∈ settings.admin_ids |
| Logging | logging.py | configured at boot from LOG_LEVEL |
| Health | infra/healthcheck.py | /healthcheck for the platform probe |