A Brighter alternative to MediatR and MassTransit?
For years, MassTransit and MediatR have been the leading open-source .NET messaging libraries. But that era is coming to an end: MassTransit is transitioning to a commercial license. V8 will likely be its final fully open-source release. Meanwhile, MediatR is rolling out paid tiers as well.
This shift has left many developers seeking free alternatives. One of the alternatives that has been gaining traction is Paramore Brighter.
Note: The band called Paramore released a song called Brighter in 2005. While I haven’t found concrete evidence linking it as an inspiration, I’d bet that’s what happened.
What is Brighter?
Brighter is a lightweight, MIT-licensed open-source library for building message-driven applications in .NET. It provides a flexible way to implement the Command Responsibility Segregation (CRS) pattern, allowing developers to separate a command and its handlers. Brighter supports various messaging patterns and integrates with popular message brokers like RabbitMQ, Azure Service Bus, and Amazon SQS.
MediatR vs Brighter
MediatR is a famous library for building message-driven applications in .NET. It allows developers to decouple request/response interactions and supports both commands and queries. Let’s see how Brighter holds up as a replacement.
Brigher vs Darker
The first surprise for me was that Brighter library covers only commands and events, while its counterpart, called Darker(a separate library), is responsible for queries. This separation of concerns differs from MediatR, which handles commands and queries within a single library.
Even though Darker is only one year younger than Brighter(at least according to history in GitHub), the feature set is quite different. Darker seems to be a simple query library, and development isn’t as active as Brighter. Comparing 2.2k stars on GitHub for Brighter to 200 stars for Darker shows that Brighter is the more mature and widely adopted library.
Can Brighter with Darker replace MediatR?
If you know MediatR, you will feel at home with Brighter. It uses a similar approach to handling commands and events.
MediatR:
public record CreateOrderCommand(int ProductId, int Quantity)
: IRequest<OrderResult>;
public record OrderResult(Guid OrderId, bool Success);
public class CreateOrderHandler(IOrderService orderService)
: IRequestHandler<CreateOrderCommand, OrderResult>
{
public async Task<OrderResult> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
..
}
}
// Usage
var command = new CreateOrderCommand(productId, quantity);
var result = await sender.Send(command);Brighter:
public class CreateOrder : Command
{
public CreateOrder(Guid id) : base(id)
{
Id = id;
}
public int ProductId { get; set; }
public int Quantity { get; set; }
}
public record OrderResult(Guid OrderId, bool Success);
public class CreateOrderHandler() : RequestHandlerAsync<CreateOrder>
{
public override async Task<CreateOrder> HandleAsync(
CreateOrder command,
CancellationToken cancellationToken = default)
{
// ... processing
return await base.HandleAsync(command, cancellationToken);
}
}
// Usage
var createOrder = new CreateOrder(Guid.NewGuid())
{
ProductId = 1,
Quantity = 1
};
await commandProcessor.SendAsync(createOrder);As you can see, the naming convention is a bit different, but the concept remains similar. You define commands and handlers, and Brighter takes care of dispatching them through a pipeline.
The main difference is that Brighter’s request classes are a bit more complex than MediatR’s. They include the Id(Guid) and Span(related to telemetry) properties. It’s also recommended to use Command as a base class for your commands and Event class for your events. On the other hand, MediatR’s IRequest is just a simple(and empty) marker interface.
Queries
Working with queries in Darker is a bit less complex and closer to the MediatR experience. To send a query, you just need to implement the IQuery<T> interface.
MediatR:
public record OrderDto { // ....
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto>;
public record OrderDto(
Guid OrderId,
int ProductId,
int Quantity,
DateTimeOffset CreatedAt);
public class GetOrderByIdQueryHandler(IOrderService orderService)
: IRequestHandler<GetOrderByIdQuery, OrderDto>
{
public async Task<OrderDto> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
...
}
}
var result = await sender.Send(new CreateOrderCommand(productId, quantity));Darker:
public record OrderDto{ // ....
public record GetOrderById(int OrderId) : IQuery<OrderDto>;
public class GetOrderByIdHandler : QueryHandlerAsync<GetOrderById, OrderDto>
{
public override Task<OrderDto> ExecuteAsync(
GetOrderById query,
CancellationToken cancellationToken = new CancellationToken())
{
return Task.FromResult(new OrderDto());
}
}
// Usage
var orderResult = await queryProcessor.ExecuteAsync(new GetOrderById(1));Brighter(with Darker) offers dispatching capabilities similar to those in MediatR. But what about the additional features?
Pipelines
I’m a big fan of the pipeline concept — a.k.a. behaviors as they are called in MediatR. Brighter’s implementation differs slightly from MediatR’s.
You can think of Brighter’s pipeline as a Russian Doll model, where you can nest pipelines inside each other. MediatR’s pipeline is more like a flat list of behaviors that are executed in order.
Defining a pipeline in Brighter involves writing a bit more boilerplate code than in MediatR. The simplest pipeline in Brighter looks like this:
public class RequestLoggingHandler<TRequest>(
ILogger<RequestLoggingHandler<TRequest>> logger)
: RequestHandlerAsync<TRequest> where TRequest : class, IRequest
{
public override Task<TRequest> HandleAsync(
TRequest command,
CancellationToken cancellationToken = new())
{
LogCommand(command);
return base.HandleAsync(command, cancellationToken);
}
private void LogCommand(TRequest request)
{
logger.LogInformation("Logging handler pipeline call");
}
}
public class RequestLoggingAttribute(int step, HandlerTiming timing)
: RequestHandlerAttribute(step, timing)
{
public override object[] InitializerParams()
{
return [Timing];
}
public override Type GetHandlerType()
{
return typeof(RequestLoggingHandler<>);
}
}
// Usage in your handler
public class CreateOrderHandler() : RequestHandlerAsync<CreateOrder>
{
[RequestLogging(step: 1, timing: HandlerTiming.Before)]
public override async Task<CreateOrder> HandleAsync(
CreateOrder command,
CancellationToken cancellationToken = default)
{
return await base.HandleAsync(command, cancellationToken);
}
}To use the pipeline, you need to define the corresponding attribute and explicitly use it in your handler. It’s not just one class and one registration like in MediatR.
If you’d rather avoid the attribute-based model, there’s an alternative. Basically, you would need to write the code like this:
var myCommandHandler = new MyCommandHandler();
var myLoggingHandler = new MyLoggingHandler(log);
//this is how you chain handlers together!
myLoggingHandler.Successor = myCommandHandler;
var subscriberRegistry = new SubscriberRegistry();
subscriberRegistry.Register<MyCommand, MyLoggingHandler>();Explicitness may be suitable in some cases, but I prefer the simplicity of MediatR behaviors over these manual settings.
The more problematic part is that Darker does not support the same pipeline model as Brighter. I couldn’t even find a way to have a pipeline for queries. The documentation doesn’t help as it’s empty for the Darker configuration.
Sharing data between handlers
Brighter is quite an opinionated library, so it’d be weird if they don’t provide a way to share data between handlers. In Brighter, the command dispatcher automatically provides a Context Bag, a simple IDictionary<string, object> that’s injected into every handler in a pipeline and lives for the duration of that request. Handlers can read and write arbitrary data in Context.Bag without touching the command’s own properties.
MediatR’s IPipelineBehavior<TRequest,TResponse> has no shared context, so to share data between behaviors, you register a custom-scoped dependency (e.g., a dictionary) in your DI container and inject it where needed. While this DI-based approach keeps behaviors decoupled from request types, it requires extra setup compared to Brighter’s out-of-the-box Context Bag.
Example of Brighter’s Context Bag
public class CorrelationAttribute(int step, HandlerTiming timing) : RequestHandlerAttribute(step, timing)
{
public override Type GetHandlerType()
{
return typeof(CorrelationHandler<>);
}
}
public class CorrelationHandler<TRequest>
: RequestHandlerAsync<TRequest> where TRequest : class, IRequest
{
public override Task<TRequest> HandleAsync(TRequest command, CancellationToken cancellationToken = new())
{
Context.Bag["CorrelationId"] = Guid.NewGuid(); //can be any object
return base.HandleAsync(command, cancellationToken);
}
}
// then in your handler:
[Correlation(step: 1, timing: HandlerTiming.Before)]
public override async Task<CreateOrder> HandleAsync(
CreateOrder command,
CancellationToken cancellationToken = default)
{
var correlationId = Context.Bag["CorrelationId"];
return await base.HandleAsync(command, cancellationToken);
}Semantically sound commands
You can use that handy context to pass data from handlers in the pipeline, but on the other hand, you can’t return any result when processing a command. Brighter enforces a strict rule that commands cannot return values. So the usual workaround is to have the handler mutate the state of the command:
public class CreateOrder : Command
{
...
public bool Success { get; st; } //new prop
}
public class CreateOrderHandler() : RequestHandlerAsync<CreateOrder>
{
public override async Task<CreateOrder> HandleAsync(
CreateOrder command,
CancellationToken cancellationToken = default)
{
command.ProceededSuccessfully = true; // mutate the state
return await base.HandleAsync(command, cancellationToken);
}
}
//Unlike MediatR's SendAsync, which returns a Task<TResponse>,
//Brighter's SendAsync only returns a non-generic Task.
await commandProcessor.SendAsync(createOrder);
if (createOrder.Success)
{
Console.WriteLine("CreateOrder command processed successfully.");
}
else
{
Console.WriteLine("CreateOrder command failed.");
}In theory, yes, Brighter is right, a command shouldn't produce a result. However, I’d argue that this mutation of input data is even less desirable than MediatR’s approach of allowing commands to return data.
Final take
Although Brighter could replace MediatR as a CRS library, I still prefer MediatR’s pragmatic approach over Brighter’s emphasis on strictly clean messaging patterns. Regarding the queries part(Darker), I’m not so sure. It works, but in a very limited way.
Let’s look at what Brighter offers in the MassTransit domain.
MassTransit vs. Brighter
MassTransit is a full-fledged service bus that provides advanced features like sagas, routing slips, and support for cloud brokers. Brighter supports some of these features, but it does not offer the same level of functionality as MassTransit.
Pub/Sub messaging
When I was exploring alternatives, my top requirement was straightforward pub/sub messaging with RabbitMQ. While Brighter does offer pub/sub support, it demands considerably more configuration and setup than MassTransit. I eventually got it to work, but it cost me a fair amount of time. Feel free to check my working example in this GitHub repo. It’s too much code to fit into a blog post.
The main issue was configuration, and once again, the documentation fell short of expectations. Moreover, implementing this pub/sub pattern forces you to call two configuration methods: AddBrighter for sending messages and AddServiceActivator for handling receiving them. I’d expect it would be all set in one method.
You also end up writing a lot of boilerplate and juggling different classes for dispatching commands: SendAsync goes through the internal bus(like MediatR), whereas PostAsync pushes commands out to the external bus(when you need RabbitMQ).
All of this leads me to believe that Brighter suffers significantly from a leaky abstraction. It gives the impression that you need to know a lot about how Brighter works internally to set up even the simplest messaging pattern.
Outbox & Inbox
The other pattern I was looking for was outbox. When I started my career, one of the first things I learned was to use a table in MS SQL to store an email to be sent and send it later via a scheduled task, rather than sending it directly via SMTP. Which was basically the outbox pattern, but we didn’t call it like that at that time.
Brighter has built-in support for the Outbox pattern. It allows you to store messages that need to be sent in a database table and then process them later. This is useful for ensuring that messages are not lost in case of failures of the message broker.
However, I haven’t succeeded in making PostgreSQL's outbox work with standard async command processing. I asked a question in the GitHub discussion, but I got an answer stating that the team is currently focused on the new version V10, and the async version of PostgreSQL outbox is not supported at this time. Maybe in the next version, Brighter will offer a more viable option.
I asked a question in the GitHub discussion, but I got an answer stating that the team is currently focused on the new version V10, and the async version of PostgreSQL outbox is not supported at this time. So maybe in the next version, it will be a more viable option.
Final take
Brighter is definitely an alternative to MassTransit, but it lacks not only advanced features like sagas and routing slips but also some basic features like async outbox support. So I’m not sure if I would recommend it as a replacement for MassTransit now.
Conclusions
Is Brighter a solid replacement for MediatR or MassTransit? In my experience, version 9 doesn’t quite fit my needs. The learning curve is steep, and the documentation could be better. That said, if you just want a lightweight broker wrapper around RabbitMQ, Brighter can do the job, but I’d wait for v10 before committing.
