diff --git a/dictionary.txt b/dictionary.txt
index 404315e11b6..849e9831d59 100644
--- a/dictionary.txt
+++ b/dictionary.txt
@@ -138,6 +138,7 @@ OPTOUT
overfetching
OWIN
pageable
+parameterless
Partitioner
pipefail
PKCE
diff --git a/src/Mocha/src/Demo/Demo.Billing/Demo.Billing.csproj b/src/Mocha/src/Demo/Demo.Billing/Demo.Billing.csproj
index a3c3fe59a05..d65525d0cfc 100644
--- a/src/Mocha/src/Demo/Demo.Billing/Demo.Billing.csproj
+++ b/src/Mocha/src/Demo/Demo.Billing/Demo.Billing.csproj
@@ -2,6 +2,7 @@
HotChocolate.Demo.Billing
HotChocolate.Demo.Billing
+ net10.0
diff --git a/src/Mocha/src/Demo/Demo.Billing/Program.cs b/src/Mocha/src/Demo/Demo.Billing/Program.cs
index a1f01fd40f2..80ddecaaf7a 100644
--- a/src/Mocha/src/Demo/Demo.Billing/Program.cs
+++ b/src/Mocha/src/Demo/Demo.Billing/Program.cs
@@ -5,8 +5,8 @@
using Microsoft.EntityFrameworkCore;
using Mocha;
using Mocha.EntityFrameworkCore;
-using Mocha.Mediator;
using Mocha.Inbox;
+using Mocha.Mediator;
using Mocha.Outbox;
using Mocha.Transport.RabbitMQ;
@@ -15,7 +15,7 @@
builder.AddServiceDefaults();
// Database
-builder.AddNpgsqlDbContext("billing-db");
+builder.AddNpgsqlDbContext("billing-db", x => x.DisableTracing = true);
// RabbitMQ
builder.AddRabbitMQClient("rabbitmq", x => x.DisableTracing = true);
@@ -23,16 +23,14 @@
// Mocha.Mediator
builder.Services.AddMediator()
.AddBilling()
+ .AddInstrumentation()
.UseEntityFrameworkTransactions();
// MessageBus
builder
.Services.AddMessageBus()
.AddInstrumentation()
- // Event handlers
- .AddEventHandler()
- .AddEventHandler()
- // Batch event handlers
+ .AddBilling()
.AddBatchHandler(opts =>
{
opts.MaxBatchSize = 5;
@@ -43,9 +41,6 @@
opts.MaxBatchSize = 500;
opts.BatchTimeout = TimeSpan.FromSeconds(5);
})
- // Request handlers for saga commands
- .AddRequestHandler()
- .AddRequestHandler()
.AddEntityFramework(p =>
{
p.UsePostgresOutbox();
@@ -69,52 +64,55 @@
app.MapGet("/", () => "Billing Service");
// Invoices
-app.MapGet("/api/invoices", async (ISender sender) =>
- await sender.QueryAsync(new GetInvoicesQuery()));
+app.MapGet("/api/invoices", async (ISender sender) => await sender.QueryAsync(new GetInvoicesQuery()));
-app.MapGet("/api/invoices/{id:guid}", async (Guid id, ISender sender) =>
- await sender.QueryAsync(new GetInvoiceByIdQuery(id)) is { } invoice
- ? Results.Ok(invoice)
- : Results.NotFound());
+app.MapGet(
+ "/api/invoices/{id:guid}",
+ async (Guid id, ISender sender) =>
+ await sender.QueryAsync(new GetInvoiceByIdQuery(id)) is { } invoice ? Results.Ok(invoice) : Results.NotFound());
-app.MapGet("/api/invoices/order/{orderId:guid}", async (Guid orderId, ISender sender) =>
- await sender.QueryAsync(new GetInvoiceByOrderIdQuery(orderId)) is { } invoice
- ? Results.Ok(invoice)
- : Results.NotFound());
+app.MapGet(
+ "/api/invoices/order/{orderId:guid}",
+ async (Guid orderId, ISender sender) =>
+ await sender.QueryAsync(new GetInvoiceByOrderIdQuery(orderId)) is { } invoice
+ ? Results.Ok(invoice)
+ : Results.NotFound());
// Payments
-app.MapPost("/api/payments/{invoiceId:guid}", async (Guid invoiceId, ProcessPaymentRequest request, ISender sender) =>
-{
- var result = await sender.SendAsync(new ProcessPaymentCommand(invoiceId, request.PaymentMethod));
-
- if (!result.Success)
+app.MapPost(
+ "/api/payments/{invoiceId:guid}",
+ async (Guid invoiceId, ProcessPaymentRequest request, ISender sender) =>
{
- return result.Error == "Invoice not found"
- ? Results.NotFound(result.Error)
- : Results.BadRequest(result.Error);
- }
+ var result = await sender.SendAsync(new ProcessPaymentCommand(invoiceId, request.PaymentMethod));
- return Results.Ok(result.Payment);
-});
+ if (!result.Success)
+ {
+ return result.Error == "Invoice not found"
+ ? Results.NotFound(result.Error)
+ : Results.BadRequest(result.Error);
+ }
-app.MapGet("/api/payments", async (ISender sender) =>
- await sender.QueryAsync(new GetPaymentsQuery()));
+ return Results.Ok(result.Payment);
+ });
+
+app.MapGet("/api/payments", async (ISender sender) => await sender.QueryAsync(new GetPaymentsQuery()));
// Refunds
-app.MapGet("/api/refunds", async (ISender sender) =>
- await sender.QueryAsync(new GetRefundsQuery()));
+app.MapGet("/api/refunds", async (ISender sender) => await sender.QueryAsync(new GetRefundsQuery()));
-app.MapGet("/api/refunds/order/{orderId:guid}", async (Guid orderId, ISender sender) =>
- await sender.QueryAsync(new GetRefundsByOrderIdQuery(orderId)));
+app.MapGet(
+ "/api/refunds/order/{orderId:guid}",
+ async (Guid orderId, ISender sender) => await sender.QueryAsync(new GetRefundsByOrderIdQuery(orderId)));
// Revenue Summaries
-app.MapGet("/api/revenue-summaries", async (ISender sender) =>
- await sender.QueryAsync(new GetRevenueSummariesQuery()));
-
-app.MapGet("/api/revenue-summaries/latest", async (ISender sender) =>
- await sender.QueryAsync(new GetLatestRevenueSummaryQuery()) is { } summary
- ? Results.Ok(summary)
- : Results.NotFound());
+app.MapGet("/api/revenue-summaries", async (ISender sender) => await sender.QueryAsync(new GetRevenueSummariesQuery()));
+
+app.MapGet(
+ "/api/revenue-summaries/latest",
+ async (ISender sender) =>
+ await sender.QueryAsync(new GetLatestRevenueSummaryQuery()) is { } summary
+ ? Results.Ok(summary)
+ : Results.NotFound());
app.Run();
diff --git a/src/Mocha/src/Demo/Demo.Catalog/Commands/PlaceOrderCommand.cs b/src/Mocha/src/Demo/Demo.Catalog/Commands/PlaceOrderCommand.cs
index f68823f5101..25f028ecbbb 100644
--- a/src/Mocha/src/Demo/Demo.Catalog/Commands/PlaceOrderCommand.cs
+++ b/src/Mocha/src/Demo/Demo.Catalog/Commands/PlaceOrderCommand.cs
@@ -1,7 +1,6 @@
using Demo.Catalog.Data;
using Demo.Catalog.Entities;
using Demo.Contracts.Events;
-using Microsoft.EntityFrameworkCore;
using Mocha;
using Mocha.Mediator;
@@ -21,57 +20,47 @@ public class PlaceOrderCommandHandler(CatalogDbContext db, IMessageBus messageBu
public async ValueTask HandleAsync(
PlaceOrderCommand command, CancellationToken cancellationToken)
{
- var executionStrategy = db.Database.CreateExecutionStrategy();
+ var product = await db.Products.FindAsync(command.ProductId);
+ if (product is null)
+ {
+ return new PlaceOrderResult(false, Error: "Product not found");
+ }
- return await executionStrategy.ExecuteAsync(async () =>
+ if (product.StockQuantity < command.Quantity)
{
- await using var transaction = await db.Database.BeginTransactionAsync();
+ return new PlaceOrderResult(false, Error: "Insufficient stock");
+ }
- var product = await db.Products.FindAsync(command.ProductId);
- if (product is null)
- {
- return new PlaceOrderResult(false, Error: "Product not found");
- }
+ var order = new OrderRecord
+ {
+ Id = Guid.NewGuid(),
+ ProductId = product.Id,
+ Quantity = command.Quantity,
+ CustomerId = command.CustomerId,
+ ShippingAddress = command.ShippingAddress,
+ TotalAmount = product.Price * command.Quantity,
+ Status = OrderStatus.Pending,
+ CreatedAt = DateTimeOffset.UtcNow,
+ UpdatedAt = DateTimeOffset.UtcNow
+ };
- if (product.StockQuantity < command.Quantity)
- {
- return new PlaceOrderResult(false, Error: "Insufficient stock");
- }
+ db.Orders.Add(order);
- var order = new OrderRecord
+ await messageBus.PublishAsync(
+ new OrderPlacedEvent
{
- Id = Guid.NewGuid(),
+ OrderId = order.Id,
ProductId = product.Id,
- Quantity = command.Quantity,
- CustomerId = command.CustomerId,
- ShippingAddress = command.ShippingAddress,
- TotalAmount = product.Price * command.Quantity,
- Status = OrderStatus.Pending,
- CreatedAt = DateTimeOffset.UtcNow,
- UpdatedAt = DateTimeOffset.UtcNow
- };
-
- db.Orders.Add(order);
- await db.SaveChangesAsync();
-
- await messageBus.PublishAsync(
- new OrderPlacedEvent
- {
- OrderId = order.Id,
- ProductId = product.Id,
- ProductName = product.Name,
- Quantity = order.Quantity,
- UnitPrice = product.Price,
- TotalAmount = order.TotalAmount,
- CustomerId = order.CustomerId,
- ShippingAddress = order.ShippingAddress,
- CreatedAt = order.CreatedAt
- },
- CancellationToken.None);
-
- await transaction.CommitAsync();
+ ProductName = product.Name,
+ Quantity = order.Quantity,
+ UnitPrice = product.Price,
+ TotalAmount = order.TotalAmount,
+ CustomerId = order.CustomerId,
+ ShippingAddress = order.ShippingAddress,
+ CreatedAt = order.CreatedAt
+ },
+ CancellationToken.None);
- return new PlaceOrderResult(true, order);
- });
+ return new PlaceOrderResult(true, order);
}
}
diff --git a/src/Mocha/src/Demo/Demo.Catalog/Demo.Catalog.csproj b/src/Mocha/src/Demo/Demo.Catalog/Demo.Catalog.csproj
index 1605134d4af..bff087b98db 100644
--- a/src/Mocha/src/Demo/Demo.Catalog/Demo.Catalog.csproj
+++ b/src/Mocha/src/Demo/Demo.Catalog/Demo.Catalog.csproj
@@ -2,6 +2,7 @@
HotChocolate.Demo.Catalog
HotChocolate.Demo.Catalog
+ net10.0
diff --git a/src/Mocha/src/Demo/Demo.Catalog/Program.cs b/src/Mocha/src/Demo/Demo.Catalog/Program.cs
index 97b7dce75ea..f6c2583b767 100644
--- a/src/Mocha/src/Demo/Demo.Catalog/Program.cs
+++ b/src/Mocha/src/Demo/Demo.Catalog/Program.cs
@@ -1,8 +1,6 @@
using Demo.Catalog.Commands;
using Demo.Catalog.Data;
-using Demo.Catalog.Handlers;
using Demo.Catalog.Queries;
-using Demo.Catalog.Sagas;
using Microsoft.EntityFrameworkCore;
using Mocha;
using Mocha.EntityFrameworkCore;
@@ -18,7 +16,7 @@
builder.AddServiceDefaults();
// Database
-builder.AddNpgsqlDbContext("catalog-db");
+builder.AddNpgsqlDbContext("catalog-db", x => x.DisableTracing = true);
// RabbitMQ
builder.AddRabbitMQClient("rabbitmq", x => x.DisableTracing = true);
@@ -26,23 +24,14 @@
// Mocha.Mediator
builder.Services.AddMediator()
.AddCatalog()
+ .AddInstrumentation()
.UseEntityFrameworkTransactions();
// MessageBus
builder
.Services.AddMessageBus()
.AddInstrumentation()
- // Event handlers
- .AddEventHandler()
- .AddEventHandler()
- // Request handlers
- .AddRequestHandler()
- .AddRequestHandler()
- .AddRequestHandler()
- .AddRequestHandler()
- // Sagas
- .AddSaga()
- .AddSaga()
+ .AddCatalog()
.AddEntityFramework(p =>
{
p.AddPostgresSagas();
diff --git a/src/Mocha/src/Demo/Demo.ServiceDefaults/Extensions.cs b/src/Mocha/src/Demo/Demo.ServiceDefaults/Extensions.cs
index 141ae9df586..e6d46b1d4d5 100644
--- a/src/Mocha/src/Demo/Demo.ServiceDefaults/Extensions.cs
+++ b/src/Mocha/src/Demo/Demo.ServiceDefaults/Extensions.cs
@@ -66,6 +66,7 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder)
tracing
.AddSource(builder.Environment.ApplicationName)
.AddSource("Mocha")
+ .AddSource("Mocha.*")
.AddAspNetCoreInstrumentation(tracing =>
// Exclude health check requests from tracing
tracing.Filter = context =>
diff --git a/src/Mocha/src/Demo/Demo.Shipping/Demo.Shipping.csproj b/src/Mocha/src/Demo/Demo.Shipping/Demo.Shipping.csproj
index fd7274e18f8..90ac64f82a0 100644
--- a/src/Mocha/src/Demo/Demo.Shipping/Demo.Shipping.csproj
+++ b/src/Mocha/src/Demo/Demo.Shipping/Demo.Shipping.csproj
@@ -2,6 +2,7 @@
HotChocolate.Demo.Shipping
HotChocolate.Demo.Shipping
+ net10.0
diff --git a/src/Mocha/src/Demo/Demo.Shipping/Program.cs b/src/Mocha/src/Demo/Demo.Shipping/Program.cs
index 9cca0ac16dc..eb08a0c7865 100644
--- a/src/Mocha/src/Demo/Demo.Shipping/Program.cs
+++ b/src/Mocha/src/Demo/Demo.Shipping/Program.cs
@@ -1,6 +1,5 @@
using Demo.Shipping.Commands;
using Demo.Shipping.Data;
-using Demo.Shipping.Handlers;
using Demo.Shipping.Queries;
using Mocha;
using Mocha.EntityFrameworkCore;
@@ -14,7 +13,7 @@
builder.AddServiceDefaults();
// Database
-builder.AddNpgsqlDbContext("shipping-db");
+builder.AddNpgsqlDbContext("shipping-db", x => x.DisableTracing = true);
// RabbitMQ
builder.AddRabbitMQClient("rabbitmq", x => x.DisableTracing = true);
@@ -22,17 +21,14 @@
// Mocha.Mediator
builder.Services.AddMediator()
.AddShipping()
+ .AddInstrumentation()
.UseEntityFrameworkTransactions();
// MessageBus
builder
.Services.AddMessageBus()
.AddInstrumentation()
- // Event handlers
- .AddEventHandler()
- // Request handlers
- .AddRequestHandler()
- .AddRequestHandler()
+ .AddShipping()
.AddEntityFramework(p =>
{
p.UsePostgresOutbox();
diff --git a/src/Mocha/src/Demo/Demo.slnx b/src/Mocha/src/Demo/Demo.slnx
new file mode 100644
index 00000000000..60c3be1bc6c
--- /dev/null
+++ b/src/Mocha/src/Demo/Demo.slnx
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Mocha/src/Mocha.Abstractions/MessagingModuleAttribute.cs b/src/Mocha/src/Mocha.Abstractions/MessagingModuleAttribute.cs
new file mode 100644
index 00000000000..d594591e9b3
--- /dev/null
+++ b/src/Mocha/src/Mocha.Abstractions/MessagingModuleAttribute.cs
@@ -0,0 +1,26 @@
+namespace Mocha;
+
+///
+/// Specifies the assembly module name that is being used in combination
+/// with the Mocha.Analyzers source generators for MessageBus handler registration.
+///
+[AttributeUsage(AttributeTargets.Assembly)]
+public sealed class MessagingModuleAttribute : Attribute
+{
+ ///
+ /// Initializes a new instance of .
+ ///
+ ///
+ /// The module name.
+ ///
+ public MessagingModuleAttribute(string name)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ Name = name;
+ }
+
+ ///
+ /// Gets the module name.
+ ///
+ public string Name { get; }
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/AnalyzerReleases.Unshipped.md b/src/Mocha/src/Mocha.Analyzers/AnalyzerReleases.Unshipped.md
index 5b5c6b3f8ec..7415218a67e 100644
--- a/src/Mocha/src/Mocha.Analyzers/AnalyzerReleases.Unshipped.md
+++ b/src/Mocha/src/Mocha.Analyzers/AnalyzerReleases.Unshipped.md
@@ -7,3 +7,7 @@ MO0002 | Mediator | Error | Message type has multiple handlers
MO0003 | Mediator | Warning | Handler is abstract and will not be registered
MO0004 | Mediator | Info | Open generic message type cannot be dispatched
MO0005 | Mediator | Error | Handler implements multiple mediator handler interfaces
+MO0011 | Messaging | Error | Request type has multiple handlers
+MO0012 | Messaging | Info | Open generic messaging handler cannot be auto-registered
+MO0013 | Messaging | Warning | Messaging handler is abstract
+MO0014 | Messaging | Error | Saga must have a public parameterless constructor
diff --git a/src/Mocha/src/Mocha.Analyzers/Errors.cs b/src/Mocha/src/Mocha.Analyzers/Errors.cs
index 84021f29a0f..1e25bb72393 100644
--- a/src/Mocha/src/Mocha.Analyzers/Errors.cs
+++ b/src/Mocha/src/Mocha.Analyzers/Errors.cs
@@ -83,4 +83,64 @@ public static class Errors
category: "Mediator",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
+
+ ///
+ /// Gets the descriptor for MO0011: a request type has more than one handler.
+ ///
+ ///
+ /// Reported as an error when a request type has multiple handler implementations.
+ /// Request types must have exactly one handler.
+ ///
+ public static readonly DiagnosticDescriptor DuplicateRequestHandler = new(
+ id: "MO0011",
+ title: "Duplicate handler for request type",
+ messageFormat: "Request type '{0}' has multiple handlers: {1}",
+ category: "Messaging",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ ///
+ /// Gets the descriptor for MO0012: a messaging handler is an open generic and cannot be auto-registered.
+ ///
+ ///
+ /// Reported as an info when a messaging handler has unbound type parameters,
+ /// making it impossible to register at compile time.
+ ///
+ public static readonly DiagnosticDescriptor OpenGenericMessagingHandler = new(
+ id: "MO0012",
+ title: "Open generic messaging handler cannot be auto-registered",
+ messageFormat: "Handler '{0}' is an open generic and cannot be auto-registered",
+ category: "Messaging",
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: true);
+
+ ///
+ /// Gets the descriptor for MO0013: a messaging handler class is abstract and cannot be registered.
+ ///
+ ///
+ /// Reported as a warning when a class implements a messaging handler interface but is declared
+ /// abstract, preventing it from being instantiated at runtime.
+ ///
+ public static readonly DiagnosticDescriptor AbstractMessagingHandler = new(
+ id: "MO0013",
+ title: "Messaging handler is abstract",
+ messageFormat: "Handler '{0}' is abstract and will not be registered",
+ category: "Messaging",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true);
+
+ ///
+ /// Gets the descriptor for MO0014: a saga subclass has no public parameterless constructor.
+ ///
+ ///
+ /// Reported as an error when a Saga<TState> subclass does not have a public
+ /// parameterless constructor, which is required by the new() constraint on AddSaga<T>.
+ ///
+ public static readonly DiagnosticDescriptor SagaMissingParameterlessConstructor = new(
+ id: "MO0014",
+ title: "Saga must have a public parameterless constructor",
+ messageFormat: "Saga '{0}' must have a public parameterless constructor",
+ category: "Messaging",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
}
diff --git a/src/Mocha/src/Mocha.Analyzers/FileBuilders/DependencyInjectionFileBuilder.cs b/src/Mocha/src/Mocha.Analyzers/FileBuilders/DependencyInjectionFileBuilder.cs
index f8e520c70e2..4b4f6244605 100644
--- a/src/Mocha/src/Mocha.Analyzers/FileBuilders/DependencyInjectionFileBuilder.cs
+++ b/src/Mocha/src/Mocha.Analyzers/FileBuilders/DependencyInjectionFileBuilder.cs
@@ -1,5 +1,3 @@
-using System.Security.Cryptography;
-using System.Text;
using Mocha.Analyzers.Utils;
namespace Mocha.Analyzers.FileBuilders;
@@ -17,7 +15,7 @@ public DependencyInjectionFileBuilder(string moduleName, string assemblyName) :
{
_extensionsClassName = moduleName + "MediatorBuilderExtensions";
_methodName = "Add" + moduleName;
- HintName = _extensionsClassName + "." + ComputeSalt(assemblyName);
+ HintName = _extensionsClassName + "." + HashHelper.ComputeSalt(assemblyName);
}
public string HintName { get; }
@@ -128,22 +126,4 @@ public void WriteEndRegistrationMethod()
Writer.DecreaseIndent();
Writer.WriteIndentedLine("}");
}
-
-#pragma warning disable CA5351 // MD5 is used for non-security hashing (file name salting)
- private static readonly MD5 s_md5 = MD5.Create();
-#pragma warning restore CA5351
-
- private static string ComputeSalt(string assemblyName)
- {
- byte[] hashBytes;
-
- lock (s_md5)
- {
- hashBytes = s_md5.ComputeHash(Encoding.UTF8.GetBytes(assemblyName));
- }
-
- var base64 = Convert.ToBase64String(hashBytes, Base64FormattingOptions.None);
-
- return base64.Replace("+", "-").Replace("/", "_").TrimEnd('=');
- }
}
diff --git a/src/Mocha/src/Mocha.Analyzers/FileBuilders/MessagingDependencyInjectionFileBuilder.cs b/src/Mocha/src/Mocha.Analyzers/FileBuilders/MessagingDependencyInjectionFileBuilder.cs
new file mode 100644
index 00000000000..f5d32a07fb7
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/FileBuilders/MessagingDependencyInjectionFileBuilder.cs
@@ -0,0 +1,124 @@
+using Mocha.Analyzers.Utils;
+
+namespace Mocha.Analyzers.FileBuilders;
+
+///
+/// Builds the module-prefixed message bus builder extensions source file that registers
+/// messaging handlers and sagas into the dependency injection container.
+///
+public sealed class MessagingDependencyInjectionFileBuilder : FileBuilderBase
+{
+ private readonly string _extensionsClassName;
+ private readonly string _methodName;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The module name used to prefix generated type names.
+ /// The assembly name used to compute a unique file hint name.
+ public MessagingDependencyInjectionFileBuilder(string moduleName, string assemblyName) : base(moduleName)
+ {
+ _extensionsClassName = moduleName + "MessageBusBuilderExtensions";
+ _methodName = "Add" + moduleName;
+ HintName = _extensionsClassName + "." + HashHelper.ComputeSalt(assemblyName);
+ }
+
+ ///
+ /// Gets the hint name used to uniquely identify the generated source file.
+ ///
+ public string HintName { get; }
+
+ ///
+ protected override string Namespace => "Microsoft.Extensions.DependencyInjection";
+
+ ///
+ public override void WriteBeginClass()
+ {
+ Writer.WriteGeneratedAttribute();
+ Writer.WriteIndentedLine("public static class {0}", _extensionsClassName);
+ Writer.WriteIndentedLine("{");
+ Writer.IncreaseIndent();
+ }
+
+ ///
+ /// Writes the opening of the registration extension method.
+ ///
+ public void WriteBeginRegistrationMethod()
+ {
+ Writer.WriteIndentedLine("public static global::Mocha.IMessageBusHostBuilder {0}(", _methodName);
+ Writer.IncreaseIndent();
+ Writer.WriteIndentedLine("this global::Mocha.IMessageBusHostBuilder builder)");
+ Writer.DecreaseIndent();
+ Writer.WriteIndentedLine("{");
+ Writer.IncreaseIndent();
+ }
+
+ ///
+ /// Writes a handler registration call for the specified messaging handler.
+ ///
+ /// The messaging handler info to register.
+ public void WriteHandlerRegistration(MessagingHandlerInfo handler)
+ {
+ var factoryCall = handler.Kind switch
+ {
+ MessagingHandlerKind.Event =>
+ $"Subscribe<{handler.HandlerTypeName}, {handler.MessageTypeName}>()",
+ MessagingHandlerKind.Send =>
+ $"Send<{handler.HandlerTypeName}, {handler.MessageTypeName}>()",
+ MessagingHandlerKind.RequestResponse =>
+ $"Request<{handler.HandlerTypeName}, {handler.MessageTypeName}, {handler.ResponseTypeName}>()",
+ MessagingHandlerKind.Consumer =>
+ $"Consume<{handler.HandlerTypeName}, {handler.MessageTypeName}>()",
+ MessagingHandlerKind.Batch =>
+ $"Batch<{handler.HandlerTypeName}, {handler.MessageTypeName}>()",
+ _ => throw new ArgumentOutOfRangeException()
+ };
+
+ Writer.WriteIndentedLine(
+ "global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration<{0}>(builder,",
+ handler.HandlerTypeName);
+ Writer.IncreaseIndent();
+ Writer.WriteIndentedLine("new global::Mocha.MessagingHandlerConfiguration");
+ Writer.WriteIndentedLine("{");
+ Writer.IncreaseIndent();
+ Writer.WriteIndentedLine("HandlerType = typeof({0}),", handler.HandlerTypeName);
+ Writer.WriteIndentedLine("Factory = global::Mocha.ConsumerFactory.{0}", factoryCall);
+ Writer.DecreaseIndent();
+ Writer.WriteIndentedLine("});");
+ Writer.DecreaseIndent();
+ }
+
+ ///
+ /// Writes a saga registration call for the specified saga.
+ ///
+ /// The saga info to register.
+ public void WriteSagaRegistration(SagaInfo saga)
+ {
+ Writer.WriteIndentedLine(
+ "global::Mocha.MessageBusHostBuilderExtensions.AddSaga<");
+ Writer.IncreaseIndent();
+ Writer.WriteIndentedLine("{0}>(builder);", saga.SagaTypeName);
+ Writer.DecreaseIndent();
+ }
+
+ ///
+ /// Writes a section comment to visually separate groups of registrations.
+ ///
+ /// The section label to write.
+ public void WriteSectionComment(string comment)
+ {
+ Writer.WriteLine();
+ Writer.WriteIndentedLine("// --- {0} ---", comment);
+ }
+
+ ///
+ /// Writes the closing of the registration extension method, including the return statement.
+ ///
+ public void WriteEndRegistrationMethod()
+ {
+ Writer.WriteLine();
+ Writer.WriteIndentedLine("return builder;");
+ Writer.DecreaseIndent();
+ Writer.WriteIndentedLine("}");
+ }
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/Filters/ClassWithMochaBaseListFilter.cs b/src/Mocha/src/Mocha.Analyzers/Filters/ClassWithMochaBaseListFilter.cs
index 675e6159328..264ab76d2e7 100644
--- a/src/Mocha/src/Mocha.Analyzers/Filters/ClassWithMochaBaseListFilter.cs
+++ b/src/Mocha/src/Mocha.Analyzers/Filters/ClassWithMochaBaseListFilter.cs
@@ -49,5 +49,10 @@ private static bool IsMochaCandidateName(string name)
=> name.StartsWith("ICommand", StringComparison.Ordinal)
|| name.StartsWith("IQuery", StringComparison.Ordinal)
|| name.StartsWith("INotification", StringComparison.Ordinal)
- || name.StartsWith("IStream", StringComparison.Ordinal);
+ || name.StartsWith("IStream", StringComparison.Ordinal)
+ || name.StartsWith("IEventHandler", StringComparison.Ordinal)
+ || name.StartsWith("IEventRequestHandler", StringComparison.Ordinal)
+ || name.StartsWith("IConsumer", StringComparison.Ordinal)
+ || name.StartsWith("IBatchEventHandler", StringComparison.Ordinal)
+ || name.StartsWith("Saga", StringComparison.Ordinal);
}
diff --git a/src/Mocha/src/Mocha.Analyzers/Generators/MessagingDependencyInjectionGenerator.cs b/src/Mocha/src/Mocha.Analyzers/Generators/MessagingDependencyInjectionGenerator.cs
new file mode 100644
index 00000000000..5b3ef33825b
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/Generators/MessagingDependencyInjectionGenerator.cs
@@ -0,0 +1,122 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Mocha.Analyzers.FileBuilders;
+
+namespace Mocha.Analyzers.Generators;
+
+///
+/// Provides a source generator that emits the Add{Module} extension method on
+/// IMessageBusHostBuilder, registering messaging handlers and sagas into the
+/// dependency injection container.
+///
+public sealed class MessagingDependencyInjectionGenerator : ISyntaxGenerator
+{
+ ///
+ public void Generate(
+ SourceProductionContext context,
+ string assemblyName,
+ string moduleName,
+ ImmutableArray syntaxInfos,
+ Action addSource)
+ {
+ var handlers = syntaxInfos
+ .OfType()
+ .Where(h => h.Diagnostics.Count == 0)
+ .OrderBy(h => h.OrderByKey)
+ .ToList();
+
+ var sagas = syntaxInfos
+ .OfType()
+ .Where(s => s.Diagnostics.Count == 0)
+ .OrderBy(s => s.OrderByKey)
+ .ToList();
+
+ if (handlers.Count == 0 && sagas.Count == 0)
+ {
+ return;
+ }
+
+ using var builder = new MessagingDependencyInjectionFileBuilder(moduleName, assemblyName);
+
+ builder.WriteHeader();
+ builder.WriteBeginNamespace();
+ builder.WriteBeginClass();
+ builder.WriteBeginRegistrationMethod();
+
+ var batchHandlers = handlers
+ .Where(h => h.Kind == MessagingHandlerKind.Batch)
+ .OrderBy(h => h.HandlerTypeName)
+ .ToList();
+
+ if (batchHandlers.Count > 0)
+ {
+ builder.WriteSectionComment("Batch Handlers");
+
+ foreach (var handler in batchHandlers)
+ {
+ builder.WriteHandlerRegistration(handler);
+ }
+ }
+
+ var consumers = handlers
+ .Where(h => h.Kind == MessagingHandlerKind.Consumer)
+ .OrderBy(h => h.HandlerTypeName)
+ .ToList();
+
+ if (consumers.Count > 0)
+ {
+ builder.WriteSectionComment("Consumers");
+
+ foreach (var handler in consumers)
+ {
+ builder.WriteHandlerRegistration(handler);
+ }
+ }
+
+ var requestHandlers = handlers
+ .Where(h => h.Kind is MessagingHandlerKind.RequestResponse or MessagingHandlerKind.Send)
+ .OrderBy(h => h.HandlerTypeName)
+ .ToList();
+
+ if (requestHandlers.Count > 0)
+ {
+ builder.WriteSectionComment("Request Handlers");
+
+ foreach (var handler in requestHandlers)
+ {
+ builder.WriteHandlerRegistration(handler);
+ }
+ }
+
+ var eventHandlers = handlers
+ .Where(h => h.Kind == MessagingHandlerKind.Event)
+ .OrderBy(h => h.HandlerTypeName)
+ .ToList();
+
+ if (eventHandlers.Count > 0)
+ {
+ builder.WriteSectionComment("Event Handlers");
+
+ foreach (var handler in eventHandlers)
+ {
+ builder.WriteHandlerRegistration(handler);
+ }
+ }
+
+ if (sagas.Count > 0)
+ {
+ builder.WriteSectionComment("Sagas");
+
+ foreach (var saga in sagas)
+ {
+ builder.WriteSagaRegistration(saga);
+ }
+ }
+
+ builder.WriteEndRegistrationMethod();
+ builder.WriteEndClass();
+ builder.WriteEndNamespace();
+
+ addSource(builder.HintName + ".g.cs", builder.ToSourceText());
+ }
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/AbstractMessagingHandlerInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/AbstractMessagingHandlerInspector.cs
new file mode 100644
index 00000000000..1c9a07b9d17
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/AbstractMessagingHandlerInspector.cs
@@ -0,0 +1,121 @@
+using System.Collections.Immutable;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Mocha.Analyzers.Filters;
+using Mocha.Analyzers.Utils;
+
+namespace Mocha.Analyzers.Inspectors;
+
+///
+/// Detects abstract class declarations implementing messaging handler interfaces
+/// and reports the MO0013 diagnostic to warn that abstract handlers cannot be registered.
+///
+public sealed class AbstractMessagingHandlerInspector : ISyntaxInspector
+{
+ ///
+ public ImmutableArray Filters { get; } = [ClassWithMochaBaseListFilter.Instance];
+
+ ///
+ public IImmutableSet SupportedKinds { get; } =
+ ImmutableHashSet.Create(SyntaxKind.ClassDeclaration, SyntaxKind.RecordDeclaration);
+
+ ///
+ public bool TryHandle(
+ KnownTypeSymbols knownSymbols,
+ SyntaxNode node,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken,
+ out SyntaxInfo? syntaxInfo)
+ {
+ if (node is not TypeDeclarationSyntax typeDeclaration)
+ {
+ syntaxInfo = null;
+ return false;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var typeSymbol = semanticModel.GetDeclaredSymbol(typeDeclaration);
+
+ if (typeSymbol is not { } namedTypeSymbol)
+ {
+ syntaxInfo = null;
+ return false;
+ }
+
+ // Only handle abstract types (non-interface)
+ if (!namedTypeSymbol.IsAbstract || namedTypeSymbol.TypeKind == TypeKind.Interface)
+ {
+ syntaxInfo = null;
+ return false;
+ }
+
+ // Check if it implements any messaging handler interface
+ if (!ImplementsAnyMessagingHandlerInterface(knownSymbols, namedTypeSymbol))
+ {
+ syntaxInfo = null;
+ return false;
+ }
+
+ var handlerName = namedTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var locationInfo = typeDeclaration.Identifier.GetLocation().ToLocationInfo();
+
+ // Create a placeholder SyntaxInfo carrying the diagnostic
+ syntaxInfo = new AbstractMessagingHandlerDiagnosticInfo(handlerName)
+ {
+ Diagnostics = new([
+ new DiagnosticInfo(Errors.AbstractMessagingHandler.Id, locationInfo, new([handlerName]))
+ ])
+ };
+ return true;
+ }
+
+ private static bool ImplementsAnyMessagingHandlerInterface(
+ KnownTypeSymbols knownSymbols,
+ INamedTypeSymbol namedTypeSymbol)
+ {
+ if (knownSymbols.IBatchEventHandler is not null
+ && namedTypeSymbol.FindImplementedInterface(knownSymbols.IBatchEventHandler) is not null)
+ {
+ return true;
+ }
+
+ if (knownSymbols.IConsumer is not null
+ && namedTypeSymbol.FindImplementedInterface(knownSymbols.IConsumer) is not null)
+ {
+ return true;
+ }
+
+ if (knownSymbols.IEventRequestHandlerResponse is not null
+ && namedTypeSymbol.FindImplementedInterface(knownSymbols.IEventRequestHandlerResponse) is not null)
+ {
+ return true;
+ }
+
+ if (knownSymbols.IEventRequestHandlerVoid is not null
+ && namedTypeSymbol.FindImplementedInterface(knownSymbols.IEventRequestHandlerVoid) is not null)
+ {
+ return true;
+ }
+
+ if (knownSymbols.IEventHandler is not null
+ && namedTypeSymbol.FindImplementedInterface(knownSymbols.IEventHandler) is not null)
+ {
+ return true;
+ }
+
+ return false;
+ }
+}
+
+///
+/// A diagnostic-only SyntaxInfo for MO0013 (abstract messaging handler).
+/// This is not used by code generators.
+///
+internal sealed record AbstractMessagingHandlerDiagnosticInfo(string HandlerTypeName) : SyntaxInfo
+{
+ ///
+ public override string OrderByKey => $"MsgAbstractDiag:{HandlerTypeName}";
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingHandlerInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingHandlerInspector.cs
new file mode 100644
index 00000000000..5b35483207a
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingHandlerInspector.cs
@@ -0,0 +1,143 @@
+using System.Collections.Immutable;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Mocha.Analyzers.Filters;
+using Mocha.Analyzers.Utils;
+
+namespace Mocha.Analyzers.Inspectors;
+
+///
+/// Inspects concrete class or record declarations to discover MessageBus handlers
+/// (IEventHandler, IEventRequestHandler, IConsumer, IBatchEventHandler)
+/// using a priority cascade where the first matching interface wins.
+///
+public sealed class MessagingHandlerInspector : ISyntaxInspector
+{
+ ///
+ public ImmutableArray Filters { get; } = [ClassWithMochaBaseListFilter.Instance];
+
+ ///
+ public IImmutableSet SupportedKinds { get; } =
+ ImmutableHashSet.Create(SyntaxKind.ClassDeclaration, SyntaxKind.RecordDeclaration);
+
+ // Priority cascade: first match wins (matches runtime MessageBusBuilder.AddHandler logic)
+ private static readonly MessagingHandlerKindDescriptor[] s_handlerKinds =
+ [
+ new(static s => s.IBatchEventHandler, MessagingHandlerKind.Batch, false, 0),
+ new(static s => s.IConsumer, MessagingHandlerKind.Consumer, false, 0),
+ new(static s => s.IEventRequestHandlerResponse, MessagingHandlerKind.RequestResponse, true, 0),
+ new(static s => s.IEventRequestHandlerVoid, MessagingHandlerKind.Send, false, 0),
+ new(static s => s.IEventHandler, MessagingHandlerKind.Event, false, 0)
+ ];
+
+ ///
+ public bool TryHandle(
+ KnownTypeSymbols knownSymbols,
+ SyntaxNode node,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken,
+ out SyntaxInfo? syntaxInfo)
+ {
+ syntaxInfo = null;
+
+ if (node is not TypeDeclarationSyntax typeDeclaration)
+ {
+ return false;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var namedTypeSymbol = semanticModel.GetDeclaredSymbol(typeDeclaration);
+
+ if (namedTypeSymbol is null || namedTypeSymbol.IsAbstract || namedTypeSymbol.TypeKind == TypeKind.Interface)
+ {
+ return false;
+ }
+
+ // Check for open generics (MO0012)
+ if (namedTypeSymbol is { IsGenericType: true, TypeParameters.Length: > 0 })
+ {
+ if (ImplementsAnyMessagingInterface(knownSymbols, namedTypeSymbol))
+ {
+ var handlerName = namedTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var locationInfo = typeDeclaration.Identifier.GetLocation().ToLocationInfo();
+ syntaxInfo = new OpenGenericMessagingHandlerDiagnosticInfo(handlerName)
+ {
+ Diagnostics = new([
+ new DiagnosticInfo(Errors.OpenGenericMessagingHandler.Id, locationInfo, new([handlerName]))
+ ])
+ };
+ return true;
+ }
+
+ return false;
+ }
+
+ foreach (var descriptor in s_handlerKinds)
+ {
+ var target = descriptor.GetTarget(knownSymbols);
+ var implemented = target is not null
+ ? namedTypeSymbol.FindImplementedInterface(target)
+ : null;
+
+ if (implemented is null)
+ {
+ continue;
+ }
+
+ var handlerFullName = namedTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var handlerNamespace = namedTypeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty;
+ var messageTypeName = implemented.TypeArguments[descriptor.MessageTypeArgIndex]
+ .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var locationInfo = typeDeclaration.Identifier.GetLocation().ToLocationInfo();
+
+ syntaxInfo = new MessagingHandlerInfo(
+ handlerFullName,
+ handlerNamespace,
+ messageTypeName,
+ descriptor.HasResponse
+ ? implemented.TypeArguments[1].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
+ : null,
+ descriptor.Kind,
+ locationInfo);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool ImplementsAnyMessagingInterface(
+ KnownTypeSymbols knownSymbols,
+ INamedTypeSymbol namedTypeSymbol)
+ {
+ return
+ (knownSymbols.IBatchEventHandler is not null
+ && namedTypeSymbol.FindImplementedInterface(knownSymbols.IBatchEventHandler) is not null)
+ || (knownSymbols.IConsumer is not null
+ && namedTypeSymbol.FindImplementedInterface(knownSymbols.IConsumer) is not null)
+ || (knownSymbols.IEventRequestHandlerResponse is not null
+ && namedTypeSymbol.FindImplementedInterface(knownSymbols.IEventRequestHandlerResponse) is not null)
+ || (knownSymbols.IEventRequestHandlerVoid is not null
+ && namedTypeSymbol.FindImplementedInterface(knownSymbols.IEventRequestHandlerVoid) is not null)
+ || (knownSymbols.IEventHandler is not null
+ && namedTypeSymbol.FindImplementedInterface(knownSymbols.IEventHandler) is not null);
+ }
+
+ private sealed record MessagingHandlerKindDescriptor(
+ Func GetTarget,
+ MessagingHandlerKind Kind,
+ bool HasResponse,
+ int MessageTypeArgIndex);
+}
+
+///
+/// A diagnostic-only SyntaxInfo for MO0012 (open generic messaging handler).
+/// This is not used by code generators.
+///
+internal sealed record OpenGenericMessagingHandlerDiagnosticInfo(string HandlerTypeName) : SyntaxInfo
+{
+ ///
+ public override string OrderByKey => $"MsgOpenGenericDiag:{HandlerTypeName}";
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingModuleInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingModuleInspector.cs
new file mode 100644
index 00000000000..d02a56ab860
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingModuleInspector.cs
@@ -0,0 +1,65 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Mocha.Analyzers.Filters;
+
+namespace Mocha.Analyzers.Inspectors;
+
+///
+/// Inspects assembly-level attribute lists to discover [assembly: MessagingModule("...")]
+/// declarations and extract from them.
+///
+public sealed class MessagingModuleInspector : ISyntaxInspector
+{
+ ///
+ public ImmutableArray Filters { get; } = [AssemblyAttributeListFilter.Instance];
+
+ ///
+ public IImmutableSet SupportedKinds { get; } = [SyntaxKind.AttributeList];
+
+ ///
+ public bool TryHandle(
+ KnownTypeSymbols knownSymbols,
+ SyntaxNode node,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken,
+ out SyntaxInfo? syntaxInfo)
+ {
+ if (node is AttributeListSyntax attributeList)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ foreach (var attributeSyntax in attributeList.Attributes)
+ {
+ var symbol = ModelExtensions.GetSymbolInfo(semanticModel, attributeSyntax).Symbol;
+
+ if (symbol is not IMethodSymbol attributeSymbol)
+ {
+ continue;
+ }
+
+ var attributeContainingTypeSymbol = attributeSymbol.ContainingType;
+ var fullName = attributeContainingTypeSymbol.ToDisplayString();
+
+ if (string.Equals(fullName, SyntaxConstants.MessagingModuleAttribute, StringComparison.Ordinal)
+ && attributeSyntax.ArgumentList is { Arguments.Count: > 0 })
+ {
+ var nameExpr = attributeSyntax.ArgumentList.Arguments[0].Expression;
+ var constantValue = semanticModel.GetConstantValue(nameExpr);
+
+ if (!constantValue.HasValue || constantValue.Value is not string name)
+ {
+ continue;
+ }
+
+ syntaxInfo = new MessagingModuleInfo(name);
+ return true;
+ }
+ }
+ }
+
+ syntaxInfo = null;
+ return false;
+ }
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/SagaInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/SagaInspector.cs
new file mode 100644
index 00000000000..173f776967d
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/SagaInspector.cs
@@ -0,0 +1,113 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Mocha.Analyzers.Filters;
+using Mocha.Analyzers.Utils;
+
+namespace Mocha.Analyzers.Inspectors;
+
+///
+/// Inspects concrete class declarations to discover Saga<TState> subclasses
+/// for source-generated registration via AddSaga<T>.
+///
+public sealed class SagaInspector : ISyntaxInspector
+{
+ ///
+ public ImmutableArray Filters { get; } = [ClassWithMochaBaseListFilter.Instance];
+
+ ///
+ public IImmutableSet SupportedKinds { get; } =
+ ImmutableHashSet.Create(SyntaxKind.ClassDeclaration);
+
+ ///
+ public bool TryHandle(
+ KnownTypeSymbols knownSymbols,
+ SyntaxNode node,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken,
+ out SyntaxInfo? syntaxInfo)
+ {
+ syntaxInfo = null;
+
+ if (knownSymbols.Saga is null)
+ {
+ return false;
+ }
+
+ if (node is not TypeDeclarationSyntax typeDeclaration)
+ {
+ return false;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var namedTypeSymbol = semanticModel.GetDeclaredSymbol(typeDeclaration);
+
+ if (namedTypeSymbol is null
+ || namedTypeSymbol.IsAbstract
+ || namedTypeSymbol.TypeKind == TypeKind.Interface)
+ {
+ return false;
+ }
+
+ // Walk the base type chain looking for Saga
+ if (!DerivesFromSaga(namedTypeSymbol, knownSymbols.Saga))
+ {
+ return false;
+ }
+
+ var sagaFullName = namedTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var sagaNamespace = namedTypeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty;
+
+ // Check for public parameterless constructor (MO0014)
+ if (!HasPublicParameterlessConstructor(namedTypeSymbol))
+ {
+ var locationInfo = typeDeclaration.Identifier.GetLocation().ToLocationInfo();
+ syntaxInfo = new SagaInfo(sagaFullName, sagaNamespace)
+ {
+ Diagnostics = new([
+ new DiagnosticInfo(
+ Errors.SagaMissingParameterlessConstructor.Id,
+ locationInfo,
+ new([sagaFullName]))
+ ])
+ };
+ return true;
+ }
+
+ syntaxInfo = new SagaInfo(sagaFullName, sagaNamespace);
+ return true;
+ }
+
+ private static bool DerivesFromSaga(INamedTypeSymbol type, INamedTypeSymbol sagaSymbol)
+ {
+ var current = type.BaseType;
+ while (current is not null)
+ {
+ if (current.IsGenericType
+ && SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, sagaSymbol))
+ {
+ return true;
+ }
+
+ current = current.BaseType;
+ }
+
+ return false;
+ }
+
+ private static bool HasPublicParameterlessConstructor(INamedTypeSymbol type)
+ {
+ foreach (var constructor in type.InstanceConstructors)
+ {
+ if (constructor.DeclaredAccessibility == Accessibility.Public
+ && constructor.Parameters.Length == 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/KnownTypeSymbols.cs b/src/Mocha/src/Mocha.Analyzers/KnownTypeSymbols.cs
index 0621a4d9b95..3ae0ed1e954 100644
--- a/src/Mocha/src/Mocha.Analyzers/KnownTypeSymbols.cs
+++ b/src/Mocha/src/Mocha.Analyzers/KnownTypeSymbols.cs
@@ -23,6 +23,14 @@ public sealed class KnownTypeSymbols
private Resolved? _commandVoid;
private Resolved? _commandOfT;
private Resolved? _queryOfT;
+ private Resolved? _eventHandler;
+ private Resolved? _eventRequestHandlerVoid;
+ private Resolved? _eventRequestHandlerResponse;
+ private Resolved? _consumer;
+ private Resolved? _batchEventHandler;
+ private Resolved? _saga;
+ private Resolved? _eventRequest;
+ private Resolved? _eventRequestOfT;
private KnownTypeSymbols(Compilation compilation)
{
@@ -78,6 +86,54 @@ public INamedTypeSymbol? ICommandOfT
public INamedTypeSymbol? IQueryOfT
=> Resolve(SyntaxConstants.IQueryOfT, ref _queryOfT);
+ ///
+ /// Gets the symbol for the IEventHandler<TEvent> interface.
+ ///
+ public INamedTypeSymbol? IEventHandler
+ => Resolve(SyntaxConstants.IEventHandler, ref _eventHandler);
+
+ ///
+ /// Gets the symbol for the IEventRequestHandler<TRequest> interface (void return).
+ ///
+ public INamedTypeSymbol? IEventRequestHandlerVoid
+ => Resolve(SyntaxConstants.IEventRequestHandlerVoid, ref _eventRequestHandlerVoid);
+
+ ///
+ /// Gets the symbol for the IEventRequestHandler<TRequest, TResponse> interface.
+ ///
+ public INamedTypeSymbol? IEventRequestHandlerResponse
+ => Resolve(SyntaxConstants.IEventRequestHandlerResponse, ref _eventRequestHandlerResponse);
+
+ ///
+ /// Gets the symbol for the IConsumer<TMessage> interface.
+ ///
+ public INamedTypeSymbol? IConsumer
+ => Resolve(SyntaxConstants.IConsumer, ref _consumer);
+
+ ///
+ /// Gets the symbol for the IBatchEventHandler<TEvent> interface.
+ ///
+ public INamedTypeSymbol? IBatchEventHandler
+ => Resolve(SyntaxConstants.IBatchEventHandler, ref _batchEventHandler);
+
+ ///
+ /// Gets the symbol for the Saga<TState> abstract class.
+ ///
+ public INamedTypeSymbol? Saga
+ => Resolve(SyntaxConstants.Saga, ref _saga);
+
+ ///
+ /// Gets the symbol for the IEventRequest marker interface (void return).
+ ///
+ public INamedTypeSymbol? IEventRequest
+ => Resolve(SyntaxConstants.IEventRequest, ref _eventRequest);
+
+ ///
+ /// Gets the symbol for the IEventRequest<TResponse> interface.
+ ///
+ public INamedTypeSymbol? IEventRequestOfT
+ => Resolve(SyntaxConstants.IEventRequestOfT, ref _eventRequestOfT);
+
private INamedTypeSymbol? Resolve(string metadataName, ref Resolved? field)
{
var snapshot = Interlocked.CompareExchange(ref field, null, null);
diff --git a/src/Mocha/src/Mocha.Analyzers/MessagingGenerator.cs b/src/Mocha/src/Mocha.Analyzers/MessagingGenerator.cs
new file mode 100644
index 00000000000..e789c3e0c5a
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/MessagingGenerator.cs
@@ -0,0 +1,270 @@
+using System.Collections.Immutable;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Text;
+using Mocha.Analyzers.Filters;
+using Mocha.Analyzers.Generators;
+using Mocha.Analyzers.Inspectors;
+using Mocha.Analyzers.Utils;
+
+namespace Mocha.Analyzers;
+
+///
+/// Provides an incremental source generator that discovers MessageBus handlers
+/// and sagas from the compilation and emits the dependency injection registrations.
+///
+[Generator]
+public sealed class MessagingGenerator : IIncrementalGenerator
+{
+ private static readonly ISyntaxInspector[] s_allInspectors =
+ [
+ new MessagingHandlerInspector(),
+ new AbstractMessagingHandlerInspector(),
+ new MessagingModuleInspector(),
+ new SagaInspector()
+ ];
+
+ private static readonly ISyntaxGenerator[] s_generators =
+ [
+ new MessagingDependencyInjectionGenerator()
+ ];
+
+ private static readonly Dictionary> s_inspectorLookup;
+
+ private static readonly Func s_predicate;
+
+ static MessagingGenerator()
+ {
+ var filterBuilder = new SyntaxFilterBuilder();
+ var inspectorLookup = new Dictionary>();
+
+ foreach (var inspector in s_allInspectors)
+ {
+ filterBuilder.AddRange(inspector.Filters);
+
+ foreach (var supportedKind in inspector.SupportedKinds)
+ {
+ if (!inspectorLookup.TryGetValue(supportedKind, out var inspectors))
+ {
+ inspectors = [];
+ inspectorLookup[supportedKind] = inspectors;
+ }
+
+ inspectors.Add(inspector);
+ }
+ }
+
+ s_predicate = filterBuilder.Build();
+ s_inspectorLookup = new Dictionary>();
+
+ foreach (var kvp in inspectorLookup)
+ {
+ s_inspectorLookup[kvp.Key] = kvp.Value.ToImmutableArray();
+ }
+ }
+
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var syntaxInfos = context
+ .SyntaxProvider.CreateSyntaxProvider(
+ predicate: static (s, _) => s_predicate(s),
+ transform: static (ctx, ct) => Transform(ctx.Node, ctx.SemanticModel, ct))
+ .WhereNotNull()
+ .WithComparer(EqualityComparer.Default)
+ .WithTrackingName("MochaMessagingSyntaxInfos")
+ .Collect()
+ .WithTrackingName("MochaMessagingCollectedInfos");
+
+ var assemblyName = context.CompilationProvider.Select(static (c, _) => c.AssemblyName ?? "Unknown");
+
+ context.RegisterSourceOutput(
+ assemblyName.Combine(syntaxInfos),
+ static (context, source) => Execute(context, source.Left, source.Right));
+ }
+
+ private static SyntaxInfo? Transform(
+ SyntaxNode node,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken)
+ {
+ if (!s_inspectorLookup.TryGetValue(node.Kind(), out var inspectors))
+ {
+ return null;
+ }
+
+ var knownTypeSymbols = KnownTypeSymbols.GetOrCreate(semanticModel.Compilation);
+
+ foreach (var inspector in inspectors)
+ {
+ if (inspector.TryHandle(knownTypeSymbols, node, semanticModel, cancellationToken, out var syntaxInfo))
+ {
+ return syntaxInfo;
+ }
+ }
+
+ return null;
+ }
+
+ private static void Execute(
+ SourceProductionContext context,
+ string assemblyName,
+ ImmutableArray syntaxInfos)
+ {
+ var sourceFiles = PooledObjects.GetStringDictionary();
+ var moduleInfo = GetModuleInfo(syntaxInfos, ModuleNameHelper.CreateModuleName(assemblyName));
+
+ try
+ {
+ // Report diagnostics attached to individual SyntaxInfo entries (e.g. MO0012, MO0013, MO0014).
+ foreach (var syntaxInfo in syntaxInfos)
+ {
+ if (syntaxInfo.Diagnostics.Count > 0)
+ {
+ foreach (var diagInfo in syntaxInfo.Diagnostics)
+ {
+ context.ReportDiagnostic(ReconstructDiagnostic(diagInfo));
+ }
+ }
+ }
+
+ // Validate request handler pairing (MO0011)
+ ValidateRequestHandlerPairing(context, syntaxInfos);
+
+ foreach (var generator in s_generators)
+ {
+ generator.Generate(
+ context,
+ assemblyName,
+ moduleInfo.ModuleName,
+ syntaxInfos,
+ AddSource);
+ }
+
+ foreach (var sourceFile in sourceFiles)
+ {
+ context.AddSource(sourceFile.Key, SourceText.From(sourceFile.Value, Encoding.UTF8));
+ }
+ }
+ finally
+ {
+ PooledObjects.Return(sourceFiles);
+ }
+
+ void AddSource(string fileName, string sourceText)
+ {
+ sourceFiles[fileName] = sourceText;
+ }
+ }
+
+ private static MessagingModuleInfo GetModuleInfo(ImmutableArray syntaxInfos, string defaultModuleName)
+ {
+ foreach (var syntaxInfo in syntaxInfos)
+ {
+ if (syntaxInfo is MessagingModuleInfo module)
+ {
+ return new MessagingModuleInfo(ModuleNameHelper.SanitizeIdentifier(module.ModuleName));
+ }
+ }
+
+ return new MessagingModuleInfo(defaultModuleName);
+ }
+
+ private static void ValidateRequestHandlerPairing(
+ SourceProductionContext context,
+ ImmutableArray syntaxInfos)
+ {
+ var requestHandlers = new List();
+
+ foreach (var info in syntaxInfos)
+ {
+ if (info is MessagingHandlerInfo
+ {
+ Diagnostics.Count: 0,
+ Kind: MessagingHandlerKind.Send or MessagingHandlerKind.RequestResponse
+ } handlerInfo)
+ {
+ requestHandlers.Add(handlerInfo);
+ }
+ }
+
+ if (requestHandlers.Count == 0)
+ {
+ return;
+ }
+
+ // Group by message type to detect duplicates
+ var handlersByMessageType = new Dictionary>();
+ foreach (var handler in requestHandlers)
+ {
+ if (!handlersByMessageType.TryGetValue(handler.MessageTypeName, out var list))
+ {
+ list = new List();
+ handlersByMessageType[handler.MessageTypeName] = list;
+ }
+
+ list.Add(handler);
+ }
+
+ foreach (var kvp in handlersByMessageType)
+ {
+ if (kvp.Value.Count > 1)
+ {
+ // MO0011: Duplicate request handler
+ var handlerNames = string.Join(", ", kvp.Value.Select(h => h.HandlerTypeName).OrderBy(n => n));
+ var firstHandler = kvp.Value[0];
+ var diagnosticLocation = ReconstructLocation(firstHandler.Location);
+
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ Errors.DuplicateRequestHandler,
+ diagnosticLocation,
+ firstHandler.MessageTypeName,
+ handlerNames));
+ }
+ }
+ }
+
+ private static readonly Dictionary s_descriptorLookup = new()
+ {
+ [Errors.DuplicateRequestHandler.Id] = Errors.DuplicateRequestHandler,
+ [Errors.OpenGenericMessagingHandler.Id] = Errors.OpenGenericMessagingHandler,
+ [Errors.AbstractMessagingHandler.Id] = Errors.AbstractMessagingHandler,
+ [Errors.SagaMissingParameterlessConstructor.Id] = Errors.SagaMissingParameterlessConstructor
+ };
+
+ private static Diagnostic ReconstructDiagnostic(DiagnosticInfo info)
+ {
+ var descriptor = s_descriptorLookup[info.DescriptorId];
+ var location = ReconstructLocation(info.Location);
+ var args = new object[info.MessageArgs.Count];
+ for (var i = 0; i < info.MessageArgs.Count; i++)
+ {
+ args[i] = info.MessageArgs[i];
+ }
+
+ return Diagnostic.Create(descriptor, location, args);
+ }
+
+ private static Location ReconstructLocation(LocationInfo? locationInfo)
+ {
+ if (locationInfo is null)
+ {
+ return Location.None;
+ }
+
+ return Location.Create(
+ locationInfo.FilePath,
+ default,
+ new LinePositionSpan(
+ new LinePosition(locationInfo.StartLine, locationInfo.StartColumn),
+ new LinePosition(locationInfo.EndLine, locationInfo.EndColumn)));
+ }
+}
+
+file static class Extensions
+{
+ public static IncrementalValuesProvider WhereNotNull(this IncrementalValuesProvider source)
+ => source.Where(static t => t is not null)!;
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/Models/MessagingHandlerInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/MessagingHandlerInfo.cs
new file mode 100644
index 00000000000..5cccda6c3a6
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/Models/MessagingHandlerInfo.cs
@@ -0,0 +1,26 @@
+namespace Mocha.Analyzers;
+
+///
+/// Represents the extracted metadata for a MessageBus handler discovered during source generation.
+///
+/// The fully qualified type name of the handler class.
+/// The namespace containing the handler class.
+/// The fully qualified type name of the message the handler processes.
+///
+/// The fully qualified type name of the response, or if the handler returns no response.
+///
+/// The kind of messaging handler.
+///
+/// The equatable source location of the handler type declaration, or if unavailable.
+///
+public sealed record MessagingHandlerInfo(
+ string HandlerTypeName,
+ string HandlerNamespace,
+ string MessageTypeName,
+ string? ResponseTypeName,
+ MessagingHandlerKind Kind,
+ LocationInfo? Location) : SyntaxInfo
+{
+ ///
+ public override string OrderByKey => $"MsgHandler:{Kind}:{MessageTypeName}:{HandlerTypeName}";
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/Models/MessagingHandlerKind.cs b/src/Mocha/src/Mocha.Analyzers/Models/MessagingHandlerKind.cs
new file mode 100644
index 00000000000..119b6e67859
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/Models/MessagingHandlerKind.cs
@@ -0,0 +1,32 @@
+namespace Mocha.Analyzers;
+
+///
+/// Represents the kind of MessageBus handler discovered by the source generator.
+///
+public enum MessagingHandlerKind
+{
+ ///
+ /// An IEventHandler<TEvent> implementation.
+ ///
+ Event,
+
+ ///
+ /// An IEventRequestHandler<TRequest, TResponse> implementation.
+ ///
+ RequestResponse,
+
+ ///
+ /// An IEventRequestHandler<TRequest> implementation (void return).
+ ///
+ Send,
+
+ ///
+ /// An IConsumer<TMessage> implementation.
+ ///
+ Consumer,
+
+ ///
+ /// An IBatchEventHandler<TEvent> implementation.
+ ///
+ Batch
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/Models/MessagingModuleInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/MessagingModuleInfo.cs
new file mode 100644
index 00000000000..3eeaea2029d
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/Models/MessagingModuleInfo.cs
@@ -0,0 +1,12 @@
+namespace Mocha.Analyzers;
+
+///
+/// Represents the metadata for a [assembly: MessagingModule("...")] attribute
+/// discovered during source generation.
+///
+/// The module name specified in the attribute.
+public sealed record MessagingModuleInfo(string ModuleName) : SyntaxInfo
+{
+ ///
+ public override string OrderByKey => $"MsgModule:{ModuleName}";
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/Models/SagaInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/SagaInfo.cs
new file mode 100644
index 00000000000..8e776c96a4b
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/Models/SagaInfo.cs
@@ -0,0 +1,14 @@
+namespace Mocha.Analyzers;
+
+///
+/// Represents a discovered Saga<TState> subclass for source-generated registration.
+///
+/// The fully qualified type name of the saga class.
+/// The namespace containing the saga class.
+public sealed record SagaInfo(
+ string SagaTypeName,
+ string SagaNamespace) : SyntaxInfo
+{
+ ///
+ public override string OrderByKey => $"Saga:{SagaTypeName}";
+}
diff --git a/src/Mocha/src/Mocha.Analyzers/SyntaxConstants.cs b/src/Mocha/src/Mocha.Analyzers/SyntaxConstants.cs
index 58d6f41d371..0b9355f01c3 100644
--- a/src/Mocha/src/Mocha.Analyzers/SyntaxConstants.cs
+++ b/src/Mocha/src/Mocha.Analyzers/SyntaxConstants.cs
@@ -50,4 +50,49 @@ public static class SyntaxConstants
/// Gets the metadata name for the MediatorModuleAttribute class.
///
public const string MediatorModuleAttribute = "Mocha.Mediator.MediatorModuleAttribute";
+
+ ///
+ /// Gets the metadata name for the IEventHandler<TEvent> interface.
+ ///
+ public const string IEventHandler = "Mocha.IEventHandler`1";
+
+ ///
+ /// Gets the metadata name for the IEventRequestHandler<TRequest> interface (void return).
+ ///
+ public const string IEventRequestHandlerVoid = "Mocha.IEventRequestHandler`1";
+
+ ///
+ /// Gets the metadata name for the IEventRequestHandler<TRequest, TResponse> interface.
+ ///
+ public const string IEventRequestHandlerResponse = "Mocha.IEventRequestHandler`2";
+
+ ///
+ /// Gets the metadata name for the IConsumer<TMessage> interface.
+ ///
+ public const string IConsumer = "Mocha.IConsumer`1";
+
+ ///
+ /// Gets the metadata name for the IBatchEventHandler<TEvent> interface.
+ ///
+ public const string IBatchEventHandler = "Mocha.IBatchEventHandler`1";
+
+ ///
+ /// Gets the metadata name for the Saga<TState> abstract class.
+ ///
+ public const string Saga = "Mocha.Sagas.Saga`1";
+
+ ///
+ /// Gets the metadata name for the IEventRequest marker interface (void return).
+ ///
+ public const string IEventRequest = "Mocha.IEventRequest";
+
+ ///
+ /// Gets the metadata name for the IEventRequest<TResponse> interface.
+ ///
+ public const string IEventRequestOfT = "Mocha.IEventRequest`1";
+
+ ///
+ /// Gets the metadata name for the MessagingModuleAttribute class.
+ ///
+ public const string MessagingModuleAttribute = "Mocha.MessagingModuleAttribute";
}
diff --git a/src/Mocha/src/Mocha.Analyzers/Utils/HashHelper.cs b/src/Mocha/src/Mocha.Analyzers/Utils/HashHelper.cs
new file mode 100644
index 00000000000..2addb5d4f9b
--- /dev/null
+++ b/src/Mocha/src/Mocha.Analyzers/Utils/HashHelper.cs
@@ -0,0 +1,31 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Mocha.Analyzers.Utils;
+
+///
+/// Provides non-security hashing utilities for source-generator file name salting.
+///
+internal static class HashHelper
+{
+#pragma warning disable CA5351 // MD5 is used for non-security hashing (file name salting)
+ private static readonly MD5 s_md5 = MD5.Create();
+#pragma warning restore CA5351
+
+ ///
+ /// Computes a URL-safe Base64 salt from the given assembly name.
+ ///
+ public static string ComputeSalt(string assemblyName)
+ {
+ byte[] hashBytes;
+
+ lock (s_md5)
+ {
+ hashBytes = s_md5.ComputeHash(Encoding.UTF8.GetBytes(assemblyName));
+ }
+
+ var base64 = Convert.ToBase64String(hashBytes, Base64FormattingOptions.None);
+
+ return base64.Replace("+", "-").Replace("/", "_").TrimEnd('=');
+ }
+}
diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore/EntityFrameworkTransactionMiddleware.cs b/src/Mocha/src/Mocha.EntityFrameworkCore/EntityFrameworkTransactionMiddleware.cs
index 97f86c34ca5..6e47a23bb48 100644
--- a/src/Mocha/src/Mocha.EntityFrameworkCore/EntityFrameworkTransactionMiddleware.cs
+++ b/src/Mocha/src/Mocha.EntityFrameworkCore/EntityFrameworkTransactionMiddleware.cs
@@ -24,22 +24,48 @@ public async ValueTask InvokeAsync(IMediatorContext context, MediatorDelegate ne
}
var dbContext = (DbContext)context.Services.GetRequiredService(dbContextType);
+ var strategy = dbContext.Database.CreateExecutionStrategy();
- await using var transaction =
- await dbContext.Database.BeginTransactionAsync(context.CancellationToken);
-
- try
+ if (strategy.RetriesOnFailure)
{
- await next(context);
+ await strategy.ExecuteAsync(async ct =>
+ {
+ await using var transaction =
+ await dbContext.Database.BeginTransactionAsync(ct);
- await dbContext.SaveChangesAsync(context.CancellationToken);
- await transaction.CommitAsync(context.CancellationToken);
+ try
+ {
+ await next(context);
+
+ await dbContext.SaveChangesAsync(ct);
+ await transaction.CommitAsync(ct);
+ }
+ catch
+ {
+ await transaction.RollbackAsync(ct);
+
+ throw;
+ }
+ }, context.CancellationToken);
}
- catch
+ else
{
- await transaction.RollbackAsync(context.CancellationToken);
+ await using var transaction =
+ await dbContext.Database.BeginTransactionAsync(context.CancellationToken);
+
+ try
+ {
+ await next(context);
+
+ await dbContext.SaveChangesAsync(context.CancellationToken);
+ await transaction.CommitAsync(context.CancellationToken);
+ }
+ catch
+ {
+ await transaction.RollbackAsync(context.CancellationToken);
- throw;
+ throw;
+ }
}
}
diff --git a/src/Mocha/src/Mocha.EntityFrameworkCore/MessageBusHostBuilderExtensions.cs b/src/Mocha/src/Mocha.EntityFrameworkCore/MessageBusHostBuilderExtensions.cs
index dcc18e0a637..a382a4773ad 100644
--- a/src/Mocha/src/Mocha.EntityFrameworkCore/MessageBusHostBuilderExtensions.cs
+++ b/src/Mocha/src/Mocha.EntityFrameworkCore/MessageBusHostBuilderExtensions.cs
@@ -93,12 +93,31 @@ internal sealed class EntityFrameworkResilienceConsumeMiddleware(Type contextTyp
{
public async ValueTask InvokeAsync(IConsumeContext context, ConsumerDelegate next)
{
- // TODO is this too opinionated?
var dbContext = (DbContext)context.Services.GetRequiredService(contextType);
+ var strategy = dbContext.Database.CreateExecutionStrategy();
- var executionStrategy = dbContext.Database.CreateExecutionStrategy();
+ if (!strategy.RetriesOnFailure)
+ {
+ await next(context);
+ return;
+ }
+
+ var originalServices = context.Services;
- await executionStrategy.ExecuteAsync(async () => await next(context));
+ await strategy.ExecuteAsync(async () =>
+ {
+ await using var scope = originalServices.CreateAsyncScope();
+ context.Services = scope.ServiceProvider;
+
+ try
+ {
+ await next(context);
+ }
+ finally
+ {
+ context.Services = originalServices;
+ }
+ });
}
public static ConsumerMiddlewareConfiguration Create()
diff --git a/src/Mocha/src/Mocha/Abstractions/IMessageBusBuilder.cs b/src/Mocha/src/Mocha/Abstractions/IMessageBusBuilder.cs
index a98de65d3f6..d6ef05eb24a 100644
--- a/src/Mocha/src/Mocha/Abstractions/IMessageBusBuilder.cs
+++ b/src/Mocha/src/Mocha/Abstractions/IMessageBusBuilder.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel;
using Microsoft.Extensions.DependencyInjection;
using Mocha.Middlewares;
using Mocha.Sagas;
@@ -14,8 +15,18 @@ public interface IMessageBusBuilder
/// Registers a message handler that will consume messages matching its declared message type.
///
/// The handler type implementing .
+ /// Optional action to configure the consumer descriptor.
/// The builder instance for method chaining.
- IMessageBusBuilder AddHandler() where THandler : class, IHandler;
+ IMessageBusBuilder AddHandler(Action? configure = null)
+ where THandler : class, IHandler;
+
+ ///
+ /// Registers a handler using pre-built configuration from the source generator.
+ ///
+ /// The pre-built handler configuration.
+ /// The builder instance for method chaining.
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ IMessageBusBuilder AddHandlerConfiguration(MessagingHandlerConfiguration configuration);
///
/// Registers a batch event handler that collects messages and delivers them in batches.
diff --git a/src/Mocha/src/Mocha/Builder/ConsumerRegistration.cs b/src/Mocha/src/Mocha/Builder/ConsumerRegistration.cs
new file mode 100644
index 00000000000..cf6ae756adb
--- /dev/null
+++ b/src/Mocha/src/Mocha/Builder/ConsumerRegistration.cs
@@ -0,0 +1,25 @@
+namespace Mocha;
+
+///
+/// Captures a deferred consumer registration with its factory and stacked configuration.
+///
+internal sealed class ConsumerRegistration
+{
+ ///
+ /// The handler type that identifies this registration. Used for linear scan lookups.
+ ///
+ public required Type HandlerType { get; init; }
+
+ ///
+ /// The composed consumer descriptor configuration action.
+ /// Multiple AddHandler calls stack onto this via closure composition.
+ /// Null when no configuration has been applied.
+ ///
+ public Action? Configure { get; set; }
+
+ ///
+ /// Factory that creates the consumer instance.
+ /// First registration wins - this is never replaced after initial creation.
+ ///
+ public required Func?, Consumer> Factory { get; init; }
+}
diff --git a/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs b/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs
index bb1949a5982..3ff96bcc89a 100644
--- a/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs
+++ b/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
+using System.ComponentModel;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -19,9 +20,9 @@ public partial class MessageBusBuilder : IMessageBusBuilder
private readonly Dictionary> _messageDescriptors = [];
- private readonly List _consumers = [];
+ private readonly List _consumerRegistrations = [];
- private readonly List _sagas = [];
+ private readonly List _sagaRegistrations = [];
private readonly List _transports = [];
@@ -65,72 +66,106 @@ public IMessageBusBuilder ConfigureFeature(Action configure)
}
///
- public IMessageBusBuilder AddHandler() where THandler : class, IHandler
- {
- AddHandler(static _ => { });
-
- return this;
- }
-
- ///
- /// Registers a message handler with additional consumer configuration.
- ///
- ///
- /// The handler type implementing .
- ///
- ///
- /// An action to configure the consumer descriptor for this handler.
- ///
- /// The builder instance for method chaining.
- public IMessageBusBuilder AddHandler(Action configure)
+ public IMessageBusBuilder AddHandler(Action? configure = null)
where THandler : class, IHandler
{
- Type consumerType;
+ var handlerType = typeof(THandler);
+ var existing = _consumerRegistrations.Find(r => r.HandlerType == handlerType);
- // Batch handlers are detected automatically and routed to BatchConsumer with default options.
- if (typeof(IBatchEventHandler).IsAssignableFrom(typeof(THandler)) && THandler.EventType is not null)
+ if (existing is not null)
{
- var batchConsumerType = typeof(BatchConsumer<,>).MakeGenericType(typeof(THandler), THandler.EventType);
- var batchConsumer = (Consumer)Activator.CreateInstance(batchConsumerType, new BatchOptions(), configure)!;
- _consumers.Add(batchConsumer);
+ if (configure is not null)
+ {
+ var inner = existing.Configure;
+ existing.Configure = inner is not null
+ ? d =>
+ {
+ inner(d);
+ configure(d);
+ }
+ : configure;
+ }
+
return this;
}
- // Handlers are mapped to consumer adapters so routing + middleware can treat them uniformly:
- // IConsumer -> ConsumerAdapter, request+response -> RequestConsumer,
- // request-only -> SendConsumer, event-only -> SubscribeConsumer.
- if (typeof(IConsumer).IsAssignableFrom(typeof(THandler)) && THandler.EventType is not null)
+ // New registration - detect kind and create factory
+ Func?, Consumer> factory;
+
+ if (typeof(IBatchEventHandler).IsAssignableFrom(typeof(THandler)) && THandler.EventType is not null)
+ {
+ factory = static c =>
+ {
+ var consumerType = typeof(BatchConsumer<,>).MakeGenericType(typeof(THandler), THandler.EventType);
+ return (Consumer)Activator.CreateInstance(consumerType, c)!;
+ };
+ }
+ else if (typeof(IConsumer).IsAssignableFrom(typeof(THandler)) && THandler.EventType is not null)
{
- consumerType = typeof(ConsumerAdapter<,>).MakeGenericType(typeof(THandler), THandler.EventType);
+ factory = static c =>
+ {
+ var consumerType = typeof(ConsumerAdapter<,>).MakeGenericType(typeof(THandler), THandler.EventType);
+ return (Consumer)Activator.CreateInstance(consumerType, c)!;
+ };
}
else if (THandler.RequestType is not null && THandler.ResponseType is not null)
{
- consumerType = typeof(RequestConsumer<,,>).MakeGenericType(
- typeof(THandler),
- THandler.RequestType,
- THandler.ResponseType);
+ factory = static c =>
+ {
+ var consumerType = typeof(RequestConsumer<,,>).MakeGenericType(
+ typeof(THandler),
+ THandler.RequestType,
+ THandler.ResponseType);
+
+ return (Consumer)Activator.CreateInstance(consumerType, c)!;
+ };
}
else if (THandler.RequestType is not null)
{
- consumerType = typeof(SendConsumer<,>).MakeGenericType(typeof(THandler), THandler.RequestType);
+ factory = static c =>
+ {
+ var consumerType = typeof(SendConsumer<,>).MakeGenericType(typeof(THandler), THandler.RequestType);
+ return (Consumer)Activator.CreateInstance(consumerType, c)!;
+ };
}
else if (THandler.EventType is not null)
{
- consumerType = typeof(SubscribeConsumer<,>).MakeGenericType(typeof(THandler), THandler.EventType);
+ factory = static c =>
+ {
+ var consumerType = typeof(SubscribeConsumer<,>).MakeGenericType(typeof(THandler), THandler.EventType);
+ return (Consumer)Activator.CreateInstance(consumerType, c)!;
+ };
}
else
{
throw ThrowHelper.InvalidHandlerType();
}
- var consumer = Activator.CreateInstance(consumerType, configure) as Consumer;
+ _consumerRegistrations.Add(new ConsumerRegistration
+ {
+ HandlerType = handlerType, Configure = configure, Factory = factory
+ });
+
+ return this;
+ }
+
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public IMessageBusBuilder AddHandlerConfiguration(MessagingHandlerConfiguration configuration)
+ {
+ var handlerType = configuration.HandlerType;
+ var existing = _consumerRegistrations.Find(r => r.HandlerType == handlerType);
- if (consumer is null)
+ if (existing is not null)
{
- throw ThrowHelper.FailedToCreateConsumer(consumerType);
+ // Factory first-wins, nothing else to stack from SG path
+ return this;
}
- _consumers.Add(consumer);
+ _consumerRegistrations.Add(new ConsumerRegistration
+ {
+ HandlerType = handlerType, Configure = null, Factory = configuration.Factory
+ });
return this;
}
@@ -146,11 +181,8 @@ public IMessageBusBuilder AddBatchHandler(Action? config
{
var options = new BatchOptions();
configure?.Invoke(options);
- options.Validate();
- var consumerType = typeof(BatchConsumer<,>).MakeGenericType(typeof(THandler), THandler.EventType!);
- var consumer = (Consumer)Activator.CreateInstance(consumerType, options, null)!;
- _consumers.Add(consumer);
+ AddHandler(d => d.Extend().Configuration.Features.Set(options));
return this;
}
@@ -158,8 +190,15 @@ public IMessageBusBuilder AddBatchHandler(Action? config
///
public IMessageBusBuilder AddSaga() where TSaga : Saga, new()
{
- var saga = new TSaga();
- _sagas.Add(saga);
+ var sagaType = typeof(TSaga);
+
+ if (_sagaRegistrations.Exists(r => r.SagaType == sagaType))
+ {
+ return this;
+ }
+
+ _sagaRegistrations.Add(new SagaRegistration { SagaType = sagaType, Factory = static () => new TSaga() });
+
return this;
}
@@ -320,15 +359,29 @@ public MessagingRuntime Build(IServiceProvider applicationServices)
AddCoreServices(servicesCollection, applicationServices);
var responseManager = applicationServices.GetRequiredService();
- // Always register the internal reply consumer so request promises can be completed.
- _consumers.Add(new ReplyConsumer(responseManager));
- foreach (var saga in _sagas)
+ // Materialize consumers from registrations
+ var consumerList = new List();
+
+ // Infrastructure consumer
+ consumerList.Add(new ReplyConsumer(responseManager));
+
+ // Handler consumers from registrations
+ foreach (var reg in _consumerRegistrations)
+ {
+ consumerList.Add(reg.Factory(reg.Configure));
+ }
+
+ // Saga consumers
+ var sagas = new List();
+ foreach (var reg in _sagaRegistrations)
{
- _consumers.Add(saga.Consumer);
+ var saga = reg.Factory();
+ sagas.Add(saga);
+ consumerList.Add(saga.Consumer);
}
- var consumers = _consumers.ToImmutableArray();
+ var consumers = consumerList.ToImmutableArray();
var transports = _transports.ToImmutableArray();
servicesCollection.AddSingleton(new RegisteredConsumers(consumers));
@@ -394,7 +447,7 @@ public MessagingRuntime Build(IServiceProvider applicationServices)
}
// sagas have to be initialized before consumers, because of the saga consumer
- foreach (var saga in _sagas)
+ foreach (var saga in sagas)
{
saga.Initialize(setupContext);
}
@@ -416,6 +469,7 @@ public MessagingRuntime Build(IServiceProvider applicationServices)
// but no endpoint.
foreach (var route in router.OutboundRoutes)
{
+ // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (route.Endpoint is null && route.Destination is not null)
{
var endpoint = setupContext.Endpoints.GetOrCreate(setupContext, route.Destination);
diff --git a/src/Mocha/src/Mocha/Builder/SagaRegistration.cs b/src/Mocha/src/Mocha/Builder/SagaRegistration.cs
new file mode 100644
index 00000000000..71e420fb5d2
--- /dev/null
+++ b/src/Mocha/src/Mocha/Builder/SagaRegistration.cs
@@ -0,0 +1,19 @@
+using Mocha.Sagas;
+
+namespace Mocha;
+
+///
+/// Captures a deferred saga registration for deduplication.
+///
+internal sealed class SagaRegistration
+{
+ ///
+ /// The saga type that identifies this registration.
+ ///
+ public required Type SagaType { get; init; }
+
+ ///
+ /// Factory that creates the saga instance.
+ ///
+ public required Func Factory { get; init; }
+}
diff --git a/src/Mocha/src/Mocha/Configuration/MessagingHandlerConfiguration.cs b/src/Mocha/src/Mocha/Configuration/MessagingHandlerConfiguration.cs
new file mode 100644
index 00000000000..262bd0b3de0
--- /dev/null
+++ b/src/Mocha/src/Mocha/Configuration/MessagingHandlerConfiguration.cs
@@ -0,0 +1,20 @@
+using System.ComponentModel;
+
+namespace Mocha;
+
+///
+/// Pre-built handler configuration emitted by the source generator.
+///
+[EditorBrowsable(EditorBrowsableState.Never)]
+public sealed class MessagingHandlerConfiguration
+{
+ ///
+ /// The concrete handler implementation type.
+ ///
+ public required Type HandlerType { get; init; }
+
+ ///
+ /// Factory that creates the consumer instance for this handler.
+ ///
+ public required Func?, Consumer> Factory { get; init; }
+}
diff --git a/src/Mocha/src/Mocha/ConsumerFactory.cs b/src/Mocha/src/Mocha/ConsumerFactory.cs
new file mode 100644
index 00000000000..565e5a2205d
--- /dev/null
+++ b/src/Mocha/src/Mocha/ConsumerFactory.cs
@@ -0,0 +1,33 @@
+using System.ComponentModel;
+
+namespace Mocha;
+
+///
+/// Provides factory methods for creating consumer instances.
+/// These methods bridge internal consumer types for source-generator use.
+///
+[EditorBrowsable(EditorBrowsableState.Never)]
+public static class ConsumerFactory
+{
+ public static Func?, Consumer> Subscribe()
+ where THandler : IEventHandler
+ => static c => new SubscribeConsumer(c ?? (static _ => { }));
+
+ public static Func?, Consumer> Send()
+ where THandler : IEventRequestHandler
+ where TRequest : notnull
+ => static c => new SendConsumer(c ?? (static _ => { }));
+
+ public static Func?, Consumer> Request()
+ where THandler : IEventRequestHandler
+ where TRequest : IEventRequest
+ => static c => new RequestConsumer(c ?? (static _ => { }));
+
+ public static Func?, Consumer> Consume()
+ where TConsumer : IConsumer
+ => static c => new ConsumerAdapter(c ?? (static _ => { }));
+
+ public static Func?, Consumer> Batch()
+ where THandler : IBatchEventHandler
+ => static c => new BatchConsumer(c ?? (static _ => { }));
+}
diff --git a/src/Mocha/src/Mocha/Consumers/Implementations/BatchConsumer.cs b/src/Mocha/src/Mocha/Consumers/Implementations/BatchConsumer.cs
index 0257c554c5d..017e0505f0a 100644
--- a/src/Mocha/src/Mocha/Consumers/Implementations/BatchConsumer.cs
+++ b/src/Mocha/src/Mocha/Consumers/Implementations/BatchConsumer.cs
@@ -14,7 +14,6 @@ namespace Mocha;
/// without any modifications to the middleware chain.
///
internal sealed class BatchConsumer(
- BatchOptions options,
Action? configure = null) : Consumer where THandler : IBatchEventHandler
{
private BatchCollector _collector = null!;
@@ -37,6 +36,9 @@ protected override void OnAfterInitialize(IMessagingSetupContext context)
base.OnAfterInitialize(context);
SetIdentity(typeof(THandler));
+ var options = Configuration!.Features.Get() ?? new BatchOptions();
+ options.Validate();
+
_applicationServices = context.Services.GetRequiredService().ServiceProvider;
_logger = context.Services.GetRequiredService>>();
diff --git a/src/Mocha/src/Mocha/Extensions/IMessageBusHostBuilderExtensions.cs b/src/Mocha/src/Mocha/Extensions/IMessageBusHostBuilderExtensions.cs
index 1aa90a0956c..62045a64ad4 100644
--- a/src/Mocha/src/Mocha/Extensions/IMessageBusHostBuilderExtensions.cs
+++ b/src/Mocha/src/Mocha/Extensions/IMessageBusHostBuilderExtensions.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -28,6 +29,25 @@ public static IMessageBusHostBuilder AddEventHandler<
return builder;
}
+ ///
+ /// Registers a handler using pre-built configuration from the source generator.
+ ///
+ /// The handler type.
+ /// The host builder.
+ /// The pre-built handler configuration.
+ /// The builder for method chaining.
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public static IMessageBusHostBuilder AddHandlerConfiguration<
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(
+ this IMessageBusHostBuilder builder,
+ MessagingHandlerConfiguration configuration)
+ where THandler : class, IHandler
+ {
+ builder.Services.TryAddScoped();
+ builder.ConfigureMessageBus(h => h.AddHandlerConfiguration(configuration));
+ return builder;
+ }
+
///
/// Registers a batch event handler with the message bus and adds it to the service collection.
///
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingBatchHandlerGeneratorTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingBatchHandlerGeneratorTests.cs
new file mode 100644
index 00000000000..221c9c249d7
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingBatchHandlerGeneratorTests.cs
@@ -0,0 +1,25 @@
+namespace Mocha.Analyzers.Tests;
+
+public class MessagingBatchHandlerGeneratorTests
+{
+ [Fact]
+ public async Task Generate_BatchEventHandler_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public record BulkOrderEvent(int[] OrderIds);
+
+ public class BulkOrderHandler : IBatchEventHandler
+ {
+ public ValueTask HandleAsync(IMessageBatch batch, CancellationToken cancellationToken)
+ => default;
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+}
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingConsumerGeneratorTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingConsumerGeneratorTests.cs
new file mode 100644
index 00000000000..1b6ab573d93
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingConsumerGeneratorTests.cs
@@ -0,0 +1,25 @@
+namespace Mocha.Analyzers.Tests;
+
+public class MessagingConsumerGeneratorTests
+{
+ [Fact]
+ public async Task Generate_SingleConsumer_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public record AuditLogMessage(string Action);
+
+ public class AuditLogConsumer : IConsumer
+ {
+ public ValueTask ConsumeAsync(IConsumeContext context)
+ => default;
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+}
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingDiagnosticTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingDiagnosticTests.cs
new file mode 100644
index 00000000000..eb03f706745
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingDiagnosticTests.cs
@@ -0,0 +1,92 @@
+namespace Mocha.Analyzers.Tests;
+
+public class MessagingDiagnosticTests
+{
+ [Fact]
+ public async Task MO0011_DuplicateRequestHandler_ReportsError()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public record GetOrderRequest(int OrderId) : IEventRequest;
+
+ public class GetOrderHandlerA : IEventRequestHandler
+ {
+ public ValueTask HandleAsync(GetOrderRequest request, CancellationToken cancellationToken)
+ => new("a");
+ }
+
+ public class GetOrderHandlerB : IEventRequestHandler
+ {
+ public ValueTask HandleAsync(GetOrderRequest request, CancellationToken cancellationToken)
+ => new("b");
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+
+ [Fact]
+ public async Task MO0013_AbstractMessagingHandler_ReportsWarning()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public record OrderPlacedEvent(int OrderId);
+
+ public abstract class BaseOrderHandler : IEventHandler
+ {
+ public abstract ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken);
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+
+ [Fact]
+ public async Task MO0012_OpenGenericHandler_ReportsInfo()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public class GenericHandler : IEventHandler
+ {
+ public ValueTask HandleAsync(T message, CancellationToken cancellationToken)
+ => default;
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+
+ [Fact]
+ public async Task MO0014_SagaWithoutParameterlessConstructor_ReportsError()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha.Sagas;
+
+ namespace TestApp;
+
+ public class OrderState : SagaStateBase;
+
+ public class BadSaga : Saga
+ {
+ public BadSaga(string name) { }
+
+ protected override void Configure(ISagaDescriptor descriptor) { }
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+}
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingEventHandlerGeneratorTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingEventHandlerGeneratorTests.cs
new file mode 100644
index 00000000000..783f1df1e16
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingEventHandlerGeneratorTests.cs
@@ -0,0 +1,52 @@
+namespace Mocha.Analyzers.Tests;
+
+public class MessagingEventHandlerGeneratorTests
+{
+ [Fact]
+ public async Task Generate_SingleEventHandler_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public record OrderPlacedEvent(int OrderId);
+
+ public class OrderPlacedHandler : IEventHandler
+ {
+ public ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken)
+ => default;
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+
+ [Fact]
+ public async Task Generate_MultipleEventHandlers_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public record OrderPlacedEvent(int OrderId);
+
+ public class OrderPlacedHandler : IEventHandler
+ {
+ public ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken)
+ => default;
+ }
+
+ public class OrderPlacedAuditHandler : IEventHandler
+ {
+ public ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken)
+ => default;
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+}
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingMixedHandlerTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingMixedHandlerTests.cs
new file mode 100644
index 00000000000..1b2ef4485a2
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingMixedHandlerTests.cs
@@ -0,0 +1,57 @@
+namespace Mocha.Analyzers.Tests;
+
+public class MessagingMixedHandlerTests
+{
+ [Fact]
+ public async Task Generate_AllHandlerKinds_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+ using Mocha.Sagas;
+
+ namespace TestApp;
+
+ // Event
+ public record OrderPlacedEvent(int OrderId);
+ public class OrderPlacedHandler : IEventHandler
+ {
+ public ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken)
+ => default;
+ }
+
+ // Request-Response
+ public record GetOrderStatusRequest(int OrderId) : IEventRequest;
+ public class GetOrderStatusHandler : IEventRequestHandler
+ {
+ public ValueTask HandleAsync(GetOrderStatusRequest request, CancellationToken cancellationToken)
+ => new("ok");
+ }
+
+ // Consumer
+ public record AuditLogMessage(string Action);
+ public class AuditLogConsumer : IConsumer
+ {
+ public ValueTask ConsumeAsync(IConsumeContext context)
+ => default;
+ }
+
+ // Batch
+ public record BulkOrderEvent(int[] OrderIds);
+ public class BulkOrderHandler : IBatchEventHandler
+ {
+ public ValueTask HandleAsync(IMessageBatch batch, CancellationToken cancellationToken)
+ => default;
+ }
+
+ // Saga
+ public class OrderState : SagaStateBase;
+ public class OrderFulfillmentSaga : Saga
+ {
+ protected override void Configure(ISagaDescriptor descriptor) { }
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+}
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingModuleTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingModuleTests.cs
new file mode 100644
index 00000000000..792cbb3b4b3
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingModuleTests.cs
@@ -0,0 +1,48 @@
+namespace Mocha.Analyzers.Tests;
+
+public class MessagingModuleTests
+{
+ [Fact]
+ public async Task Generate_ExplicitModuleName_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ [assembly: MessagingModule("OrderService")]
+
+ namespace TestApp;
+
+ public record OrderPlacedEvent(int OrderId);
+
+ public class OrderPlacedHandler : IEventHandler
+ {
+ public ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken)
+ => default;
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+
+ [Fact]
+ public async Task Generate_DefaultModuleName_UsesAssemblyName_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public record OrderPlacedEvent(int OrderId);
+
+ public class OrderPlacedHandler : IEventHandler
+ {
+ public ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken)
+ => default;
+ }
+ """
+ ], assemblyName: "My.OrderService.Api").MatchMarkdownAsync();
+ }
+}
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingMultiInterfaceTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingMultiInterfaceTests.cs
new file mode 100644
index 00000000000..4da450060da
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingMultiInterfaceTests.cs
@@ -0,0 +1,30 @@
+namespace Mocha.Analyzers.Tests;
+
+public class MessagingMultiInterfaceTests
+{
+ [Fact]
+ public async Task Generate_HandlerWithBatchAndEvent_RegistersAsBatch_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public record OrderEvent(int OrderId);
+
+ // Implements both IBatchEventHandler and IEventHandler
+ // Should register as Batch (higher priority)
+ public class OrderBatchAndEventHandler : IBatchEventHandler, IEventHandler
+ {
+ public ValueTask HandleAsync(IMessageBatch batch, CancellationToken cancellationToken)
+ => default;
+
+ public ValueTask HandleAsync(OrderEvent message, CancellationToken cancellationToken)
+ => default;
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+}
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingRequestHandlerGeneratorTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingRequestHandlerGeneratorTests.cs
new file mode 100644
index 00000000000..c3c944109da
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingRequestHandlerGeneratorTests.cs
@@ -0,0 +1,46 @@
+namespace Mocha.Analyzers.Tests;
+
+public class MessagingRequestHandlerGeneratorTests
+{
+ [Fact]
+ public async Task Generate_RequestResponseHandler_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public record GetOrderStatusRequest(int OrderId) : IEventRequest;
+
+ public class GetOrderStatusHandler : IEventRequestHandler
+ {
+ public ValueTask HandleAsync(GetOrderStatusRequest request, CancellationToken cancellationToken)
+ => new("shipped");
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+
+ [Fact]
+ public async Task Generate_SendHandler_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha;
+
+ namespace TestApp;
+
+ public record ProcessOrderRequest(int OrderId);
+
+ public class ProcessOrderHandler : IEventRequestHandler
+ {
+ public ValueTask HandleAsync(ProcessOrderRequest request, CancellationToken cancellationToken)
+ => default;
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+}
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingSagaGeneratorTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingSagaGeneratorTests.cs
new file mode 100644
index 00000000000..1517d294315
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingSagaGeneratorTests.cs
@@ -0,0 +1,26 @@
+namespace Mocha.Analyzers.Tests;
+
+public class MessagingSagaGeneratorTests
+{
+ [Fact]
+ public async Task Generate_SimpleSaga_MatchesSnapshot()
+ {
+ await MessagingTestHelper.GetGeneratedSourceSnapshot(
+ [
+ """
+ using Mocha.Sagas;
+
+ namespace TestApp;
+
+ public class OrderState : SagaStateBase;
+
+ public class OrderFulfillmentSaga : Saga
+ {
+ protected override void Configure(ISagaDescriptor descriptor)
+ {
+ }
+ }
+ """
+ ]).MatchMarkdownAsync();
+ }
+}
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingTestHelper.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingTestHelper.cs
new file mode 100644
index 00000000000..38b2637b438
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingTestHelper.cs
@@ -0,0 +1,165 @@
+using System.Collections.Immutable;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using Basic.Reference.Assemblies;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using CookieCrumble;
+using Microsoft.Extensions.DependencyInjection;
+using Mocha.Analyzers;
+
+namespace Mocha.Analyzers.Tests;
+
+internal static class MessagingTestHelper
+{
+ private static readonly HashSet s_ignoreCodes =
+ ["CS8652", "CS8632", "CS5001", "CS8019", "CS0518", "CS0012"];
+
+ public static Snapshot GetGeneratedSourceSnapshot(
+ string[] sourceTexts,
+ string? assemblyName = "Tests")
+ {
+ IEnumerable references =
+ [
+#if NET8_0
+ .. Net80.References.All,
+#elif NET9_0
+ .. Net90.References.All,
+#elif NET10_0
+ .. Net100.References.All,
+#endif
+ // Mocha.Abstractions (IEventHandler, IEventRequestHandler, IEventRequest, MessagingModuleAttribute)
+ MetadataReference.CreateFromFile(typeof(IEventHandler).Assembly.Location),
+
+ // Mocha (IConsumer, IBatchEventHandler, Saga, SagaStateBase)
+ MetadataReference.CreateFromFile(typeof(IConsumer).Assembly.Location),
+
+ // Microsoft.Extensions.DependencyInjection.Abstractions
+ MetadataReference.CreateFromFile(typeof(IServiceCollection).Assembly.Location),
+
+ // System.Runtime.CompilerServices.Unsafe
+ MetadataReference.CreateFromFile(typeof(Unsafe).Assembly.Location),
+
+ // System.Runtime from the actual runtime (needed for predefined type resolution
+ // so that assembly-level attribute constructor arguments can be bound)
+ MetadataReference.CreateFromFile(
+ Path.Combine(
+ Path.GetDirectoryName(typeof(object).Assembly.Location)!,
+ "System.Runtime.dll"))
+ ];
+
+ var compilation = CSharpCompilation.Create(
+ assemblyName: assemblyName,
+ syntaxTrees: sourceTexts.Select(s => CSharpSyntaxTree.ParseText(s)),
+ references,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ var generator = new MessagingGenerator();
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+ driver = driver.RunGenerators(compilation);
+
+ var snapshot = new Snapshot();
+
+ foreach (var result in driver.GetRunResult().Results)
+ {
+ var sources = result.GeneratedSources.OrderBy(s => s.HintName);
+ foreach (var source in sources)
+ {
+ snapshot.Add(source.SourceText.ToString(), source.HintName, MarkdownLanguages.CSharp);
+ }
+
+ if (result.Diagnostics.Any())
+ {
+ AddDiagnosticsToSnapshot(snapshot, result.Diagnostics, "Generator Diagnostics");
+ }
+ }
+
+ return snapshot;
+ }
+
+ private static void AddDiagnosticsToSnapshot(
+ Snapshot snapshot,
+ ImmutableArray diagnostics,
+ string title)
+ {
+ var hasDiagnostics = false;
+ using var stream = new MemoryStream();
+ using var jsonWriter = new Utf8JsonWriter(
+ stream,
+ new JsonWriterOptions
+ {
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ Indented = true
+ });
+
+ jsonWriter.WriteStartArray();
+
+ foreach (var diagnostic in diagnostics
+ .OrderBy(d => d.Location.SourceTree?.FilePath)
+ .ThenBy(d => d.Location.GetLineSpan().StartLinePosition.Line)
+ .ThenBy(d => d.Location.GetLineSpan().StartLinePosition.Character))
+ {
+ if (s_ignoreCodes.Contains(diagnostic.Id))
+ {
+ continue;
+ }
+
+ hasDiagnostics = true;
+
+ jsonWriter.WriteStartObject();
+ jsonWriter.WriteString(nameof(diagnostic.Id), diagnostic.Id);
+
+ var descriptor = diagnostic.Descriptor;
+
+ jsonWriter.WriteString(nameof(descriptor.Title), descriptor.Title.ToString());
+ jsonWriter.WriteString(nameof(diagnostic.Severity), diagnostic.Severity.ToString());
+ jsonWriter.WriteNumber(nameof(diagnostic.WarningLevel), diagnostic.WarningLevel);
+
+ jsonWriter.WriteString(
+ nameof(diagnostic.Location),
+ diagnostic.Location.GetMappedLineSpan().ToString());
+
+ var description = descriptor.Description.ToString();
+ if (!string.IsNullOrWhiteSpace(description))
+ {
+ jsonWriter.WriteString(nameof(descriptor.Description), description);
+ }
+
+ var help = descriptor.HelpLinkUri;
+ if (!string.IsNullOrWhiteSpace(help))
+ {
+ jsonWriter.WriteString(nameof(descriptor.HelpLinkUri), help);
+ }
+
+ jsonWriter.WriteString(
+ nameof(descriptor.MessageFormat),
+ descriptor.MessageFormat.ToString());
+
+ jsonWriter.WriteString("Message", diagnostic.GetMessage());
+ jsonWriter.WriteString(nameof(descriptor.Category), descriptor.Category);
+
+ jsonWriter.WritePropertyName(nameof(descriptor.CustomTags));
+
+ jsonWriter.WriteStartArray();
+
+ foreach (var tag in descriptor.CustomTags)
+ {
+ jsonWriter.WriteStringValue(tag);
+ }
+
+ jsonWriter.WriteEndArray();
+
+ jsonWriter.WriteEndObject();
+ }
+
+ jsonWriter.WriteEndArray();
+ jsonWriter.Flush();
+
+ if (hasDiagnostics)
+ {
+ snapshot.Add(Encoding.UTF8.GetString(stream.ToArray()), title, MarkdownLanguages.Json);
+ }
+ }
+}
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/Mocha.Analyzers.Tests.csproj b/src/Mocha/test/Mocha.Analyzers.Tests/Mocha.Analyzers.Tests.csproj
index d454ff3e5a5..78a3d40380c 100644
--- a/src/Mocha/test/Mocha.Analyzers.Tests/Mocha.Analyzers.Tests.csproj
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/Mocha.Analyzers.Tests.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingBatchHandlerGeneratorTests.Generate_BatchEventHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingBatchHandlerGeneratorTests.Generate_BatchEventHandler_MatchesSnapshot.md
new file mode 100644
index 00000000000..d4efcff8bd3
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingBatchHandlerGeneratorTests.Generate_BatchEventHandler_MatchesSnapshot.md
@@ -0,0 +1,31 @@
+# Generate_BatchEventHandler_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class TestsMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddTests(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Batch Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.BulkOrderHandler),
+ Factory = global::Mocha.ConsumerFactory.Batch()
+ });
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingConsumerGeneratorTests.Generate_SingleConsumer_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingConsumerGeneratorTests.Generate_SingleConsumer_MatchesSnapshot.md
new file mode 100644
index 00000000000..993e7a06d99
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingConsumerGeneratorTests.Generate_SingleConsumer_MatchesSnapshot.md
@@ -0,0 +1,31 @@
+# Generate_SingleConsumer_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class TestsMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddTests(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Consumers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.AuditLogConsumer),
+ Factory = global::Mocha.ConsumerFactory.Consume()
+ });
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0011_DuplicateRequestHandler_ReportsError.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0011_DuplicateRequestHandler_ReportsError.md
new file mode 100644
index 00000000000..5f2a077c15a
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0011_DuplicateRequestHandler_ReportsError.md
@@ -0,0 +1,57 @@
+# MO0011_DuplicateRequestHandler_ReportsError
+
+## TestsMessageBusBuilderExtensions.kHkt5Slhw0EY-Xbr5K86dQ.g.cs
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class TestsMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddTests(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Request Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.GetOrderHandlerA),
+ Factory = global::Mocha.ConsumerFactory.Request()
+ });
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.GetOrderHandlerB),
+ Factory = global::Mocha.ConsumerFactory.Request()
+ });
+
+ return builder;
+ }
+ }
+}
+
+```
+
+## Generator Diagnostics
+
+```json
+[
+ {
+ "Id": "MO0011",
+ "Title": "Duplicate handler for request type",
+ "Severity": "Error",
+ "WarningLevel": 0,
+ "Location": ": (6,13)-(6,29)",
+ "MessageFormat": "Request type '{0}' has multiple handlers: {1}",
+ "Message": "Request type 'global::TestApp.GetOrderRequest' has multiple handlers: global::TestApp.GetOrderHandlerA, global::TestApp.GetOrderHandlerB",
+ "Category": "Messaging",
+ "CustomTags": []
+ }
+]
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0012_OpenGenericHandler_ReportsInfo.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0012_OpenGenericHandler_ReportsInfo.md
new file mode 100644
index 00000000000..2a9cbe4aae3
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0012_OpenGenericHandler_ReportsInfo.md
@@ -0,0 +1,17 @@
+# MO0012_OpenGenericHandler_ReportsInfo
+
+```json
+[
+ {
+ "Id": "MO0012",
+ "Title": "Open generic messaging handler cannot be auto-registered",
+ "Severity": "Info",
+ "WarningLevel": 1,
+ "Location": ": (4,13)-(4,27)",
+ "MessageFormat": "Handler '{0}' is an open generic and cannot be auto-registered",
+ "Message": "Handler 'global::TestApp.GenericHandler' is an open generic and cannot be auto-registered",
+ "Category": "Messaging",
+ "CustomTags": []
+ }
+]
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0013_AbstractMessagingHandler_ReportsWarning.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0013_AbstractMessagingHandler_ReportsWarning.md
new file mode 100644
index 00000000000..3e04001e49a
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0013_AbstractMessagingHandler_ReportsWarning.md
@@ -0,0 +1,17 @@
+# MO0013_AbstractMessagingHandler_ReportsWarning
+
+```json
+[
+ {
+ "Id": "MO0013",
+ "Title": "Messaging handler is abstract",
+ "Severity": "Warning",
+ "WarningLevel": 1,
+ "Location": ": (6,22)-(6,38)",
+ "MessageFormat": "Handler '{0}' is abstract and will not be registered",
+ "Message": "Handler 'global::TestApp.BaseOrderHandler' is abstract and will not be registered",
+ "Category": "Messaging",
+ "CustomTags": []
+ }
+]
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0014_SagaWithoutParameterlessConstructor_ReportsError.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0014_SagaWithoutParameterlessConstructor_ReportsError.md
new file mode 100644
index 00000000000..4e90c2f2394
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0014_SagaWithoutParameterlessConstructor_ReportsError.md
@@ -0,0 +1,17 @@
+# MO0014_SagaWithoutParameterlessConstructor_ReportsError
+
+```json
+[
+ {
+ "Id": "MO0014",
+ "Title": "Saga must have a public parameterless constructor",
+ "Severity": "Error",
+ "WarningLevel": 0,
+ "Location": ": (6,13)-(6,20)",
+ "MessageFormat": "Saga '{0}' must have a public parameterless constructor",
+ "Message": "Saga 'global::TestApp.BadSaga' must have a public parameterless constructor",
+ "Category": "Messaging",
+ "CustomTags": []
+ }
+]
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_MultipleEventHandlers_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_MultipleEventHandlers_MatchesSnapshot.md
new file mode 100644
index 00000000000..8409c2d5b30
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_MultipleEventHandlers_MatchesSnapshot.md
@@ -0,0 +1,37 @@
+# Generate_MultipleEventHandlers_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class TestsMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddTests(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Event Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.OrderPlacedAuditHandler),
+ Factory = global::Mocha.ConsumerFactory.Subscribe()
+ });
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.OrderPlacedHandler),
+ Factory = global::Mocha.ConsumerFactory.Subscribe()
+ });
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_SingleEventHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_SingleEventHandler_MatchesSnapshot.md
new file mode 100644
index 00000000000..439c0cea178
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_SingleEventHandler_MatchesSnapshot.md
@@ -0,0 +1,31 @@
+# Generate_SingleEventHandler_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class TestsMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddTests(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Event Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.OrderPlacedHandler),
+ Factory = global::Mocha.ConsumerFactory.Subscribe()
+ });
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMixedHandlerTests.Generate_AllHandlerKinds_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMixedHandlerTests.Generate_AllHandlerKinds_MatchesSnapshot.md
new file mode 100644
index 00000000000..00d25838b81
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMixedHandlerTests.Generate_AllHandlerKinds_MatchesSnapshot.md
@@ -0,0 +1,59 @@
+# Generate_AllHandlerKinds_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class TestsMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddTests(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Batch Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.BulkOrderHandler),
+ Factory = global::Mocha.ConsumerFactory.Batch()
+ });
+
+ // --- Consumers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.AuditLogConsumer),
+ Factory = global::Mocha.ConsumerFactory.Consume()
+ });
+
+ // --- Request Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.GetOrderStatusHandler),
+ Factory = global::Mocha.ConsumerFactory.Request()
+ });
+
+ // --- Event Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.OrderPlacedHandler),
+ Factory = global::Mocha.ConsumerFactory.Subscribe()
+ });
+
+ // --- Sagas ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddSaga<
+ global::TestApp.OrderFulfillmentSaga>(builder);
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_DefaultModuleName_UsesAssemblyName_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_DefaultModuleName_UsesAssemblyName_MatchesSnapshot.md
new file mode 100644
index 00000000000..64c064de4d6
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_DefaultModuleName_UsesAssemblyName_MatchesSnapshot.md
@@ -0,0 +1,31 @@
+# Generate_DefaultModuleName_UsesAssemblyName_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class ApiMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddApi(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Event Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.OrderPlacedHandler),
+ Factory = global::Mocha.ConsumerFactory.Subscribe()
+ });
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_ExplicitModuleName_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_ExplicitModuleName_MatchesSnapshot.md
new file mode 100644
index 00000000000..30d6d2e12dc
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_ExplicitModuleName_MatchesSnapshot.md
@@ -0,0 +1,31 @@
+# Generate_ExplicitModuleName_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class OrderServiceMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddOrderService(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Event Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.OrderPlacedHandler),
+ Factory = global::Mocha.ConsumerFactory.Subscribe()
+ });
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMultiInterfaceTests.Generate_HandlerWithBatchAndEvent_RegistersAsBatch_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMultiInterfaceTests.Generate_HandlerWithBatchAndEvent_RegistersAsBatch_MatchesSnapshot.md
new file mode 100644
index 00000000000..2e6e525c6d1
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMultiInterfaceTests.Generate_HandlerWithBatchAndEvent_RegistersAsBatch_MatchesSnapshot.md
@@ -0,0 +1,31 @@
+# Generate_HandlerWithBatchAndEvent_RegistersAsBatch_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class TestsMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddTests(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Batch Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.OrderBatchAndEventHandler),
+ Factory = global::Mocha.ConsumerFactory.Batch()
+ });
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_RequestResponseHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_RequestResponseHandler_MatchesSnapshot.md
new file mode 100644
index 00000000000..22a5f744550
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_RequestResponseHandler_MatchesSnapshot.md
@@ -0,0 +1,31 @@
+# Generate_RequestResponseHandler_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class TestsMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddTests(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Request Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.GetOrderStatusHandler),
+ Factory = global::Mocha.ConsumerFactory.Request()
+ });
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_SendHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_SendHandler_MatchesSnapshot.md
new file mode 100644
index 00000000000..cfb11c3f857
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_SendHandler_MatchesSnapshot.md
@@ -0,0 +1,31 @@
+# Generate_SendHandler_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class TestsMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddTests(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Request Handlers ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder,
+ new global::Mocha.MessagingHandlerConfiguration
+ {
+ HandlerType = typeof(global::TestApp.ProcessOrderHandler),
+ Factory = global::Mocha.ConsumerFactory.Send()
+ });
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingSagaGeneratorTests.Generate_SimpleSaga_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingSagaGeneratorTests.Generate_SimpleSaga_MatchesSnapshot.md
new file mode 100644
index 00000000000..de6480149f4
--- /dev/null
+++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingSagaGeneratorTests.Generate_SimpleSaga_MatchesSnapshot.md
@@ -0,0 +1,27 @@
+# Generate_SimpleSaga_MatchesSnapshot
+
+```csharp
+//
+
+#nullable enable
+#pragma warning disable
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")]
+ public static class TestsMessageBusBuilderExtensions
+ {
+ public static global::Mocha.IMessageBusHostBuilder AddTests(
+ this global::Mocha.IMessageBusHostBuilder builder)
+ {
+
+ // --- Sagas ---
+ global::Mocha.MessageBusHostBuilderExtensions.AddSaga<
+ global::TestApp.OrderFulfillmentSaga>(builder);
+
+ return builder;
+ }
+ }
+}
+
+```
diff --git a/website/src/docs/docs.json b/website/src/docs/docs.json
index 11ffab084e3..8398bb0e6e6 100644
--- a/website/src/docs/docs.json
+++ b/website/src/docs/docs.json
@@ -2860,6 +2860,10 @@
"path": "handlers-and-consumers",
"title": "Handlers and Consumers"
},
+ {
+ "path": "handler-registration",
+ "title": "Handler Registration"
+ },
{
"path": "routing-and-endpoints",
"title": "Routing and Endpoints"
diff --git a/website/src/docs/mocha/v1/handler-registration.md b/website/src/docs/mocha/v1/handler-registration.md
new file mode 100644
index 00000000000..81c6d365e0a
--- /dev/null
+++ b/website/src/docs/mocha/v1/handler-registration.md
@@ -0,0 +1,238 @@
+---
+title: "Handler Registration"
+description: "How Mocha discovers message bus handlers at compile time using a Roslyn source generator, and how to customize module naming, mix auto and manual registration, and resolve analyzer diagnostics."
+---
+
+```csharp
+builder.Services
+ .AddMessageBus()
+ .AddOrderService(); // source-generated from your assembly name
+```
+
+# Handler registration
+
+That registers the message bus, discovers your handlers and sagas at compile time, and wires up the registration. `.AddOrderService()` is a source-generated extension method - it knows your handler types at compile time and produces direct registration calls with no reflection.
+
+# How the source generator works
+
+At build time, the `Mocha.Analyzers` package runs a [Roslyn incremental source generator](https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/source-generators-overview) that scans your assembly for classes implementing message bus handler interfaces. For each handler it finds, it emits a registration call in a generated extension method on `IMessageBusHostBuilder`.
+
+The generator discovers these types:
+
+| Interface | Pattern |
+| ---------------------------------- | ---------------------- |
+| `IEventHandler` | Pub/sub events |
+| `IEventRequestHandler` | Request/reply |
+| `IEventRequestHandler` | Send (fire-and-forget) |
+| `IBatchEventHandler` | Batch processing |
+| `IConsumer` | Low-level consumer |
+| `Saga` | Saga orchestration |
+
+If you have used the [Mediator source generator](/docs/mocha/v1/mediator), this works the same way. The mediator generates `Add{ModuleName}()` on `IMediatorHostBuilder`; the message bus generates `Add{ModuleName}()` on `IMessageBusHostBuilder`.
+
+> **Recommendation:** Always use the source generator for handler registration. The generated code uses optimized, reflection-free registration paths. The source-generated output is designed for long-term stability across versions. Manual registration methods are available for edge cases but their internal behavior may change between releases.
+
+# Module naming
+
+The source generator names the extension method based on your assembly:
+
+1. If you apply `[assembly: MessagingModule("OrderService")]`, the method is `AddOrderService()`
+2. Otherwise, it uses the last segment of the assembly name: `MyCompany.OrderService.Api` produces `AddApi()`
+
+To set an explicit module name, add the attribute to any file in your project:
+
+```csharp
+using Mocha;
+
+[assembly: MessagingModule("OrderService")]
+```
+
+This generates:
+
+```csharp
+builder.Services
+ .AddMessageBus()
+ .AddOrderService() // from [assembly: MessagingModule("OrderService")]
+ .AddRabbitMQ();
+```
+
+> **Convention:** Use a short, meaningful name that identifies the service or bounded context - `OrderService`, `Billing`, `Inventory`. This name appears in the generated code and in your `Program.cs`, so keep it readable.
+
+# What the generator produces
+
+For a project named `OrderService` with an event handler, a request handler, and a saga, the source generator produces an extension method `AddOrderService()` on `IMessageBusHostBuilder`. This method registers all discovered handlers and sagas with optimized, reflection-free factory delegates.
+
+Handlers are grouped by kind and ordered alphabetically within each group. The registration order is: batch handlers, consumers, request handlers, event handlers, sagas.
+
+> **Note:** The generated code is an implementation detail and may change between versions. Do not depend on the shape of the generated output.
+
+# Manual handler registration
+
+When you need to register handlers outside the source generator's reach - from a plugin assembly, a dynamically loaded module, or in integration tests - use the explicit registration methods:
+
+```csharp
+builder.Services
+ .AddMessageBus()
+ .AddOrderService() // source-generated handlers
+ .AddEventHandler() // from another assembly
+ .AddRequestHandler() // from a plugin
+ .AddRabbitMQ();
+```
+
+You can mix source-generated and manual registration freely. If both the source generator and manual code register the same handler type, the configurations are composed — the source generator sets up the base registration and your manual call layers additional configuration (such as consumer middleware) on top.
+
+> **Prefer the source generator.** Manual registration methods use runtime reflection to create handler consumers. The source generator produces direct, reflection-free factory calls. We guarantee backwards compatibility for the source-generated registration path; the manual registration API is stable at the surface level but its internal behavior may evolve.
+
+# Analyzer diagnostics
+
+The source generator reports compile-time diagnostics when it finds issues with your handlers. These appear as warnings or errors in your IDE and build output.
+
+| Code | Severity | Message | Cause |
+| ---------- | -------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| **MO0010** | Warning | Request type '{0}' has no registered handler | An `IEventRequest` type exists but no handler implements `IEventRequestHandler` or `IEventRequestHandler` for it |
+| **MO0011** | Error | Request type '{0}' has multiple handlers: {1} | Two or more handlers implement `IEventRequestHandler` for the same request type. Request handlers must be unique per request type |
+| **MO0012** | Info | Handler '{0}' is an open generic and cannot be auto-registered | A handler like `GenericHandler : IEventHandler` has unbound type parameters. Register closed generic versions manually |
+| **MO0013** | Warning | Handler '{0}' is abstract and will not be registered | An abstract class implements a handler interface. Only concrete classes are registered. This is expected for base handler classes |
+| **MO0014** | Error | Saga '{0}' must have a public parameterless constructor | A `Saga` subclass is missing a public parameterless constructor, which is required for saga state management |
+
+## MO0010: Missing request handler
+
+A request type implements `IEventRequest` but no handler in the assembly handles it:
+
+```csharp
+// This produces MO0010
+public record GetOrderStatusRequest(Guid OrderId) : IEventRequest;
+
+// No class implements IEventRequestHandler
+```
+
+**Fix:** Add a handler for the request type:
+
+```csharp
+public class GetOrderStatusHandler
+ : IEventRequestHandler
+{
+ public ValueTask HandleAsync(
+ GetOrderStatusRequest request,
+ CancellationToken ct)
+ => new("shipped");
+}
+```
+
+## MO0011: Duplicate request handler
+
+Request types can have only one handler. If two handlers register for the same request type, the build fails:
+
+```csharp
+// This produces MO0011
+public class GetOrderHandlerA : IEventRequestHandler
+{
+ public ValueTask HandleAsync(
+ GetOrderRequest request,
+ CancellationToken ct)
+ => new("A");
+}
+
+public class GetOrderHandlerB : IEventRequestHandler
+{
+ public ValueTask HandleAsync(
+ GetOrderRequest request,
+ CancellationToken ct)
+ => new("B");
+}
+```
+
+**Fix:** Remove one of the handlers, or change one to handle a different request type.
+
+## MO0012: Open generic handler
+
+Open generics cannot be auto-registered because the source generator needs concrete type arguments:
+
+```csharp
+// This produces MO0012
+public class GenericHandler : IEventHandler
+{
+ public ValueTask HandleAsync(T message, CancellationToken ct) => default;
+}
+```
+
+**Fix:** Create closed generic implementations for each message type:
+
+```csharp
+public class OrderPlacedHandler : GenericHandler { }
+public class PaymentReceivedHandler : GenericHandler { }
+```
+
+## MO0013: Abstract handler
+
+Abstract handlers are not registered - they cannot be instantiated by DI. This diagnostic is informational; it confirms the generator is intentionally skipping your base class.
+
+```csharp
+// This produces MO0013 - expected for base classes
+public abstract class BaseOrderHandler : IEventHandler
+{
+ public abstract ValueTask HandleAsync(OrderPlaced message, CancellationToken ct);
+}
+
+// Concrete subclass is registered normally
+public class OrderPlacedHandler : BaseOrderHandler
+{
+ public override ValueTask HandleAsync(OrderPlaced message, CancellationToken ct) => default;
+}
+```
+
+## MO0014: Saga without parameterless constructor
+
+Sagas require a public parameterless constructor for state management:
+
+```csharp
+// This produces MO0014
+public class OrderSaga : Saga
+{
+ public OrderSaga(string name) { } // no parameterless constructor
+
+ protected override void Configure(ISagaDescriptor descriptor) { }
+}
+```
+
+**Fix:** Add a public parameterless constructor, or remove the constructor with parameters and let the compiler generate the default:
+
+```csharp
+public class OrderSaga : Saga
+{
+ protected override void Configure(ISagaDescriptor descriptor) { }
+}
+```
+
+# Troubleshooting
+
+## The source-generated method does not appear
+
+If IntelliSense does not show `Add{ModuleName}()`:
+
+- Confirm the `Mocha.Analyzers` package is referenced with `OutputItemType="Analyzer"` in your `.csproj`
+- Rebuild the project - source generators run during compilation
+- Check the build output for analyzer warnings prefixed with `MO`
+- Verify you have at least one concrete handler class in the assembly
+
+## Handler is not being called
+
+If the source-generated method is available but a specific handler does not run:
+
+- Check for **MO0013** (abstract handler) - only concrete classes are registered
+- Check for **MO0012** (open generic) - close the generic type
+- Verify the handler implements the correct interface for the messaging pattern you are using
+- Ensure the handler is in the same project that references `Mocha.Analyzers`
+
+## Priority when a handler implements multiple interfaces
+
+If a class implements more than one messaging interface (e.g., both `IBatchEventHandler` and `IEventHandler`), the source generator registers it using the highest-priority interface only:
+
+`IBatchEventHandler` > `IConsumer` > `IEventRequestHandler` > `IEventRequestHandler` > `IEventHandler`
+
+# Next steps
+
+- [Handlers and Consumers](/docs/mocha/v1/handlers-and-consumers) - handler interfaces, DI scoping, and exception behavior
+- [Routing and Endpoints](/docs/mocha/v1/routing-and-endpoints) - how the bus routes messages to registered handlers
+- [Sagas](/docs/mocha/v1/sagas) - saga state machines and long-running workflows
+- [Mediator](/docs/mocha/v1/mediator) - the mediator uses the same source generation approach for in-process CQRS
diff --git a/website/src/docs/mocha/v1/handlers-and-consumers.md b/website/src/docs/mocha/v1/handlers-and-consumers.md
index b57ea4f7523..fe12b6db525 100644
--- a/website/src/docs/mocha/v1/handlers-and-consumers.md
+++ b/website/src/docs/mocha/v1/handlers-and-consumers.md
@@ -5,7 +5,7 @@ description: "Learn how to implement message handlers in Mocha - event handlers,
# Handlers and consumers
-You implement a handler interface, register it with the bus builder, and Mocha routes matching messages to it. This page covers every handler type, when to use each one, and the patterns that apply to all of them: DI scoping, exception behavior, and publishing from within a handler.
+You implement a handler interface, and the source generator discovers it at compile time. Mocha routes matching messages to your handler automatically. This page covers every handler type, when to use each one, and the patterns that apply to all of them: DI scoping, exception behavior, and publishing from within a handler.
## When to use which handler
@@ -80,14 +80,14 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddMessageBus()
- .AddEventHandler()
+ .AddMyApp() // source-generated - registers OrderPlacedHandler automatically
.AddInMemory(); // or .AddRabbitMQ()
var app = builder.Build();
app.Run();
```
-`.AddEventHandler()` registers the handler as a scoped service and tells the bus which message type to route to it.
+`.AddMyApp()` is a source-generated extension method that discovers all handlers in the assembly and registers them. The source generator found `OrderPlacedHandler`, saw that it implements `IEventHandler`, and emitted a registration call for it. For details on how the source generator works and how to customize the module name, see [Handler Registration](/docs/mocha/v1/handler-registration).
## Verify the handler runs
@@ -179,7 +179,7 @@ The return value is sent back to the caller automatically. The return value must
```csharp
builder.Services
.AddMessageBus()
- .AddRequestHandler()
+ .AddMyApp() // source-generated - registers GetProductRequestHandler automatically
.AddRabbitMQ();
```
@@ -260,7 +260,7 @@ public class ReserveInventoryCommandHandler(
```csharp
builder.Services
.AddMessageBus()
- .AddRequestHandler()
+ .AddMyApp() // source-generated - registers ReserveInventoryCommandHandler automatically
.AddRabbitMQ();
```
@@ -342,6 +342,7 @@ To access envelope metadata for a specific message in the batch, call `batch.Get
```csharp
builder.Services
.AddMessageBus()
+ .AddMyApp() // source-generated - registers OrderPlacedBatchHandler automatically
.AddBatchHandler(opts =>
{
opts.MaxBatchSize = 50;
@@ -350,7 +351,7 @@ builder.Services
.AddRabbitMQ();
```
-The configuration parameter is optional. Without it, Mocha uses the defaults: 100 messages per batch, 1-second timeout.
+The source generator registers the batch handler, but you can chain `.AddBatchHandler