Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
57fdcd2
feat: add Brighter.SourceGenerators for compile-time handler/mapper/t…
slang25 May 11, 2026
30a39ff
refactor: reduce complexity in source generator per CodeScene feedback
slang25 May 11, 2026
5b45c44
refactor: separate reader/writer with intermediate model, add tests
slang25 May 18, 2026
41f49d4
Merge remote-tracking branch 'upstream/master' into slang25/source-ge…
slang25 May 18, 2026
587a924
refactor: shrink TryBuildModel signature and split mapper classification
slang25 May 18, 2026
fe251bb
perf: make the incremental pipeline actually incremental
slang25 May 18, 2026
af0f070
feat: auto-generate BrighterAssemblyRegistrations on direct PackageRe…
slang25 May 18, 2026
1979663
fix: address PR review findings
slang25 May 18, 2026
01abe33
Merge remote-tracking branch 'upstream/master' into slang25/source-ge…
slang25 Jun 4, 2026
b694550
docs: add ADR 0062 for source-generated handler registration
slang25 Jun 4, 2026
c7b1c4a
refactor: emit registrations via IndentedTextWriter-based CodeWriter
slang25 Jun 4, 2026
c5c29e7
feat: stamp generated registrations with banner and [GeneratedCode]
slang25 Jun 4, 2026
66d4116
test: assert generator incrementality via tracked pipeline stages
slang25 Jun 4, 2026
e9a18ee
fix: address PR review findings
slang25 Jun 4, 2026
b563fdb
fix: address second review batch
slang25 Jun 4, 2026
6b565a2
refactor: reduce complexity flagged by CodeScene
slang25 Jun 5, 2026
c63b750
fix: address third review batch
slang25 Jun 5, 2026
cfc8156
refactor: pin Roslyn to 4.8 and clear remaining CodeScene findings
slang25 Jun 5, 2026
0c4c60e
refactor: revert two metric-driven changes that hurt readability
slang25 Jun 5, 2026
db546d0
docs: reshape ADR 0062 around a RegistrationCatalog data type
slang25 Jul 12, 2026
653485f
docs: settle ADR 0062 open decisions from API exploration
slang25 Jul 12, 2026
5a12d0b
docs: add throwaway RegistrationCatalog API prototype
slang25 Jul 12, 2026
0511ecc
Merge branch 'master' into slang25/source-gen-auto-assemblies
slang25 Jul 12, 2026
3ae1db5
Merge branch 'master' into slang25/source-gen-auto-assemblies
slang25 Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Brighter.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
<Folder Name="/tests/">
<File Path="tests/README.md" />
<Project Path="tests/Paramore.Brighter.Analyzer.Tests/Paramore.Brighter.Analyzer.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.SourceGenerators.Tests/Paramore.Brighter.SourceGenerators.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.AsyncAPI.Tests/Paramore.Brighter.AsyncAPI.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.AWS.Tests/Paramore.Brighter.AWS.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.AWS.V4.Tests/Paramore.Brighter.AWS.V4.Tests.csproj" />
Expand Down Expand Up @@ -244,6 +245,7 @@
</Folder>
<Project Path="src/Paramore.Brighter.Analyzer.Package/Paramore.Brighter.Analyzer.Package.csproj" />
<Project Path="src/Paramore.Brighter.Analyzer/Paramore.Brighter.Analyzer.csproj" />
<Project Path="src/Paramore.Brighter.SourceGenerators/Paramore.Brighter.SourceGenerators.csproj" />
<Project Path="src/Paramore.Brighter.Archive.Azure/Paramore.Brighter.Archive.Azure.csproj" />
<Project Path="src/Paramore.Brighter.AsyncAPI.NJsonSchema/Paramore.Brighter.AsyncAPI.NJsonSchema.csproj" />
<Project Path="src/Paramore.Brighter.AsyncAPI/Paramore.Brighter.AsyncAPI.csproj" />
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.3" />
<PackageVersion Include="Microsoft.Bcl.TimeProvider" Version="10.0.7" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.0.1" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
Expand Down
518 changes: 518 additions & 0 deletions docs/adr/0062-source-generated-handler-registration.md

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion samples/CommandProcessor/HelloWorld/HelloWorld.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<!--
With the NuGet package, build/Paramore.Brighter.SourceGenerators.props sets this and makes
it CompilerVisible for direct PackageReferences only (so it doesn't flow to transitive
consumers). The sample uses a ProjectReference, so we opt in by hand.
-->
<BrighterAutoRegistration>true</BrighterAutoRegistration>
</PropertyGroup>

<ItemGroup>
<CompilerVisibleProperty Include="BrighterAutoRegistration" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
Expand All @@ -12,5 +22,8 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Paramore.Brighter.Extensions.DependencyInjection\Paramore.Brighter.Extensions.DependencyInjection.csproj" />
<ProjectReference Include="..\..\..\src\Paramore.Brighter\Paramore.Brighter.csproj" />
<ProjectReference Include="..\..\..\src\Paramore.Brighter.SourceGenerators\Paramore.Brighter.SourceGenerators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
</Project>
22 changes: 22 additions & 0 deletions samples/CommandProcessor/HelloWorld/NoOpTransformer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#nullable enable
using Paramore.Brighter;

namespace HelloWorld;

/// <summary>
/// A do-nothing transform purely to exercise the source generator's transform discovery.
/// </summary>
public sealed class NoOpTransformer : IAmAMessageTransform
{
public IRequestContext? Context { get; set; }

public void InitializeWrapFromAttributeParams(params object?[] initializerList) { }

public void InitializeUnwrapFromAttributeParams(params object?[] initializerList) { }

public Message Wrap(Message message, Publication publication) => message;

public Message Unwrap(Message message) => message;

public void Dispose() { }
}
2 changes: 1 addition & 1 deletion samples/CommandProcessor/HelloWorld/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ THE SOFTWARE. */
using Paramore.Brighter.Extensions.DependencyInjection;

var builder = Host.CreateApplicationBuilder();
builder.Services.AddBrighter().AutoFromAssemblies();
builder.Services.AddBrighter().AddFromThisAssembly();
var host = builder.Build();

var commandProcessor = host.Services.GetRequiredService<IAmACommandProcessor>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// PROTOTYPE — THROWAWAY domain types.
using System;
using System.Threading;
using System.Threading.Tasks;
using Paramore.Brighter;

namespace Orders.Domain.Billing;

public sealed class ChargeCard(decimal amount) : Command(Id.Random())
{
public decimal Amount { get; } = amount;
}

public sealed class ChargeCardHandler : RequestHandler<ChargeCard>
{
public override ChargeCard Handle(ChargeCard command)
{
Console.WriteLine($" → ChargeCardHandler charged £{command.Amount}");
return base.Handle(command);
}
}

public sealed class RefundPayment(decimal amount) : Command(Id.Random())
{
public decimal Amount { get; } = amount;
}

public sealed class RefundPaymentHandler : RequestHandlerAsync<RefundPayment>
{
public override Task<RefundPayment> HandleAsync(RefundPayment command, CancellationToken cancellationToken = default)
{
Console.WriteLine($" → RefundPaymentHandler refunded £{command.Amount}");
return base.HandleAsync(command, cancellationToken);
}
}

public sealed class UrgentRefund(decimal amount) : Command(Id.Random())
{
public decimal Amount { get; } = amount;
}

// Deliberately named to be scooped by the "Urgent" TypeNamePattern group, across namespaces.
public sealed class UrgentRefundHandler : RequestHandlerAsync<UrgentRefund>
{
public override Task<UrgentRefund> HandleAsync(UrgentRefund command, CancellationToken cancellationToken = default)
{
Console.WriteLine($" → UrgentRefundHandler fast-tracked £{command.Amount}");
return base.HandleAsync(command, cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// PROTOTYPE — THROWAWAY domain types.
using System;
using Paramore.Brighter;

namespace Orders.Domain.Fulfilment;

public sealed class ShipOrder(string sku) : Command(Id.Random())
{
public string Sku { get; } = sku;
}

public sealed class ShipOrderHandler : RequestHandler<ShipOrder>
{
public override ShipOrder Handle(ShipOrder command)
{
Console.WriteLine($" → ShipOrderHandler shipped {command.Sku}");
return base.Handle(command);
}
}

public sealed class UrgentShipOrder(string sku) : Command(Id.Random())
{
public string Sku { get; } = sku;
}

// Second "Urgent" type, in a different namespace to Billing.UrgentRefundHandler — the
// TypeNamePattern group cuts across the namespace groups.
public sealed class UrgentShipOrderHandler : RequestHandler<UrgentShipOrder>
{
public override UrgentShipOrder Handle(UrgentShipOrder command)
{
Console.WriteLine($" → UrgentShipOrderHandler expedited {command.Sku}");
return base.Handle(command);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// PROTOTYPE — THROWAWAY domain types.
using System;
using System.Threading;
using System.Threading.Tasks;
using Paramore.Brighter;

namespace Orders.Domain.Notifications;

public sealed class ReceiptRequested(string email) : Event(Id.Random())
{
public string Email { get; } = email;
}

public sealed class ReceiptRequestedHandler : RequestHandlerAsync<ReceiptRequested>
{
public override Task<ReceiptRequested> HandleAsync(ReceiptRequested @event, CancellationToken cancellationToken = default)
{
Console.WriteLine($" → ReceiptRequestedHandler emailed {@event.Email}");
return base.HandleAsync(@event, cancellationToken);
}
}

// Never invoked by the prototype (no external bus); exists so the catalog has a mapper entry.
public sealed class ReceiptRequestedMapper : IAmAMessageMapper<ReceiptRequested>
{
public IRequestContext? Context { get; set; }

public Message MapToMessage(ReceiptRequested request, Publication publication) =>
throw new NotImplementedException("prototype");

public ReceiptRequested MapToRequest(Message message) =>
throw new NotImplementedException("prototype");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>

<!--
PROTOTYPE. The design's key dependency claim: a handler-owning (domain) project needs
core Paramore.Brighter ONLY - no Paramore.Brighter.Extensions.DependencyInjection,
no Microsoft.Extensions.*, no Polly. (Plus the generator as a dev-only analyzer, once real.)
-->
<ItemGroup>
<ProjectReference Include="..\..\..\src\Paramore.Brighter\Paramore.Brighter.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// ─────────────────────────────────────────────────────────────────────────────────────
// THIS FILE IS WHAT THE USER WRITES. Everything else about registration is generated.
//
// The holder declaration is the whole per-assembly ceremony: name, namespace and visibility
// are user-controlled, so any number of assemblies compose in the host without collision.
// [RegistrationGroup] declares named convention scoops (evaluated at build time) — drop the
// attributes and you still get the flat `Catalog`.
// ─────────────────────────────────────────────────────────────────────────────────────
using Paramore.Brighter;

namespace Orders.Domain;

// GenerateBuilderExtensions is the Phase-2 opt-in for fluent sugar (see the host's
// OrdersRegistrations.BuilderExtensions.g.cs). In the real design those extensions are generated
// INTO THIS PROJECT, which would then have to reference the DI package — the prototype parks the
// file in the host instead so Orders.Domain keeps demonstrating the core-only posture.
[BrighterRegistrations(GenerateBuilderExtensions = true)]
[RegistrationGroup("Billing", InNamespace = "Orders.Domain.Billing")]
[RegistrationGroup("Fulfilment", InNamespace = "Orders.Domain.Fulfilment")]
[RegistrationGroup("Urgent", TypeNamePattern = "^Urgent")]
public static partial class OrdersRegistrations;
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// <auto-generated/>
// ─────────────────────────────────────────────────────────────────────────────────────
// PROTOTYPE — hand-written stand-in for what BrighterRegistrationsGenerator WOULD emit
// for the declaration in OrdersRegistrations.cs.
//
// Note what is NOT here: no builder calls, no open-generic casts, no default-mapper logic.
// The generator emits inert data; AddRegistrations (library code) does the thinking.
// [RegistrationGroup] conventions were evaluated at generation time against the discovered
// type names, so each group below is just another pre-filtered block of static data —
// the regex never runs at runtime.
// ─────────────────────────────────────────────────────────────────────────────────────
#nullable enable

namespace Orders.Domain
{
public static partial class OrdersRegistrations
{
/// <summary>Every Brighter type discovered in this assembly.</summary>
public static global::Paramore.Brighter.RegistrationCatalog Catalog { get; } = BuildCatalog();

/// <summary>[RegistrationGroup("Billing", InNamespace = "Orders.Domain.Billing")]</summary>
public static global::Paramore.Brighter.RegistrationCatalog Billing { get; } = BuildBilling();

/// <summary>[RegistrationGroup("Fulfilment", InNamespace = "Orders.Domain.Fulfilment")]</summary>
public static global::Paramore.Brighter.RegistrationCatalog Fulfilment { get; } = BuildFulfilment();

/// <summary>[RegistrationGroup("Urgent", TypeNamePattern = "^Urgent")]</summary>
public static global::Paramore.Brighter.RegistrationCatalog Urgent { get; } = BuildUrgent();

private static global::Paramore.Brighter.RegistrationCatalog BuildCatalog()
{
var catalog = new global::Paramore.Brighter.RegistrationCatalog();
catalog.AddHandler(typeof(global::Orders.Domain.Billing.ChargeCardHandler), typeof(global::Orders.Domain.Billing.ChargeCard), isAsync: false);
catalog.AddHandler(typeof(global::Orders.Domain.Billing.RefundPaymentHandler), typeof(global::Orders.Domain.Billing.RefundPayment), isAsync: true);
catalog.AddHandler(typeof(global::Orders.Domain.Billing.UrgentRefundHandler), typeof(global::Orders.Domain.Billing.UrgentRefund), isAsync: true);
catalog.AddHandler(typeof(global::Orders.Domain.Fulfilment.ShipOrderHandler), typeof(global::Orders.Domain.Fulfilment.ShipOrder), isAsync: false);
catalog.AddHandler(typeof(global::Orders.Domain.Fulfilment.UrgentShipOrderHandler), typeof(global::Orders.Domain.Fulfilment.UrgentShipOrder), isAsync: false);
catalog.AddHandler(typeof(global::Orders.Domain.Notifications.ReceiptRequestedHandler), typeof(global::Orders.Domain.Notifications.ReceiptRequested), isAsync: true);
catalog.AddMapper(typeof(global::Orders.Domain.Notifications.ReceiptRequestedMapper), typeof(global::Orders.Domain.Notifications.ReceiptRequested), isAsync: false);
return catalog;
}

private static global::Paramore.Brighter.RegistrationCatalog BuildBilling()
{
var catalog = new global::Paramore.Brighter.RegistrationCatalog();
catalog.AddHandler(typeof(global::Orders.Domain.Billing.ChargeCardHandler), typeof(global::Orders.Domain.Billing.ChargeCard), isAsync: false);
catalog.AddHandler(typeof(global::Orders.Domain.Billing.RefundPaymentHandler), typeof(global::Orders.Domain.Billing.RefundPayment), isAsync: true);
catalog.AddHandler(typeof(global::Orders.Domain.Billing.UrgentRefundHandler), typeof(global::Orders.Domain.Billing.UrgentRefund), isAsync: true);
return catalog;
}

private static global::Paramore.Brighter.RegistrationCatalog BuildFulfilment()
{
var catalog = new global::Paramore.Brighter.RegistrationCatalog();
catalog.AddHandler(typeof(global::Orders.Domain.Fulfilment.ShipOrderHandler), typeof(global::Orders.Domain.Fulfilment.ShipOrder), isAsync: false);
catalog.AddHandler(typeof(global::Orders.Domain.Fulfilment.UrgentShipOrderHandler), typeof(global::Orders.Domain.Fulfilment.UrgentShipOrder), isAsync: false);
return catalog;
}

private static global::Paramore.Brighter.RegistrationCatalog BuildUrgent()
{
var catalog = new global::Paramore.Brighter.RegistrationCatalog();
catalog.AddHandler(typeof(global::Orders.Domain.Billing.UrgentRefundHandler), typeof(global::Orders.Domain.Billing.UrgentRefund), isAsync: true);
catalog.AddHandler(typeof(global::Orders.Domain.Fulfilment.UrgentShipOrderHandler), typeof(global::Orders.Domain.Fulfilment.UrgentShipOrder), isAsync: false);
return catalog;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// ─────────────────────────────────────────────────────────────────────────────────────
// PROTOTYPE — THROWAWAY. In the real design these are emitted internal-per-assembly by the
// generator's post-init output (like the PR's existing BrighterRegistrationsAttribute).
// ─────────────────────────────────────────────────────────────────────────────────────
using System;

namespace Paramore.Brighter;

/// <summary>Marks a static partial class the generator fills with a RegistrationCatalog.</summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class BrighterRegistrationsAttribute : Attribute
{
/// <summary>
/// Phase 2: also emit one-line fluent extensions — Add{HolderName}() for the whole catalog and
/// Add{GroupName}Registrations() per [RegistrationGroup] — each delegating to AddRegistrations.
/// DELIBERATE TRADE: the extensions take IBrighterBuilder, so opting in couples the declaring
/// assembly to the DI package. Setting this without referencing it is a build-error diagnostic.
/// </summary>
public bool GenerateBuilderExtensions { get; set; }
}

/// <summary>
/// Declares a named sub-catalog scooped by convention — the Brighter analogue of NServiceBus's
/// RegistrationMethodNamePatterns. The generator evaluates the convention AT BUILD TIME against
/// the discovered types and emits the group as a pre-filtered static catalog property named
/// <see cref="Name"/>. An invalid regex or a group that matches nothing is a build-time
/// diagnostic, not a runtime surprise.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class RegistrationGroupAttribute(string name) : Attribute
{
/// <summary>Generated property name; must be a valid C# identifier.</summary>
public string Name { get; } = name;

/// <summary>Scoop every discovered type in this namespace (and below).</summary>
public string? InNamespace { get; set; }

/// <summary>Scoop every discovered type whose simple name matches this regex.</summary>
public string? TypeNamePattern { get; set; }
}
Loading
Loading