CodeOn.NET

Code, Learn, Build in .NET
Theme:
    November 27, 2025 6 min read

    Building an Event Queue in ASP.NET Core

    When you're building modern apps, it's important to keep components loosely coupled. You don't want tasks like sending a confirmation email or sending an SMS text to delay your main web or API requests. At the same time, we need a reliable way to handle critical events such as unusual payments, failed logins, or sudden error spikes.

    This is exactly where the Event Queue pattern comes in. It enforces true separation of concerns: a producer publishes the event and immediately returns, allowing a consumer to handle that work asynchronously and without blocking the request thread.

    In this article, we are going to build an event queue using the Channel<T> and a BackgroundService because it provides the most reliable, lightweight, and async friendly way to handle in-process events in modern ASP.NET Core. Our goal is simple: use this reliable Channel<T> queue to send critical alerts to a Discord Channel.

    The Core Components

    In .NET, the System.Threading.Channels library gives us a simple, thread-safe pipeline. Producers publish events, and consumers consume them. We will use an Unbounded Channel for simplicity, meaning producers can always write immediately.

    Before diving into the code, this class diagram shows the high-level architecture:

    class diagram

    Step 1: Define the Event Contract

    All events will implement a basic contract that tells the consumer what happened and how to format a message.

    public interface IEventMessage
    {
        string Level { get; }
        string AlterTitle { get; }
        DateTime TimestampUtc { get; }
        string? BuildMessage();
    }
    

    Step 2: Implement the Event Queue (Channel<T> Wrapper)

    The EventQueue class wraps the Channel and provides the methods for adding and reading events asynchronously.

    public sealed class EventQueue(Channel channel)
    {
        private readonly Channel<IEventMessage> _channel = channel;
    
        public async ValueTask QueueAsync(IEventMessage eventMessage, CancellationToken cancellationToken)
        {
            await _channel.Writer.WriteAsync(eventMessage, cancellationToken);
        }
    
        public IAsyncEnumerable<IEventMessage> ReadAllAsync(CancellationToken cancellationToken)
            => _channel.Reader.ReadAllAsync(cancellationToken);
    }
    

    Step 3: Writing the Consumer as a Background Service

    We use BackgroundService to continuously pull events from the channel as long as the application is running. This consumer's job is to take the event and send it to the designated provider (Discord in our case).

    public sealed class EventConsumerService(
        EventQueue queue, 
        IEnumerable<IAlertProvider> providers) : BackgroundService
    {
        private readonly EventQueue _queue = queue;
        private readonly IEnumerable<IAlertProvider> _providers = providers;
    
        protected override async Task ExecuteAsync(CancellationToken cancellationToken)
        {
            await foreach (var eventMessage in _queue.ReadAllAsync(cancellationToken))
            {
                foreach (var provider in _providers)
                {
                    await provider.SendAsync(eventMessage, cancellationToken);
                }
            }
        }
    }
    

    Step 4: Define the Alert Provider (Discord)

    This is the service that handles the external API call, separating that logic from the queue processing.

    public interface IAlertProvider
    {
        Task SendAsync(IEventMessage eventMessage, CancellationToken cancellationToken = default);
    }
    

    For example, a Discord provider can look like this:

    // Sends an event message to Discord using a webhook, formatting it with emoji and embed styling
    public sealed class DiscordProvider(
        IOptions<DiscordSettings> options, 
        HttpClient httpClient, 
        ILogger<DiscordProvider> logger) : IAlertProvider
    {
        // reads Discord settings from appsettings.json
        private readonly DiscordSettings _settings = options.Value;
        private readonly HttpClient _httpClient = httpClient;
    
        public async Task SendAsync(IEventMessage eventMessage, CancellationToken cancellationToken = default)
        {
            var message = eventMessage.BuildMessage();
            
            if (message == null)
            {
                return;
            }
    
            var emoji = eventMessage.Level switch
            {
                "warning" => "⚠️",
                "error" => "❌",
                _ => "ℹ️"
            };
    
            var payload = new DiscordWebhookPayload
            {
                // discord bot username
                Username = _settings.Username,
                // discord bot userid
                Content = $"<@{_settings.UserId}>",
                AllowedMentions = new AllowedMentions
                {
                    Users = [_settings.UserId]
                },
                Embeds =
                [
                    new DiscordEmbed
                    {
                        Title = $"{emoji} {eventMessage.AlterTitle}",
                        Description = message,
                        Color = eventMessage.Level switch
                        {
                            "warning" => 16776960,
                            "error" => 16711680,
                            _ => 3447003
                        }
                    }
                ]
            };
    
            var response = await _httpClient.PostAsJsonAsync(_settings.WebhookUrl, payload, cancellationToken);
            response.EnsureSuccessStatusCode();
    
            logger.LogInformation("Discord sent: {Message}", message);
        }
    }
    
    public class DiscordSettings
    {
        public string WebhookUrl { get; set; } = null!;
        public string UserId { get; set; } = null!;
        public string Username { get; set; } = null!;
    }
    

    Step 5: Register Services with Dependency Injection

    Register the core services in your Program.cs file.

    var builder = WebApplication.CreateBuilder(args);
    
    // Single channel for all events
    builder.Services.AddSingleton(Channel.CreateUnbounded<IEventMessage>());
    
    // Queue and consumer
    builder.Services.AddSingleton<EventQueue>();
    builder.Services.AddHostedService<EventConsumerService>();
    
    // Providers
    builder.Services.Configure<DiscordSettings>(builder.Configuration.GetSection("Discord"));
    builder.Services.AddSingleton<IAlertProvider, DiscordProvider>();
    builder.Services.AddSingleton<IAlertProvider, SlackProvider>();
    
    var app = builder.Build();
    app.Run();
    

    Step 6: Example Payment Event

    Here's a simple event implementation I used for payment alerts.

    public sealed class PaymentEvent(
        decimal amount,
        string email,
        decimal minThresholdAmount = 5,
        decimal maxThresholdAmount = 1500) : IEventMessage
    {
        private const string HighPaymentTemplate = "High payment of `${0:N2}` by `{1}`";
        private const string LowPaymentTemplate = "Low payment of `${0:N2}` by `{1}`";
    
        private readonly string _email = email;
        private readonly decimal _amount = amount;
        private readonly decimal _minThresholdAmount = minThresholdAmount;
        private readonly decimal _maxThresholdAmount = maxThresholdAmount;
    
        public string Level => "warning";
        
        public string AlterTitle => "Payment Alert";
    
        public DateTime TimestampUtc => DateTime.UtcNow;
    
        public string? BuildMessage()
        {
            if (_amount >= _maxThresholdAmount)
            {
                return string.Format(HighPaymentTemplate, _amount, _email);
            }
            else if (_amount < _minThresholdAmount)
            {
                return string.Format(LowPaymentTemplate, _amount, _email);
            }
            else
            {
               return null;
            }
        }
    }
    

    Adding the Event to the Queue (The Producer)

    Any controller or service can now inject EventQueue and publish the event instantly.

    // Inside a controller or service method
    EventQueue _eventQueue = eventQueue; 
    ...
    var paymentEvent = new PaymentEvent(amount: 2000.00M, email: "jon.doe@gmail.com");
    // almost always fire-and-forget
    await _eventQueue.QueueAsync(paymentEvent);
    

    Before wrapping up, this code has been tested in production under a moderate load. For heavy traffic apps, keep these best practices in mind:

    You can find the complete code for this post on GitHub here.