Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Organizing Handlers with grammY's Composer

Recommended resources

The freely available resources this lesson will direct you to.

Welcome back. In our previous lessons, you've built individual pieces of middleware for logging and authentication. While functional, you've probably noticed that as you add more features—commands, message handlers, middleware—your main bot file can quickly become crowded and difficult to navigate. This is a classic software engineering problem: how do we maintain structure and scalability as an application grows?

This is where grammY's Composer class comes in. Today, you'll learn how to use it to organize your handlers into clean, reusable, and self-contained modules. For someone with your extensive front-end development experience, this concept will be very familiar. Think of Composer as being analogous to creating components in a framework like React or modules in Angular; it's a way to encapsulate related logic and behavior into a single, importable unit.

By the end of this lesson, you will be able to refactor your growing bot into a structured, modular codebase, making it far easier to manage and extend.

The Core of grammY: The Composer Class

Before we start refactoring, it's important to understand what Composer is. It's not just a helper utility; it is the fundamental building block of grammY's middleware system.

The key insight is this: the Bot class itself is a direct extension of the Composer class.

class Bot extends Composer { /* ... */ }

This means that any method you can call on your bot instance—.use(), .on(), .command(), .filter()—is actually a method inherited from Composer. A Bot instance is simply a special Composer that has the added ability to connect to Telegram and start receiving updates.

This design has powerful implications. It means you can create your own Composer instances, attach any number of handlers and middleware to them just like you would with bot, and then "plug" that entire collection of logic into your main bot instance with a single bot.use() call.

The following reading explains this relationship and reveals that grammY's middleware system is actually a tree, not a simple linear stack.

Middleware Redux - grammY

This document from the grammY documentation explains the central role of the Composer class. It's a short but crucial read for understanding the underlying architecture.

Focus on the first two sections, Middleware in grammY and Augmenting Composer. Pay close attention to the statement that Bot is a subclass of Composer and that methods like .on() and .filter() are just convenient wrappers around .use().

Structuring Your Code with Modules

Now, let's put this into practice. We're going to take the functionality we've built so far and organize it into logical modules. The grammY documentation provides a clear, proven strategy for this.

Scaling Up I: Large Codebase - grammY

This guide provides a blueprint for structuring a large bot codebase, which we will follow.

First, read the Possible Solution section to understand the overall strategy. Then, study the code in the Example Structure section. This shows exactly how to export a Composer instance from one file and import it into your main bot file.

Inspired by that guide, let's create a more organized structure for our project. Imagine our current project looks something like this:

src/bot.ts (contains everything: logger, auth, commands)

We'll refactor it to this:

src/
├── features/
│   ├── middleware.ts   # For our custom middleware (logging, auth)
│   └── commands.ts     # For our command handlers (/start, /help)
└── bot.ts              # The main entry point, now much cleaner

Step 1: Create the Middleware Module

First, create src/features/middleware.ts. In this file, we'll define our logger and auth middleware and export them within a composer.

src/features/middleware.ts

import { Composer, NextFunction } from "grammy";
import type { MyContext } from "../bot"; // Assuming you have a custom context type

// Let's bring in our logger from a previous lesson
const logger = new Composer<MyContext>();
logger.use(async (ctx, next) => {
  console.log(`Processing update ${ctx.update.update_id}...`);
  await next();
  console.log(`Finished processing update ${ctx.update.update_id}.`);
});

// And our auth middleware from the last lesson
const ALLOWED_USER_IDS = [123456789]; // Use Bun.env in a real app!

const auth = new Composer<MyContext>();
auth.use(async (ctx, next: NextFunction) => {
  if (ctx.from && ALLOWED_USER_IDS.includes(ctx.from.id)) {
    await next();
  } else {
    console.log(`Unauthorized access attempt by user ${ctx.from?.id}.`);
    await ctx.reply("Sorry, you are not authorized to use this bot.");
  }
});

// We can compose composers!
export const middleware = new Composer<MyContext>();
middleware.use(logger);
middleware.use(auth);

Notice that we created separate composers for logger and auth, and then combined them into a single middleware composer for export. This shows how modular this system can be.

Step 2: Create the Commands Module

Next, create src/features/commands.ts. This will house all our bot's command handlers.

src/features/commands.ts

import { Composer } from "grammy";
import type { MyContext } from "../bot";

export const commands = new Composer<MyContext>();

commands.command("start", (ctx) => {
  ctx.reply("Welcome! You are an authorized user.");
});

commands.command("help", (ctx) => {
  ctx.reply("This is a basic bot. Send /start to begin.");
});

Step 3: Clean Up the Main Bot File

Finally, we update our main src/bot.ts file to use these new modules. It becomes a simple "assembler" of features.

src/bot.ts

import { Bot, Context } from "grammy";
import { middleware } from "./features/middleware";
import { commands } from "./features/commands";

// Define a custom context if you need one (e.g., for sessions later)
export type MyContext = Context;

const bot = new Bot<MyContext>(Bun.env.BOT_TOKEN || "");

// 1. Install all our custom middleware.
// This will run for EVERY update.
bot.use(middleware);

// 2. Install all our command handlers.
// These will only run if the auth middleware above calls `next()`.
bot.use(commands);

// Add a catch-all handler for any messages that aren't commands
bot.on("message", (ctx) => {
    ctx.reply("I can only respond to commands for now.");
});

bot.start();
console.log("Bot started!");

Our main file is now clean, declarative, and easy to read. It clearly states the bot's capabilities: it uses some middleware, it has some commands, and it has a default message handler. All the implementation details are neatly tucked away in their respective modules.

The Power of Scoped Middleware

This modular structure becomes even more powerful when you realize that middleware can be scoped to a specific composer.

In our previous lesson, we built a global authentication middleware. Every single update had to pass through it. A more robust approach is to create a dedicated module for "admin" features and apply the authentication check only to that module.

Let's refine our auth logic. Instead of a global middleware that blocks everyone, let's create an admin composer that only processes updates from authorized users.

src/features/admin.ts

import { Composer } from "grammy";
import type { MyContext } from "../bot";

const ALLOWED_USER_IDS = [123456789]; // Your admin ID

export const admin = new Composer<MyContext>();

// Use .filter() to create a branch in the middleware tree.
// Handlers attached after this filter will ONLY run for admin users.
admin
  .filter((ctx) => {
    return ctx.from !== undefined && ALLOWED_USER_IDS.includes(ctx.from.id);
  })
  .command("admin", (ctx) => {
    ctx.reply("Welcome, admin! Here are the admin tools.");
  });

// You can add more admin-only commands here...
// admin.command("restart", ...);

Now, in your bot.ts, you would register your public commands and your admin module separately.

src/bot.ts (revised)

// ... imports
// Public commands that anyone can use
const publicCommands = new Composer<MyContext>();
publicCommands.command("start", (ctx) => ctx.reply("Welcome to the bot!"));

const bot = new Bot<MyContext>(Bun.env.BOT_TOKEN || "");

// Install public features
bot.use(publicCommands);

// Install admin features
// grammY will automatically handle the filtering inside the `admin` composer
bot.use(admin); 

// ...
bot.start();

With this pattern, an update from a non-admin user will simply be ignored by the admin composer, but it will still be processed by the publicCommands composer. This is far more elegant and scalable than a single, monolithic authentication check.

Conclusion

You have now learned the most important pattern for building scalable and maintainable Telegram bots with grammY. By moving beyond a single, monolithic file and embracing the modularity offered by the Composer class, you are setting yourself up for success as your bot's complexity grows.

Key Takeaways:

  • The Composer class is the core of grammY's middleware system; the Bot class is simply an extension of it.
  • You can group related handlers (commands, message handlers, middleware) into a Composer instance within its own file, creating a reusable module.
  • Your main bot.ts file can then assemble these modules using bot.use(yourComposer).
  • This structure is analogous to modules or components in modern front-end frameworks.
  • You can apply middleware like .filter() directly to a composer to create scoped logic, such as an "admin" module that self-validates user permissions.

Now that your codebase is neatly organized, you are well-equipped to handle more advanced features. In our next lesson, we will tackle one of the most essential concepts in building interactive bots: state management. You'll learn how to use grammY's session plugin to give your bot a memory, allowing it to remember user data across multiple messages.

End of Module,Exit lesson

Can't find a good explanation? Sign up and we'll make it for you

Sign up