After scaffolding the same Telegram bot three times from scratch, I stopped and turned it into a template: feature routers, FSM flows, middleware, and a Docker setup that just runs.
By the third Telegram bot I noticed I was doing the same thing every time: copy the last project, delete the handlers, rewire the config, and fight the same setup for an afternoon before writing a single new feature. That is the signal to build a template, so I did.
The first thing the template fixes is the file every bot grows: one giant handlers module where every command, callback, and message lives together. aiogram 3 has a proper Router, so I split handlers by feature and let each one own its slice.
from aiogram import Router
from aiogram.filters import CommandStart
router = Router()
@router.message(CommandStart())
async def start(message):
await message.answer("Welcome.")
Each feature gets its own router.py, and a single place includes them all with dp.include_router(feature.router). Adding a feature stops meaning "edit the 500-line file" and starts meaning "add a folder."
The second fix is multi-step conversations. Ask a user for their name, then their age, then confirm - do that with if-statements and flags and you will lose your mind. aiogram's FSM makes the steps explicit instead.
from aiogram.fsm.state import State, StatesGroup
class Register(StatesGroup):
name = State()
age = State()
@router.message(Register.name)
async def got_name(message, state):
await state.update_data(name=message.text)
await state.set_state(Register.age)
await message.answer("How old are you?")
The state and its data live in Redis, not in the process, which matters the moment you run more than one worker or restart to deploy - the same lesson every backend eventually learns, just wearing a bot costume.
Everything cross-cutting goes in middleware rather than being repeated in handlers. The template ships three: a throttle so one user cannot hammer the bot, a database-session injector so handlers receive a ready session, and i18n so replies match the user's language. Handlers stay thin and about one thing.
class ThrottleMiddleware(BaseMiddleware):
async def __call__(self, handler, event, data):
if await self.too_fast(event.from_user.id):
return
return await handler(event, data)
Config is pydantic-settings reading the environment, so there are no tokens in the code and the same image runs locally and in production. And the whole thing is a Dockerfile plus a compose file with the bot, Postgres, and Redis - docker compose up and it runs, on my laptop or a fresh VPS, no README archaeology required.
None of these ideas are clever on their own. The value is that they are decided once and sit in a template, so the next bot starts at "write the feature" instead of "reinvent the scaffolding."