Skip to content

Repository files navigation

FeatureFusion

BuildingBlocks for .NET — CQRS Send + pipeline, config-driven OpenTelemetry, and a local Aspire SigNoz stack — plus a runnable lab that uses them.

Formerly FeatureManagement (GitHub redirects).

.NET Aspire NuGet · BuildingBlocks.Mediator NuGet · BuildingBlocks.Mcp NuGet · BuildingBlocks.Telemetry NuGet · BuildingBlocks.Aspire.Hosting.SigNoz License: MIT Stars Last commit LinkedIn Repo

Author · Mohammad Hasan Hosseini · Technical Team Lead & .NET enthusiast


Table of contents


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.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
Loading
  1. Mediator dispatches commands/queries through an ordered pipeline. UseTelemetry() wraps Send (not a pipeline behavior) with an ActivitySource and Meter named BuildingBlocks.Mediator.
  2. Telemetry AddTelemetry + IntegrateMediator = true registers that source and meter so spans and mediator.send metrics export with the rest of the host.
  3. SigNoz hosting (optional, local) provisions a collector + UI. WithSigNozOtlpExporter sets OTEL_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

NuGet Downloads

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.


BuildingBlocks.Mcp

NuGet

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"
    }
  }
}

BuildingBlocks.Telemetry

NuGet GitHub Release

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() IntegrateMediatorAddSource + AddMeter (TelemetryDefaults.MediatorMeter)
MCP o.UseTelemetry() on MCP builder IntegrateMcpAddSource (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

BuildingBlocks.Aspire.Hosting.SigNoz

NuGet GitHub Release

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 = "admin@localhost.local";
        o.UiCredentials.AdminPassword = "Admin@Signoz1";
        o.UiCredentials.AdminName = "Local Admin";
        o.UiCredentials.OrgName = "default";
    })
    .WithUi(port: 8080, adminEmail: "dev@local.test", 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)

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 Generic bidirectional keyset (cursor) pagination
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).

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
Loading

Stack

ASP.NET Core EF Core RabbitMQ Redis YARP OpenTelemetry xUnit Docker

.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/
    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/
    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/
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 dev@local.test
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 admin@localhost.local / 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.

Feature-flag greeting smoke
  1. POST /api/v1/Auth/login with vipuser / vippassword
  2. GET /api/v1/Greeting/custom-greeting with Authorization: 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

Reusable, type-safe keyset pagination for EF Core: Base64 JSON cursors (last value + id + sort + direction), dynamic sorting, forward/back navigation, and expression-tree filters — integrated with CQRS / Mediator.


Design patterns

Pattern Where it shows up
Mediator / CQRS BuildingBlocks.MediatorICommand/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; CursorFactory; 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 Infrastructure/CursorPagination — type-safe 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-tools planned)
  • 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.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.

About

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.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

49 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages