Skip to main content

Overview

This guide covers architectural patterns, code organization strategies, and best practices for building production-ready Telegram bots with Telegrator.

Project Structure

MyTelegramBot/
├── Handlers/
│   ├── Commands/
│   │   ├── StartCommandHandler.cs
│   │   ├── HelpCommandHandler.cs
│   │   └── SettingsCommandHandler.cs
│   ├── Messages/
│   │   ├── TextMessageHandler.cs
│   │   └── MediaMessageHandler.cs
│   ├── Callbacks/
│   │   ├── ButtonCallbackHandler.cs
│   │   └── InlineKeyboardHandler.cs
│   └── Awaiting/
│       ├── FormInputHandlers.cs
│       └── ConfirmationHandlers.cs
├── Filters/
│   ├── CustomFilterAttributes.cs
│   └── AuthorizationFilters.cs
├── StateKeeping/
│   ├── UserStateKeeper.cs
│   ├── ConversationStateKeeper.cs
│   └── StateAttributes.cs
├── Services/
│   ├── IUserService.cs
│   ├── UserService.cs
│   ├── INotificationService.cs
│   └── NotificationService.cs
├── Models/
│   ├── User.cs
│   ├── Session.cs
│   └── BotSettings.cs
├── Data/
│   ├── BotDbContext.cs
│   ├── Repositories/
│   └── Migrations/
├── Configuration/
│   ├── BotConfiguration.cs
│   └── ErrorHandling/
│       └── CustomExceptionHandler.cs
└── Program.cs

Handler Design Patterns

Single Responsibility Principle

Each handler should have one clear purpose:
// Good: Focused handler
[MessageHandler]
[MessageRegex(@"^\/start$")]
public class StartCommandHandler : MessageHandler
{
    private readonly IUserService _userService;

    public StartCommandHandler(IUserService userService)
    {
        _userService = userService;
    }

    protected override async Task<Result> ExecuteInternal(
        IHandlerContainer container,
        CancellationToken cancellationToken)
    {
        var userId = container.GetSenderId();
        await _userService.RegisterUserIfNewAsync(userId, cancellationToken);
        
        await container.Client.SendTextMessageAsync(
            container.GetChatId(),
            "Welcome! Type /help to see available commands.",
            cancellationToken: cancellationToken);
            
        return Result.Ok();
    }
}

// Bad: Handler doing too much
[MessageHandler]
public class EverythingHandler : MessageHandler
{
    protected override async Task<Result> ExecuteInternal(
        IHandlerContainer container,
        CancellationToken cancellationToken)
    {
        // Handles commands, messages, user registration, analytics, etc.
        // Too many responsibilities!
    }
}

Composition Over Inheritance

// Good: Use services for shared functionality
public interface IAuthorizationService
{
    Task<bool> IsAuthorizedAsync(long userId, CancellationToken ct);
}

[MessageHandler]
public class AdminCommandHandler : MessageHandler
{
    private readonly IAuthorizationService _authService;

    public AdminCommandHandler(IAuthorizationService authService)
    {
        _authService = authService;
    }

    protected override async Task<Result> ExecuteInternal(
        IHandlerContainer container,
        CancellationToken cancellationToken)
    {
        if (!await _authService.IsAuthorizedAsync(
            container.GetSenderId(), 
            cancellationToken))
        {
            await container.Client.SendTextMessageAsync(
                container.GetChatId(),
                "You don't have permission to use this command.");
            return Result.Fault();
        }
        
        // Admin command logic
        return Result.Ok();
    }
}

// Avoid: Deep inheritance hierarchies
// public class BaseHandler : MessageHandler { }
// public class AuthenticatedHandler : BaseHandler { }
// public class AdminHandler : AuthenticatedHandler { }

Dependency Injection

Proper Service Registration

// Program.cs or Startup.cs
services.AddSingleton<ITelegramBotClient>(sp =>
{
    var configuration = sp.GetRequiredService<IConfiguration>();
    var token = configuration["Telegram:BotToken"] ??
        throw new InvalidOperationException("Bot token not configured");
    return new TelegramBotClient(token);
});

// Register handlers
services.AddScoped<StartCommandHandler>();
services.AddScoped<HelpCommandHandler>();

// Register services
services.AddScoped<IUserService, UserService>();
services.AddScoped<INotificationService, NotificationService>();

// Register state keepers as singletons (shared state)
services.AddSingleton<UserStateKeeper>();

// Register exception handler
services.AddSingleton<IRouterExceptionHandler, CustomExceptionHandler>();

// Register database context
services.AddDbContext<BotDbContext>(options =>
    options.UseSqlServer(configuration.GetConnectionString("BotDatabase")));

Scoped vs Singleton vs Transient

// Singleton: Shared across all requests (stateful, expensive to create)
services.AddSingleton<IMemoryCache, MemoryCache>();
services.AddSingleton<UserStateKeeper>();

// Scoped: One instance per request/update (database contexts)
services.AddScoped<BotDbContext>();
services.AddScoped<IUserService, UserService>();

// Transient: New instance every time (lightweight, stateless)
services.AddTransient<IDateTimeProvider, DateTimeProvider>();

Configuration Management

Use Configuration Files

// appsettings.json
{
  "Telegram": {
    "BotToken": "your-bot-token",
    "AdminChatId": 123456789,
    "WebhookUrl": "https://yourdomain.com/webhook"
  },
  "Telegrator": {
    "MaxParallelHandlers": 20,
    "ExclusiveAwaitingRouting": true
  },
  "ConnectionStrings": {
    "BotDatabase": "Server=localhost;Database=TelegramBot;..."
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning"
    }
  }
}
// Strongly-typed configuration
public class TelegramSettings
{
    public string BotToken { get; set; } = string.Empty;
    public long AdminChatId { get; set; }
    public string? WebhookUrl { get; set; }
}

public class TelegratorSettings
{
    public int MaxParallelHandlers { get; set; } = 10;
    public bool ExclusiveAwaitingRouting { get; set; } = true;
}

// Register in Program.cs
services.Configure<TelegramSettings>(
    configuration.GetSection("Telegram"));
services.Configure<TelegratorSettings>(
    configuration.GetSection("Telegrator"));

// Use in handlers
public class MyHandler : MessageHandler
{
    private readonly TelegramSettings _settings;

    public MyHandler(IOptions<TelegramSettings> options)
    {
        _settings = options.Value;
    }
}

Environment-Specific Settings

// appsettings.Development.json
{
  "Telegram": {
    "BotToken": "dev-bot-token"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Debug"
    }
  }
}

// appsettings.Production.json
{
  "Telegram": {
    "BotToken": "prod-bot-token"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  }
}

Error Handling Strategy

Layered Error Handling

// Global exception handler for unhandled errors
public class GlobalExceptionHandler : IRouterExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public void HandleException(
        ITelegramBotClient botClient,
        Exception exception,
        HandleErrorSource source,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "Unhandled error from {Source}", source);
        // Alert monitoring system
    }
}

// Handler-level error handling for expected errors
[MessageHandler]
public class SafeHandler : MessageHandler
{
    protected override async Task<Result> ExecuteInternal(
        IHandlerContainer container,
        CancellationToken cancellationToken)
    {
        try
        {
            await ProcessAsync(container, cancellationToken);
            return Result.Ok();
        }
        catch (ValidationException ex)
        {
            // Handle expected validation errors
            await container.Client.SendTextMessageAsync(
                container.GetChatId(),
                $"Invalid input: {ex.Message}");
            return Result.Ok(); // Don't propagate
        }
        // Let unexpected errors propagate to global handler
    }
}

State Management Best Practices

Use Enums for Finite State Machines

public enum RegistrationState
{
    Start,
    EnteringName,
    EnteringEmail,
    EnteringPhone,
    Confirmation,
    Complete
}

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class RegistrationStateAttribute : StateKeeperAttributeBase
{
    public static readonly EnumStateKeeper<RegistrationState> Shared = new();
    public RegistrationState ExpectedState { get; }

    public RegistrationStateAttribute(RegistrationState expectedState)
        : base(typeof(EnumStateKeeper<RegistrationState>))
    {
        ExpectedState = expectedState;
    }

    public override bool CanPass(FilterExecutionContext<Update> context)
    {
        if (!Shared.TryGetState(context.Update, out var state))
            return ExpectedState == RegistrationState.Start;
        return state == ExpectedState;
    }
}

Clean Up State

[MessageHandler]
[RegistrationState(RegistrationState.Complete)]
public class CompleteRegistrationHandler : MessageHandler
{
    protected override async Task<Result> ExecuteInternal(
        IHandlerContainer container,
        CancellationToken cancellationToken)
    {
        // Process registration completion
        await FinalizeRegistrationAsync(container, cancellationToken);
        
        // Clean up state
        RegistrationStateAttribute.Shared.DeleteState(container.HandlingUpdate);
        
        await container.Client.SendTextMessageAsync(
            container.GetChatId(),
            "Registration complete!");
            
        return Result.Ok();
    }
}

Testing

Unit Testing Handlers

public class StartCommandHandlerTests
{
    private readonly Mock<IUserService> _userServiceMock;
    private readonly Mock<ITelegramBotClient> _botClientMock;
    private readonly StartCommandHandler _handler;

    public StartCommandHandlerTests()
    {
        _userServiceMock = new Mock<IUserService>();
        _botClientMock = new Mock<ITelegramBotClient>();
        _handler = new StartCommandHandler(_userServiceMock.Object);
    }

    [Fact]
    public async Task ExecuteInternal_RegistersNewUser()
    {
        // Arrange
        var userId = 12345L;
        var chatId = 67890L;
        var message = new Message { From = new User { Id = userId }, Chat = new Chat { Id = chatId } };
        var update = new Update { Message = message };
        
        var containerMock = new Mock<IHandlerContainer>();
        containerMock.Setup(c => c.GetSenderId()).Returns(userId);
        containerMock.Setup(c => c.GetChatId()).Returns(chatId);
        containerMock.Setup(c => c.Client).Returns(_botClientMock.Object);
        containerMock.Setup(c => c.HandlingUpdate).Returns(update);
        containerMock.Setup(c => c.GetMessage()).Returns(message);

        // Act
        var result = await _handler.ExecuteInternal(
            containerMock.Object,
            CancellationToken.None);

        // Assert
        Assert.True(result.Positive);
        _userServiceMock.Verify(
            s => s.RegisterUserIfNewAsync(userId, It.IsAny<CancellationToken>()),
            Times.Once);
        _botClientMock.Verify(
            b => b.SendTextMessageAsync(
                It.IsAny<ChatId>(),
                It.IsAny<string>(),
                It.IsAny<int?>(),
                It.IsAny<ParseMode?>(),
                It.IsAny<IEnumerable<MessageEntity>>(),
                It.IsAny<bool?>(),
                It.IsAny<bool?>(),
                It.IsAny<int?>(),
                It.IsAny<bool?>(),
                It.IsAny<IReplyMarkup>(),
                It.IsAny<CancellationToken>()),
            Times.Once);
    }
}

Integration Testing

public class BotIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public BotIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task Webhook_ProcessesUpdate_Successfully()
    {
        // Arrange
        var client = _factory.CreateClient();
        var update = new Update { /* test update data */ };
        var content = new StringContent(
            JsonSerializer.Serialize(update),
            Encoding.UTF8,
            "application/json");

        // Act
        var response = await client.PostAsync("/webhook", content);

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

Security Best Practices

Validate Input

[MessageHandler]
public class UserInputHandler : MessageHandler
{
    protected override async Task<Result> ExecuteInternal(
        IHandlerContainer container,
        CancellationToken cancellationToken)
    {
        var input = container.GetMessage().Text;
        
        // Validate and sanitize input
        if (string.IsNullOrWhiteSpace(input) || input.Length > 1000)
        {
            await container.Client.SendTextMessageAsync(
                container.GetChatId(),
                "Invalid input. Please provide text between 1-1000 characters.");
            return Result.Ok();
        }
        
        // Sanitize HTML/special characters if displaying to users
        var sanitized = System.Net.WebUtility.HtmlEncode(input);
        
        return Result.Ok();
    }
}

Protect Sensitive Data

// Never log sensitive information
_logger.LogInformation(
    "User {UserId} executed command", // OK
    userId);

// Don't log passwords, tokens, personal data
// _logger.LogInformation("User entered password: {Password}", password); // BAD!

// Use User Secrets for development
// dotnet user-secrets set "Telegram:BotToken" "your-token"

// Use environment variables or Azure Key Vault in production
var botToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN") ??
    configuration["Telegram:BotToken"];

Rate Limiting

public class RateLimitingService
{
    private readonly Dictionary<long, Queue<DateTime>> _requestHistory = new();
    private readonly int _maxRequestsPerMinute = 20;

    public bool IsRateLimited(long userId)
    {
        if (!_requestHistory.ContainsKey(userId))
            _requestHistory[userId] = new Queue<DateTime>();

        var history = _requestHistory[userId];
        var cutoff = DateTime.UtcNow.AddMinutes(-1);

        // Remove old requests
        while (history.Count > 0 && history.Peek() < cutoff)
            history.Dequeue();

        if (history.Count >= _maxRequestsPerMinute)
            return true;

        history.Enqueue(DateTime.UtcNow);
        return false;
    }
}

Logging Best Practices

Structured Logging

[MessageHandler]
public class WellLoggedHandler : MessageHandler
{
    private readonly ILogger<WellLoggedHandler> _logger;

    protected override async Task<Result> ExecuteInternal(
        IHandlerContainer container,
        CancellationToken cancellationToken)
    {
        var userId = container.GetSenderId();
        var chatId = container.GetChatId();
        
        using (_logger.BeginScope(new Dictionary<string, object>
        {
            ["UserId"] = userId,
            ["ChatId"] = chatId,
            ["UpdateId"] = container.HandlingUpdate.Id
        }))
        {
            _logger.LogInformation("Processing message from user {UserId}", userId);
            
            try
            {
                await ProcessMessageAsync(container, cancellationToken);
                _logger.LogInformation("Message processed successfully");
                return Result.Ok();
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to process message");
                throw;
            }
        }
    }
}

Performance Checklist

Use async/await consistently: Never block with .Result or .Wait().
Set appropriate concurrency limits: Based on your bot’s workload and available resources.
Cache frequently accessed data: Reduce database load and improve response times.
Use appropriate service lifetimes: Singleton for stateful/expensive, Scoped for request-bound, Transient for lightweight.
Monitor memory usage: Implement state cleanup to prevent memory leaks.
Profile your bot: Use Application Insights, ELK stack, or similar tools to identify bottlenecks.

Deployment Best Practices

Health Checks

services.AddHealthChecks()
    .AddDbContextCheck<BotDbContext>()
    .AddCheck("telegram_api", () =>
    {
        // Check if Telegram API is reachable
        return HealthCheckResult.Healthy();
    });

app.MapHealthChecks("/health");

Graceful Shutdown

var cts = new CancellationTokenSource();
Console.CancelKeyPress += (sender, e) =>
{
    e.Cancel = true;
    cts.Cancel();
};

var options = new TelegratorOptions
{
    GlobalCancellationToken = cts.Token
};

Monitoring and Alerts

// Use Application Insights
services.AddApplicationInsightsTelemetry();

// Custom metrics
public class MetricsService
{
    private readonly TelemetryClient _telemetry;

    public void TrackHandlerExecution(string handlerName, long duration)
    {
        _telemetry.TrackMetric("HandlerExecutionTime", duration, 
            new Dictionary<string, string> { ["Handler"] = handlerName });
    }
}

Build docs developers (and LLMs) love