FeatureFusion
Health Pass
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Community trust — 49 GitHub stars
Code Pass
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
a .NET lab of production building blocks (Mediator, OpenTelemetry, Aspire/SigNoz and etc) plus a runnable showcase. More packages, a frontend, and MCP on the way.
FeatureFusion
BuildingBlocks for .NET — CQRS Send + pipeline, config-driven OpenTelemetry, MCP tools, keyset pagination, and a local Aspire SigNoz stack — plus a runnable lab that uses them.
Formerly FeatureManagement (GitHub redirects).
Author · Mohammad Hasan Hosseini · Technical Team Lead & .NET enthusiast
Table of contents
- BuildingBlocks
- Lab
- Architecture
- Stack
- Repository layout
- Prerequisites
- Run the lab
- Lab features
- Design patterns
- LinkedIn catalog
- What's next
- Testing
- Contributing
BuildingBlocks
NuGet packages you can install in your hosts. The FeatureFusion API is a showcase, not a required dependency.
| Package | Role | TFMs |
|---|---|---|
| BuildingBlocks.Mediator | CQRS Send + ordered pipeline (ICommand / IQuery, typed behaviors, opt-in traces + metrics) |
net8 / net9 / net10 |
| BuildingBlocks.Mcp | Message types → MCP tools on the official SDK (deny-by-default, McpResult, HTTP + opt-in stdio) |
net8 / net9 / net10 |
| BuildingBlocks.Pagination.EntityFrameworkCore | Typed keyset (cursor) pagination for EF Core (IR bundled) | net8 / net9 / net10 |
| BuildingBlocks.Telemetry | Config-driven OpenTelemetry (traces, metrics, logs) + IntegrateMediator / opt-in IntegrateMcp |
net8 / net9 / net10 |
| BuildingBlocks.Aspire.Hosting.SigNoz | Local-dev Aspire AddSigNoz() + WithSigNozOtlpExporter |
net10 (AppHost) |
Production apps use Mediator + Telemetry and export OTLP to any backend. SigNoz hosting is local AppHost only.
How they work together
flowchart LR
host[Your host]
med[Mediator]
tel[Telemetry]
otlp[OTLP backend]
signoz[SigNoz AppHost]
host --> med
med -->|"UseTelemetry"| tel
tel -->|"AddTelemetry IntegrateMediator"| otlp
signoz -->|"local collector"| otlp
- Mediator dispatches commands/queries through an ordered pipeline.
UseTelemetry()wraps Send (not a pipeline behavior) with an ActivitySource and Meter namedBuildingBlocks.Mediator. - Telemetry
AddTelemetry+IntegrateMediator = trueregisters that source and meter so spans andmediator.sendmetrics export with the rest of the host. - SigNoz hosting (optional, local) provisions a collector + UI.
WithSigNozOtlpExportersetsOTEL_EXPORTER_OTLP_*on a project resource. In production, set the same env vars to your collector.
Compose (same shape as this lab):
// API / worker — BuildingBlocks.Telemetry + BuildingBlocks.Mediator
builder.AddTelemetry(o =>
{
o.IntegrateMediator = true;
o.Instrumentation.Npgsql = true;
});
builder.Services.AddMediator(cfg =>
{
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>), order: 0); // host-owned
cfg.UseTelemetry();
cfg.ValidateOnStartup = true;
});
// AppHost — BuildingBlocks.Aspire.Hosting.SigNoz (local)
var signoz = builder.AddSigNoz("signoz")
.WithUi()
.WithDashboards();
builder.AddProject<Projects.Api>("api")
.WithSigNozOtlpExporter(signoz);
BuildingBlocks.Mediator
CQRS-first Send + ordered pipeline. Manual control over registration, pipeline order, validation, and telemetry — not a MediatR or messaging replacement (no Publish / INotification in v1).
What's new in 1.1.0: typed ICommandPipelineBehavior / IQueryPipelineBehavior (MS.DI does not construct the opposite kind), AddOpenCommandBehavior / AddOpenQueryBehavior, opt-in Send metrics. Drop-in from 1.0.1.
dotnet add package BuildingBlocks.Mediator
Quick start
public sealed record CreateOrder(string Product, int Qty) : ICommand<Guid>;
public sealed class CreateOrderHandler : ICommandHandler<CreateOrder, Guid>
{
public Task<Guid> Handle(CreateOrder command, CancellationToken ct)
=> Task.FromResult(Guid.NewGuid());
}
services.AddMediator(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<CreateOrderHandler>();
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>), order: 0);
cfg.UseTelemetry();
cfg.ValidateOnStartup = true;
});
await sender.Send(new CreateOrder("SKU-1", 2), ct);
Prefer ISender. Host OTel: AddSource + AddMeter "BuildingBlocks.Mediator" (or Telemetry IntegrateMediator).
All options
Markers: ICommand / ICommand<T> / IQuery<T> (no public IRequest, no non-generic IQuery). Void: ICommand : ICommand<Unit>. IMediator is the same Send surface.
public sealed record CreateOrder(string Product, int Qty) : ICommand<Guid>;
public sealed record CancelOrder(Guid Id) : ICommand;
public sealed record GetOrder(Guid Id) : IQuery<OrderDto>;
public sealed class CreateOrderHandler : ICommandHandler<CreateOrder, Guid>
{
public Task<Guid> Handle(CreateOrder command, CancellationToken ct)
=> Task.FromResult(Guid.NewGuid());
}
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
public Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
=> next(ct);
}
public sealed class AuditCommands<TCommand, TResponse> : ICommandPipelineBehavior<TCommand, TResponse>
where TCommand : ICommand<TResponse>
{
public Task<TResponse> Handle(TCommand command, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
=> next(ct);
}
public sealed class CacheQueries<TQuery, TResponse> : IQueryPipelineBehavior<TQuery, TResponse>
where TQuery : IQuery<TResponse>
{
public Task<TResponse> Handle(TQuery query, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
=> next(ct);
}
services.AddMediator(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(CreateOrderHandler).Assembly);
cfg.RegisterServicesFromAssemblyContaining<CreateOrderHandler>(); // same assembly is deduped
cfg.Lifetime = ServiceLifetime.Scoped; // ISender / IMediator — default Scoped
cfg.HandlerLifetime = ServiceLifetime.Transient; // discovered handlers — default Transient
// Open-generic handlers always resolve Transient (ignore HandlerLifetime)
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>), order: 0); // lower = outermost
cfg.AddOpenCommandBehavior(typeof(AuditCommands<,>), order: 10);
cfg.AddOpenQueryBehavior(typeof(CacheQueries<,>), order: 20);
// cfg.AddBehavior<ClosedLoggingBehavior>(order: 5);
cfg.UseTelemetry(o =>
{
o.ActivitySourceName = "BuildingBlocks.Mediator";
o.MeterName = ""; // empty → copies ActivitySourceName
o.EnableMetrics = true; // mediator.send.duration, mediator.send
o.EnableLogging = true;
o.RecordException = true;
});
cfg.ValidateOnStartup = true;
});
await sender.Send(new CreateOrder("SKU-1", 2), ct);
await sender.Send(new GetOrder(id), ct);
await sender.Send(new CancelOrder(id), ct);
await sender.Send((object)new CreateOrder("SKU-1", 2), ct); // MCP / dynamic
1.0.1 bases CommandPipelineBehavior / QueryPipelineBehavior still work (runtime skip). Analyzers BBM001 / BBM002. No Publish / INotification.
- Package README:
src/BuildingBlocks/Mediator/PACKAGE_README.md - Docs: getting-started · pipeline · cookbook · test matrix
- Freeze / ADR:
docs/building-blocks/mediator.md·docs/adr/0001-mediator-building-blocks-in-monorepo.md - LinkedIn: BuildingBlocks.Mediator v1.0.1 · Mediator Pattern + Pipeline Behavior (prior)
BuildingBlocks.Mcp
Map application message types (commands, queries, DTOs) and public static Minimal API methods to MCP tools. The official C# SDK owns the protocol; this package owns the catalog, McpResult, filters, and safe defaults. Not OpenAPI, not MVC controllers (unsupported for now), not a SOLID linter.
dotnet add package BuildingBlocks.Mcp
Requires .NET 8 / 9 / 10. HTTP default: MapBuildingBlocksMcp() → /mcp. Cursor talks to a running API (url). Stdio (UseStdioTransport(), logs on stderr) is for console hosts only — do not enable it on a web API. Host OpenTelemetry: IntegrateMcp = true plus o.UseTelemetry() on the MCP builder.
After you add or rename tools, restart the API and reload the MCP server in Cursor (Aspire restart alone does not refresh Cursor’s cached tools/list).
Quick start
[McpTool("orders.create", Description = "Create an order")]
public sealed record CreateOrder(int ProductId, int Quantity);
builder.Services.AddBuildingBlocksMcp(o =>
{
o.ScanAssemblyContaining<CreateOrder>();
o.UseMemoryIdempotency(TimeSpan.FromHours(1));
}).UseDispatcher(async (sp, msg, ct) =>
{
await using var scope = sp.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<ISender>().Send(msg, ct);
});
app.MapBuildingBlocksMcp();
All options — Mediator / ISender
[McpTool("orders.create", Description = "Create an order", Kind = McpToolKind.Command, Idempotent = true)]
public sealed record CreateOrder(int ProductId, int Quantity);
builder.Services.AddBuildingBlocksMcp(o =>
{
o.ScanAssemblyContaining<CreateOrder>();
o.UseTelemetry();
o.UseMemoryIdempotency(TimeSpan.FromHours(1));
}).UseDispatcher(async (sp, msg, ct) =>
{
await using var scope = sp.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<ISender>().Send(msg, ct);
});
app.MapBuildingBlocksMcp();
UseDispatcher is a singleton; create a scope per call (ISender is scoped). Kind can be omitted when the type implements Mediator ICommand / IQuery. Tool-level Description is required. Property [Description] is optional (JSON Schema text only).
All options — Minimal API — same method as MapGet / MapPost
JSON binds to one request parameter. CancellationToken, McpInvokeContext, interfaces, and ILogger<T> come from DI. HttpContext is not the MCP body (null outside HTTP). Do not use [FromHeader] types as the MCP input.
A — [McpTool] + scan (attribute is enough; scan picks up public static methods):
[McpTool("lab.ping", Description = "Minimal API ping", Kind = McpToolKind.Query)]
public static string LabPing([AsParameters] LabPingRequest request)
=> string.IsNullOrWhiteSpace(request.Name) ? "pong" : $"pong:{request.Name}";
api.MapGet("/lab-ping", LabPing);
builder.Services.AddBuildingBlocksMcp(o => o.ScanAssembly(Assembly.GetExecutingAssembly()));
B — [McpTool] + .WithMcp(app) (same tool; scan and WithMcp dedupe by name). Pass the IEndpointRouteBuilder used for MapGet:
api.MapGet("/lab-ping", LabPing).WithMcp(app);
C — .WithMcp(app, "name", "description") without an attribute. GET → query (no idempotency key). POST/PUT → command (idempotent write). Other verbs need Kind in configure.
api.MapPost("/items", CreateItem).WithMcp(app, "items.create", "Create an item");
D — MapTool when the HTTP signature cannot be the MCP input (FromHeader, multiple bodies). Dedicated DTO + handler (scoped IServiceProvider overload for validators / feature flags).
o.MapTool<GreetingMcpRequest, string>(
"greetings.custom",
"Dedicated MCP DTO — not the HTTP FromHeader model",
async (sp, msg, ctx, ct) => McpResult.Ok("…"),
a => a.Kind = McpToolKind.Query);
MVC controller classes and actions are unsupported for now.
Idempotency (writes only)
MCP has no HTTP verb on Mediator messages. Command ≈ POST/PUT; Query ≈ GET.
| Command | Query | |
|---|---|---|
| Default | Idempotent = true |
never uses the store |
| Client | must send idempotencyKey when a store is registered |
do not require a key |
| Schema | string + format: uuid (hint; host accepts any non-empty string, including ULID) |
no key property |
| Opt out | Idempotent = false (lab demo.echo) |
— |
Register a store with o.UseMemoryIdempotency(ttl) (single instance). Multi-instance: implement IMcpIdempotencyStore (Redis, etc.). Keys are namespaced per tool; in-flight calls share a lock; success is replayed as JsonElement. The library never retries writes. Cursor/Claude fill idempotencyKey from the tool schema (they do not inject a key unless it is required). Reuse the same UUID only when retrying the same write. RequireConfirmation adds required confirmed: true.
Cursor HTTP:
{
"mcpServers": {
"featurefusion": {
"url": "http://localhost:5141/mcp"
}
}
}
- Package README:
src/BuildingBlocks/Mcp/PACKAGE_README.md - Docs:
docs/building-blocks/mcp.md· ADR0002· test matrix - Lab (Development):
orders.create,products.list,demo.echo,lab.pingathttp://localhost:5141/mcp - Catalog:
docs/linkedin-posts.md→mcp-message-tools(planned)
BuildingBlocks.Pagination.EntityFrameworkCore
Typed keyset (cursor) pagination for EF Core. One package — SortKey / cursors ship inside it. Hosts map a sort enum to a prebuilt key — the library never reflects "Price" into a property. Unique last column required. Set SigningKey on public HTTP APIs. Dapper is an in-repo lab project, not a nupkg. There is no IEnumerable adapter.
dotnet add package BuildingBlocks.Pagination.EntityFrameworkCore
Requires .NET 8 / 9 / 10.
var key = SortKey.For<Product>()
.By(p => p.Price)
.ThenByUnique(p => p.Id);
var page = await db.Products
.AsNoTracking()
.ToCursorPageAsync(new CursorRequest(cursor, 20), key);
Optional PaginationOptions.Hint defaults to None. ReadUncommitted is SQL Server session isolation (not WITH (NOLOCK)): EF starts one transaction around COUNT+PAGE when there is no ambient transaction, then restores READ COMMITTED on the still-open connection; ambient is ignored; PostgreSQL and Sqlite ignore it. Host AsNoTracking / Dapper WITH (NOLOCK) still work. Host OrderBy is replaced by the SortKey. Composite indexes should match each key, e.g. (Price, Id) and (CreatedAt, Id). Nullable T? sort columns are unsupported. Guid CLR order is not SQL Server uniqueidentifier order. NullOrder is seek-predicate only (no SQL NULLS FIRST/LAST). Updates to a sort column can make a row vanish or reappear (inherent keyset).
Indexed keyset on file SQLite SQL with index (Price, Id), page 20. --probe is Stopwatch (1 warmup + 5 repeats), not BenchmarkDotNet, not Dry, not EF InMemory. Times below are this machine’s catalog; do not treat them as PostgreSQL or SQL Server timings. KB is mean managed allocations per page (GC.GetAllocatedBytesForCurrentThread), not process working set. First page is cheap for all three; allocations stay in the same band (~75–86 KB) because each call opens a context and materializes 20 rows.
10 million rows (--probe 10000000):
| Skip | OFFSET | FeatureFusion | MR 1.5.0 |
|---|---|---|---|
| 0 | 0.5 ms / 77 KB | 0.6 ms / 79 KB | 0.5 ms / 75 KB |
| 1,000,000 | 29.7 ms / 77 KB | 15.5 ms / 85 KB | 18.2 ms / 86 KB |
| 5,000,000 | 154.9 ms / 77 KB | 17.8 ms / 85 KB | 19.9 ms / 86 KB |
100 million rows (--probe 100000000):
| Skip | OFFSET | FeatureFusion | MR 1.5.0 |
|---|---|---|---|
| 0 | 0.6 ms / 77 KB | 0.7 ms / 79 KB | 0.6 ms / 75 KB |
| 10,000,000 | 737.9 ms / 75 KB | 379.0 ms / 84 KB | 427.0 ms / 84 KB |
| 50,000,000 | 2470.4 ms / 75 KB | 177.2 ms / 83 KB | 218.0 ms / 85 KB |
At skip 50M on this catalog, FeatureFusion is about 14× OFFSET (177 ms vs 2470 ms). SQLite plans are not SQL Server or PostgreSQL plans. Reproduce:
dotnet run -c Release --project benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks -- --filter *CursorCodec*
dotnet run -c Release --project benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks -- --filter *Keyset*
dotnet run -c Release --project benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks -- --probe 10000000
dotnet run -c Release --project benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks -- --probe 100000000
- Package README:
Pagination.EntityFrameworkCore(includes the table) - Docs:
docs/building-blocks/pagination.md· ADR0003· test matrix - Lab: FeatureFusion PostgreSQL catalog —
GET /api/v2/products-page(Minimal API EF; POST kept) ·POST /api/v2/Product/products(MVC EF) ·POST /api/v2/Product/products-dapper(Dapper project showcase) · MCPproducts.list— sameGetProductsQuery. See Pagination showcase. - Catalog:
docs/linkedin-posts.md→cursor-pagination
BuildingBlocks.Telemetry
Config-driven OpenTelemetry for ASP.NET Core: traces, metrics, and logs from one AddTelemetry call. Export OTLP to any backend (SigNoz, collectors, Tempo, Azure Monitor). Requires IHostApplicationBuilder. This package is not a SigNoz SDK — local Aspire SigNoz lives in BuildingBlocks.Aspire.Hosting.SigNoz.
1.0.1: IntegrateMediator also AddMeter("BuildingBlocks.Mediator") so Send metrics export with traces (TelemetryDefaults.MediatorMeter). 1.0.2: IntegrateMcp (default off) adds ActivitySource BuildingBlocks.Mcp.
dotnet add package BuildingBlocks.Telemetry
Libraries still need their own UseTelemetry() (Mediator / MCP) so they emit. Integrate* only registers the source/meter so the host exports.
Quick start
builder.AddTelemetry(o =>
{
o.IntegrateMediator = true;
o.IntegrateMcp = true; // default false
o.Instrumentation.EventBus = true; // default false
});
Set OTEL_EXPORTER_OTLP_ENDPOINT. Do not call AddTelemetry twice.
All options
Values below are defaults unless marked opt-in. Prefer OTEL_EXPORTER_OTLP_* over Exporters.Otlp.Endpoint. If FeatureFusion ServiceDefaults already calls AddTelemetry, pass this callback there — AddServiceDefaults is not in this package.
builder.AddTelemetry(o =>
{
o.ServiceName = null; // empty → ApplicationName
o.ServiceNamespace = null;
o.ServiceVersion = null;
o.ResourceAttributes["team"] = "platform";
o.EnableTracing = true;
o.EnableMetrics = true;
o.EnableLogging = true;
o.IntegrateMediator = true; // default true — AddSource + AddMeter
o.IntegrateMcp = true; // default false — AddSource BuildingBlocks.Mcp
o.Sources.Add("MyApp");
o.Meters.Add("MyApp");
o.TracesSamplerRatio = null; // null: AlwaysOn in Development
o.AlwaysOnSamplerInDevelopment = true;
o.SetErrorStatusOnException = true;
o.EnableTraceBasedExemplars = true;
var i = o.Instrumentation;
i.AspNetCore = true;
i.HttpClient = true;
i.Runtime = true;
i.Npgsql = true;
i.IncludeFrameworkMeters = true;
i.FilterHealthCheckRequests = true; // /health, /alive, /ready, /metrics
i.RecordException = true;
i.SqlClient = false;
i.EventBus = true;
i.MassTransit = false;
i.ConfigureAspNetCore = opts =>
opts.EnrichWithHttpRequest = (activity, request) => activity.SetTag("http.route", request.Path);
i.ConfigureHttpClient = opts => { };
i.ConfigureSqlClient = opts =>
opts.EnrichWithSqlCommand = (activity, command) =>
activity.SetTag("db.command_type", command.CommandType.ToString());
o.Exporters.Otlp.Enabled = false;
o.Exporters.Otlp.Endpoint = null;
o.Exporters.Otlp.Headers = null;
o.Exporters.Otlp.Protocol = TelemetryOtlpProtocol.Grpc; // ignored on env fast-path
o.Exporters.Otlp.ProtocolName = null;
o.Exporters.Console.Enabled = false;
o.Exporters.AzureMonitor.Enabled = false;
o.Exporters.AzureMonitor.ConnectionString = null;
},
configureBuilder: t =>
{
t.AddSource("DbMigrations");
t.AddMeter("DbMigrations");
t.ConfigureResource(r => { });
t.ConfigureTracing(tr => tr
.AddEntityFrameworkCoreInstrumentation()
.AddRedisInstrumentation());
t.ConfigureMetrics(m => { });
t.ConfigureLogging(l => { });
});
using var activity = TelemetryActivity.Start("MyApp", "Checkout");
activity?.SetTag("order.id", id);
{
"Telemetry": {
"EnableTracing": true,
"EnableMetrics": true,
"EnableLogging": true,
"IntegrateMediator": true,
"IntegrateMcp": false,
"Sources": [ "MyApp" ],
"Meters": [ "MyApp" ],
"TracesSamplerRatio": null,
"AlwaysOnSamplerInDevelopment": true,
"SetErrorStatusOnException": true,
"EnableTraceBasedExemplars": true,
"Instrumentation": {
"AspNetCore": true,
"HttpClient": true,
"Runtime": true,
"Npgsql": true,
"IncludeFrameworkMeters": true,
"FilterHealthCheckRequests": true,
"SqlClient": false,
"EventBus": false,
"MassTransit": false
},
"Exporters": {
"Otlp": { "Enabled": false, "Protocol": "Grpc" },
"Console": { "Enabled": false },
"AzureMonitor": { "Enabled": false }
}
}
}
ConfigureAspNetCore / ConfigureHttpClient / ConfigureSqlClient are code-only. Lab FeatureFusion: pass the same options into AddServiceDefaults (do not also call AddTelemetry).
OTLP (prefer env)
OTLP turns on when OTEL_EXPORTER_OTLP_ENDPOINT is set (or Telemetry:Exporters:Otlp:Enabled / Endpoint). Prefer env so the same binary works in Aspire, CI, and production.
| Endpoint | OTEL_EXPORTER_OTLP_ENDPOINT (e.g. http://localhost:4317) |
| Protocol | OTEL_EXPORTER_OTLP_PROTOCOL (grpc or http/protobuf) |
| Headers | OTEL_EXPORTER_OTLP_HEADERS |
Env-only OTLP uses UseOtlpExporter() for traces, metrics, and logs. On that fast-path, Exporters.Otlp.Protocol in options is ignored — set OTEL_EXPORTER_OTLP_PROTOCOL. Setting Exporters.Otlp.Endpoint / Headers or Console exporter switches to per-signal AddOtlpExporter (do not mix the two styles).
Azure Monitor: APPLICATIONINSIGHTS_CONNECTION_STRING or Exporters.AzureMonitor (can coexist with OTLP). Console is for local debug.
Development sampling is AlwaysOn unless TracesSamplerRatio is set. Production: set a ratio (0.0–1.0, ParentBased). Health paths /health, /alive, /ready, /metrics are filtered by default.
Mediator / MCP (two switches)
| Library | Emits (library) | Host exports (AddTelemetry) |
|---|---|---|
| Mediator | cfg.UseTelemetry() |
IntegrateMediator → AddSource + AddMeter (TelemetryDefaults.MediatorMeter) |
| MCP | o.UseTelemetry() on MCP builder |
IntegrateMcp → AddSource (BuildingBlocks.Mcp) |
Filter spans with telemetry.component (mediator, mcp, npgsql, …). Manual spans: AddSource("MyApp") then TelemetryActivity.Start("MyApp", "Checkout").
Startup: one Information log of signals and instrumentation — never endpoints or secrets. Empty backend with telemetry “on” usually means no OTLP endpoint.
| Capability | What it does |
|---|---|
AddTelemetry |
Traces + metrics + logs, resource deployment.environment |
| ASP.NET / HttpClient / Runtime / Npgsql | On by default; SqlClient / EventBus / MassTransit opt-in |
IntegrateMediator |
ActivitySource and Meter for BuildingBlocks.Mediator |
IntegrateMcp |
Opt-in ActivitySource BuildingBlocks.Mcp (default off) |
TelemetryBuilder |
ConfigureTracing / AddSource / AddMeter for EF, Redis, extra meters |
- Package README:
src/BuildingBlocks/Telemetry/PACKAGE_README.md - Docs: telemetry
BuildingBlocks.Aspire.Hosting.SigNoz
Local-dev Aspire AppHost integration: ClickHouse, ZooKeeper, schema migrator, OTLP collector, and SigNoz UI. Not for production — production still uses BuildingBlocks.Telemetry against any OTLP endpoint. Docker required. TFM net10.0 (Aspire 13.4.6).
dotnet add package BuildingBlocks.Aspire.Hosting.SigNoz
Quick start
var signoz = builder.AddSigNoz("signoz")
.WithUi()
.WithDashboards();
builder.AddProject<Projects.Api>("api")
.WithSigNozOtlpExporter(signoz);
Run the AppHost https profile. Add .WithDataVolume() for durable ClickHouse/ZooKeeper.
All options
var jwt = builder.AddParameter("signoz-jwt", secret: true);
var signoz = builder.AddSigNoz(
name: "signoz",
port: 8080,
otlpGrpcPort: 4317,
otlpHttpPort: 4318,
jwtSecret: jwt,
configure: o =>
{
o.Lifetime = ContainerLifetime.Persistent;
o.CollectorConfigPath = null;
o.SigNozImage = "signoz/signoz";
o.SigNozTag = "v0.136.1";
o.CollectorImage = "signoz/signoz-otel-collector";
o.CollectorTag = "v0.144.6";
o.SchemaMigratorImage = "signoz/signoz-otel-collector";
o.SchemaMigratorTag = o.CollectorTag;
o.ClickHouseImage = "clickhouse/clickhouse-server";
o.ClickHouseTag = "25.12.5";
o.ZooKeeperImage = "signoz/zookeeper";
o.ZooKeeperTag = "3.7.1";
o.UiCredentials.AdminEmail = "[email protected]";
o.UiCredentials.AdminPassword = "Admin@Signoz1";
o.UiCredentials.AdminName = "Local Admin";
o.UiCredentials.OrgName = "default";
})
.WithUi(port: 8080, adminEmail: "[email protected]", adminPassword: "DevPassword123!", adminName: "Local Admin", orgName: "default")
.WithDashboards()
.WithDataVolume(name: null, isReadOnly: false);
// .WithDataBindMount(@"D:\signoz-data", isReadOnly: false);
builder.AddProject<Projects.Api>("api")
.WithSigNozOtlpExporter(signoz, SigNozOtlpProtocol.Grpc);
Method port / otlp* win over SigNozOptions. WithUi overrides o.UiCredentials. Lab: WithUiFromConfiguration (SigNoz__UiPort, SigNoz__AdminEmail, …) is FeatureFusion AppHost, not this package.
| API | Role |
|---|---|
AddSigNoz |
ZooKeeper, ClickHouse, migrator, collector, UI + SigNozOptions (tags, lifetime, collector config, UI credentials) |
WithUi |
Host port + local admin credentials (password policy applies) |
WithDashboards |
Seeds ASP.NET Core + BuildingBlocks dashboards |
WithDataVolume / WithDataBindMount |
Persist ClickHouse and ZooKeeper |
WithSigNozOtlpExporter |
OTEL_EXPORTER_OTLP_* on a ProjectResource only (Grpc or HttpProtobuf) |
- Package README:
src/BuildingBlocks/Aspire.Hosting.SigNoz/PACKAGE_README.md - Docs: telemetry · alerts
Lab
Install the packages above in your own hosts, or clone this repo and run FeatureFusion — a showcase API + AppHost that already wires Mediator, MCP, Telemetry, and SigNoz.
| Area | What you get |
|---|---|
| Mediator (CQRS) | BuildingBlocks.Mediator — used by FeatureFusion handlers |
| MCP | BuildingBlocks.Mcp — opt-in tools ([McpTool] on types/methods or MapTool) at /mcp |
| Telemetry | BuildingBlocks.Telemetry in ServiceDefaults; BuildingBlocks.Aspire.Hosting.SigNoz on AppHost |
| Event bus | RabbitMQ + transactional outbox/inbox, DLQ, dedup hooks |
| Aspire lab | AppHost orchestration for Postgres, Redis, RabbitMQ, Memcached, SigNoz |
| IdempotentFusion | ULID Idempotency-Key + Redis status tracking + optional lock |
| Feature flags (demo) | ASP.NET Core Feature Management + custom filters (claims / VIP) |
| API surface | Versioned controllers + Minimal APIs, FluentValidation patterns |
| Gateway | YARP reverse proxy + Memcached distributed rate limiting |
| Caching | Redis / Memcached / memory managers + middleware demos |
| Pagination | BuildingBlocks.Pagination.EntityFrameworkCore — PostgreSQL product catalog via GET /api/v2/products-page (same query on MVC, Dapper, MCP); Dapper is in-repo only |
| Design patterns | Mediator, Decorator, CoR, Strategy, and more — see below |
Also in the lab: app/DB initializers, middleware dynamic caching, Aspire AppHost integration tests, and performance-minded practices (OTel hooks, resilience).
Pagination showcase
FeatureFusion is the runnable integration of BuildingBlocks.Pagination.EntityFrameworkCore — a real PostgreSQL catalog (~1000 seeded products), not a sample-only API.
One GetProductsQuery drives:
| Surface | Endpoint |
|---|---|
| Minimal API (EF) | GET /api/v2/products-page (POST kept for compatibility) |
| MVC (EF) | POST /api/v2/Product/products |
| Dapper | POST /api/v2/Product/products-dapper |
| MCP | products.list |
What that path demonstrates: typed SortKey / SortKeyRegistry, composite keyset order (Price + Id, Name + Id, CreatedAt + Id), unique Id tie-breaker, forward and backward cursors, first-page TotalCount, CancellationToken, HasKeysetIndex, EF Core SQL projection, and the in-repo Dapper adapter. Query names are case-insensitive (limit / Limit). sortBy: Id · Name · Price · CreatedAt. sortDirection: Ascending · Descending. Empty cursor + pageDirection=Backward is the last page. Cursors are opaque — pass NextCursor / PreviousCursor back unchanged; do not construct them. FeatureFusion is PostgreSQL: QueryHint stays None.
GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending
Response includes items, hasMore, nextCursor, previousCursor, hasPrevious, and totalCount on this first page. Then:
GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor=<NextCursor>
GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor=<PreviousCursor>
Swagger: http://localhost:5141/swagger. Details: docs/building-blocks/pagination.md · NuGet.
Aspire-hosted functional tests and Compose need Docker.
Architecture
flowchart LR
Client([HTTP clients]) --> FF[FeatureFusion API]
Client --> GW[ApiGateway]
GW --> FF
subgraph aspireHost [Aspire AppHost]
FF
PG[(Postgres)]
RD[(Redis)]
RMQ[[RabbitMQ]]
MC[(Memcached)]
SZ[SigNoz]
end
FF --> PG
FF --> RD
FF --> RMQ
FF --> MC
FF -->|"OTLP"| SZ
GW --> MC
Stack
.NET 10 (net10.0) for the lab · packages also target net8 / net9 where noted · Aspire 13.4.x · FluentValidation · Feature Management · Memcached (Enyim)
Repository layout
FeatureFusion.sln # .NET only
src/ # C# only
BuildingBlocks/
Mediator/ # CQRS Send + pipeline NuGet
Mediator.Analyzers/
Mcp/ # [McpTool] / MapTool → MCP tools NuGet
Mcp.Analyzers/
Pagination/ # keyset IR (not packable; bundled into EF nupkg)
Pagination.EntityFrameworkCore/ # THE pagination NuGet (EF Core layout)
Pagination.Dapper/ # lab/dev project only (not a nupkg)
Telemetry/ # Config-driven OpenTelemetry NuGet
Aspire.Hosting.SigNoz/ # AddSigNoz() Aspire hosting NuGet
Lab/
FeatureFusion/ # Web API showcase (Features/, Infrastructure/, Controllers, Minimal APIs)
FeatureFusion.ApiGateway/ # YARP + Memcached rate limiter
FeatureFusion.AppHost/ # Aspire AppHost (+ SigNoz stack)
FeatureFusion.ServiceDefaults/
EventBus/ # Reusable RabbitMQ event bus (namespaces stay EventBusRabbitMQ)
web/ # reserved — Next.js project root (not created yet; not in the .sln)
tests/
BuildingBlocks/
Mediator.Tests/
Mediator.Analyzers.Tests/
Mcp.Tests/
Mcp.Analyzers.Tests/
Pagination.Tests/
Pagination.EntityFrameworkCore.Tests/
Pagination.Dapper.Tests/
Telemetry.Tests/
Aspire.Hosting.SigNoz.Tests/
Lab/
IntegrationTests/ # Aspire fixture · EventBus + HTTP API smoke
FeatureFusion.Tests/ # Unit / filter / mediator
FeatureFusion.ApiGateway.Tests/
FeatureFusion.Common/
benchmarks/BuildingBlocks/Mediator.Benchmarks/
benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks/
deploy/signoz/alerts/ # Repo-owned SigNoz alert samples (not packaged)
docs/
linkedin-posts.md # Post ↔ code map
building-blocks/
Preferred vertical-slice shape
Features/{Name}/
Commands/
Queries/
Behaviors/
IntegrationEvents/
Prerequisites
| Tool | Why |
|---|---|
| .NET 10 SDK | Build & run the lab (package tests also use 8 / 9 SDKs in CI) |
| Docker Desktop (Linux containers, running) | Aspire resources, Compose, functional tests |
| Aspire dashboard (optional) | Resource graph when using AppHost |
If the Aspire dashboard shows Container runtime not installed while docker info works, set DOTNET_ASPIRE_CONTAINER_RUNTIME=docker (AppHost already sets this) and restart the IDE/terminal so PATH includes Docker CLI.
Run the lab
Option A — Aspire AppHost (recommended)
dotnet run --project src/Lab/FeatureFusion.AppHost
Starts Postgres, Redis, RabbitMQ, Memcached, SigNoz, and FeatureFusion. Open the Aspire dashboard URL printed in the console.
The SigNoz UI always shows a login page. Root-user env vars skip the first-run signup wizard; they do not disable auth. This lab’s credentials come from src/Lab/FeatureFusion.AppHost/appsettings.Development.json via WithUiFromConfiguration (override with SigNoz__AdminEmail / SigNoz__AdminPassword):
[email protected] |
|
| Password | DevPassword123! |
Those custom credentials are not shown on the Aspire resource panel (only package-default WithUi() creds are). Package defaults, if you call WithUi() with no overrides, are [email protected] / Admin@Signoz1. If login fails after changing email, delete the persistent SigNoz sqlite volume and restart AppHost.
Option B — Docker Compose
docker compose up -d --build
Uses SDK / ASP.NET 10.0 images plus supporting services.
Option C — API only
dotnet restore FeatureFusion.sln
dotnet run --project src/Lab/FeatureFusion --launch-profile https
Point connection strings in appsettings.*.json (or user secrets) at your local infra.
POST /api/v1/Auth/loginwithvipuser/vippasswordGET /api/v1/Greeting/custom-greetingwithAuthorization: Bearer <token>
Lab features
RabbitMQ EventBus (outbox / inbox / DLQ)
Transactional outbox with optional direct publish fallback, inbox/dedup hooks, DLX, and Aspire-hosted integration tests.
Setup: AppHost or Compose, then dotnet test tests/Lab/IntegrationTests.
LinkedIn: see the catalog.
Distributed rate limiting (YARP + Memcached)
IP-based fixed-window limiting at the reverse proxy with Memcached-backed counters. Excess traffic receives 429 Too Many Requests.
docker compose up -d
# point traffic at the ApiGateway (see launchSettings / appsettings)
Feature management filters
Conditional features via Microsoft.FeatureManagement and custom filters (e.g. VIP claims). Versioned controllers and Minimal APIs under /api/v1|v2/....
IdempotentFusion
REST idempotency with ULID keys and Redis status tracking (POST /api/v2/Order/order).
API versioning & validation
Controllers + Minimal API groups; FluentValidation via controllers, generic endpoint filters, and WithValidation / MapPostWithValidation.
Caching, middleware & pagination
Redis / Memcached / memory managers, feature-flagged recommendation cache middleware, and DB migration/seed initializers.
Generic bidirectional cursor (keyset) pagination
See Pagination showcase for the FeatureFusion catalog (GET /api/v2/products-page). Package API, QueryHint, and SQLite probe numbers: PACKAGE_README.
- Indexes:
(Price, Id),(CreatedAt, Id),(Name, Id)onproducts(ASC and DESC variants) - LinkedIn: Reusable Cursor (keyset) Pagination
Design patterns
| Pattern | Where it shows up |
|---|---|
| Mediator / CQRS | BuildingBlocks.Mediator — ICommand/IQuery Send + pipeline; host handlers in FeatureFusion |
| CQRS | Features/.../Commands + Queries with dedicated handlers |
| Void command | ICommand : ICommand<Unit> — concrete type in pipeline (no Adapter / IRequest) |
| Decorator | Pipeline behaviors; EventBus handler decorators in tests |
| Singleton | Cached mediator wrappers / long-lived Redis multiplexer |
| Factory | Resilience / connection helpers; gateway Memcached factory |
| Repository / DbContext | EF Core CatalogDbContext + feature handlers |
| Unit of work | ResilientTransaction spanning business write + outbox |
| Strategy | Feature filters & validation styles (controller vs Minimal API) |
| Template method | BaseValidator.PostInitialize |
| Keyset pagination | BuildingBlocks.Pagination.EntityFrameworkCore — typed bidirectional cursors |
| Chain of Responsibility | Feature toggle rule evaluation; mediator pipeline chain |
| Observer / messaging | RabbitMQ integration events (outbox → bus → handlers) |
| Outbox / Inbox | TransactionalOutbox + OutBoxWorker |
| Polling publisher | OutBoxWorker background poll → publish |
| Dead letter queue | EventBus DLX / DLQ topology |
| Message deduplication | Inbox + MessageDeduplicationService |
| Idempotency | IdempotentAttribute + Redis status tracking |
| Feature toggle | ASP.NET Core Feature Management + custom filters |
| Rate limiting | ApiGateway Memcached fixed-window limiter |
| Circuit breaker / resilience | Polly ResiliencePipelineFactory |
| Options | AddOptions / IOptions<> for EventBus, Redis, Memcached |
| Middleware pipeline | RecommendationCacheMiddleware |
| Cache-aside | Memcached/Redis GetValueOrCreateAsync |
| Result object | Result<T> + Match / HTTP mapping |
| API Gateway / reverse proxy | YARP FeatureFusion.ApiGateway |
| API versioning | Asp.Versioning on controllers and Minimal APIs |
| Dependency Injection | Program / BuilderExtensions composition |
LinkedIn catalog
Post ↔ code map: docs/linkedin-posts.md · Follow on LinkedIn
BuildingBlocks.Mediator: NuGet v1.0.1 · manual pipeline (prior)
What's next
This remains a public .NET lab. Near-term direction:
- More BuildingBlocks.* packages extracted from the showcase
- Frontend showcase (
web/, Next.js project root) - Keep the LinkedIn catalog in sync when new posts ship (
mcp-message-toolsplanned) - Pub/sub stays a sibling story (not Mediator notifications)
Testing
dotnet test FeatureFusion.sln -c Release
| Project | Notes |
|---|---|
BuildingBlocks.Mediator.Tests |
Package suite on net8 / net9 / net10 |
BuildingBlocks.Mediator.Analyzers.Tests |
BBM001 / BBM002 |
BuildingBlocks.Mcp.Tests |
Catalog, invoker, endpoint methods, MapTool scoped SP, idempotency, filters |
BuildingBlocks.Mcp.Analyzers.Tests |
BBMCP001–005 |
BuildingBlocks.Pagination.Tests |
Codec, registry, identifiers (net8 / net9 / net10) |
BuildingBlocks.Pagination.EntityFrameworkCore.Tests |
Sqlite keyset + shadow + projection |
BuildingBlocks.Pagination.Dapper.Tests |
Sqlite execute + dialect SQL asserts |
BuildingBlocks.Telemetry.Tests |
AddTelemetry / IntegrateMediator / IntegrateMcp |
BuildingBlocks.Aspire.Hosting.SigNoz.Tests |
AppHost integration |
IntegrationTests |
Shared Aspire fixture — EventBus, HTTP API smoke, MCP /mcp (Api/FeatureFusionMcpTests) |
FeatureFusion.Tests |
Unit / filter / mediator (single-dependency containers where useful) |
FeatureFusion.ApiGateway.Tests |
Memcached-backed limiter tests |
API / functional coverage uses the Aspire fixture in IntegrationTests (dynamic ports; stop a local AppHost if you still hit conflicts).
Contributing
PRs welcome. Prefer vertical-slice feature folders, XML docs on public APIs, constants over magic strings, and tests + catalog updates when behavior changes. See CONTRIBUTING.md.
License: MIT — see LICENSE.txt.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found