Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ dotnet test PatternKit.slnx -c Release
* **Messaging Backplane Facade:** `BackplaneFacadeDemo` (+ `BackplaneFacadeDemoTests`) — host setup, request/reply, pub/sub, outbox, idempotency, and mailbox-backed transport subscribers.
* **Product Gateway Routing:** `ProductGatewayRoutingDemo` (+ `ProductGatewayRoutingDemoTests`) — fluent and generated downstream route dispatch with DI and ASP.NET Core mapping.
* **Checkout Strangler Fig Migration:** `CheckoutStranglerFigDemo` (+ `CheckoutStranglerFigDemoTests`) — fluent and generated legacy-to-modern checkout routing with DI and ASP.NET Core mapping.
* **Order Telemetry Sidecar:** `OrderTelemetrySidecarDemo` (+ `OrderTelemetrySidecarDemoTests`) — fluent and generated companion telemetry behavior with DI and ASP.NET Core mapping.
* **Production-Ready Example Catalog:** `PatternKitExampleCatalog` (+ `PatternKitExampleCatalogTests`) — DI registration, generic host validation, ASP.NET Core endpoint mapping, and source/test/docs manifest checks.
* **Tests:** `PatternKit.Examples.Tests/*` use TinyBDD scenarios that read like specs.

Expand Down
12 changes: 12 additions & 0 deletions docs/examples/order-telemetry-sidecar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Order Telemetry Sidecar

The order telemetry sidecar example adds trace context and telemetry capture around an order submission handler.

```csharp
services.AddOrderTelemetrySidecarDemo();

var runner = provider.GetRequiredService<OrderTelemetrySidecarDemoRunner>();
var result = runner.RunGenerated(new OrderTelemetryRequest("O-100", 42m));
```

The example includes fluent and source-generated construction, an `IServiceCollection` extension, and an ASP.NET Core minimal API mapping through `MapOrderTelemetrySidecar()`.
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,6 @@

- name: Checkout Strangler Fig Migration
href: checkout-strangler-fig.md

- name: Order Telemetry Sidecar
href: order-telemetry-sidecar.md
5 changes: 5 additions & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Gateway Aggregation**](gateway-aggregation.md) | API gateway response composition factories | `[GenerateGatewayAggregation]` |
| [**Gateway Routing**](gateway-routing.md) | API gateway route dispatch factories | `[GenerateGatewayRouting]` |
| [**Strangler Fig**](strangler-fig.md) | Legacy-to-modern migration routing factories | `[GenerateStranglerFig]` |
| [**Sidecar**](sidecar.md) | Companion behavior pipeline factories | `[GenerateSidecar]` |

## Quick Reference

Expand Down Expand Up @@ -308,6 +309,10 @@ public static partial class ProductGatewayRouting { }
// Strangler Fig - generated legacy-to-modern migration routing
[GenerateStranglerFig(typeof(CheckoutMigrationRequest), typeof(CheckoutMigrationResponse))]
public static partial class CheckoutMigration { }

// Sidecar - generated companion behavior around a primary handler
[GenerateSidecar(typeof(OrderTelemetryRequest), typeof(OrderTelemetryResponse))]
public static partial class OrderTelemetrySidecar { }
```

## Examples
Expand Down
25 changes: 25 additions & 0 deletions docs/generators/sidecar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Sidecar Generator

`[GenerateSidecar]` creates a typed `Sidecar<TRequest, TResponse>` factory from before steps, after steps, and one primary handler.

```csharp
[GenerateSidecar(typeof(OrderTelemetryRequest), typeof(OrderTelemetryResponse), SidecarName = "order-telemetry-sidecar")]
public static partial class OrderSidecars
{
[SidecarBefore("trace-context")]
private static void AddTrace(SidecarContext<OrderTelemetryRequest> ctx) => ctx.Items["trace-id"] = "trace-1";

[SidecarAfter("telemetry")]
private static void Capture(SidecarContext<OrderTelemetryRequest> ctx, OrderTelemetryResponse response) { }

[SidecarHandler]
private static OrderTelemetryResponse Submit(SidecarContext<OrderTelemetryRequest> ctx) => new("accepted", "trace-1");
}
```

Diagnostics:

- `PKSC001`: host type must be partial.
- `PKSC002`: at least one companion step and exactly one handler are required.
- `PKSC003`: before, after, or handler signature is invalid.
- `PKSC004`: companion step names must be unique.
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@
- name: Strangler Fig
href: strangler-fig.md

- name: Sidecar
href: sidecar.md

- name: Queue Load Leveling
href: queue-load-leveling.md

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Cloud Architecture | Gateway Aggregation | `GatewayAggregation<TRequest,TResponse>` | Gateway Aggregation generator |
| Cloud Architecture | Gateway Routing | `GatewayRouting<TRequest,TResponse>` | Gateway Routing generator |
| Cloud Architecture | Strangler Fig | `StranglerFig<TRequest,TResponse>` | Strangler Fig generator |
| Cloud Architecture | Sidecar | `Sidecar<TRequest,TResponse>` | Sidecar generator |
| Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator |
| Application Architecture | Specification | `Specification<T>` and named registries | Specification generator |
| Application Architecture | Repository | `IRepository<TEntity,TKey>` and `InMemoryRepository<TEntity,TKey>` | Repository generator |
Expand Down
18 changes: 18 additions & 0 deletions docs/patterns/cloud/sidecar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Sidecar

Sidecar attaches companion behavior to a primary operation without mixing that behavior into the application handler.

```csharp
var sidecar = Sidecar<OrderTelemetryRequest, OrderTelemetryResponse>
.Create("order-telemetry-sidecar")
.Before("trace-context", ctx => ctx.Items["trace-id"] = $"trace-{ctx.Request.OrderId}")
.After("telemetry", (ctx, response) => telemetry.Capture("order.accepted", response.Confirmation))
.Handle(ctx => new($"ACCEPTED-{ctx.Request.OrderId}", (string)ctx.Items["trace-id"]))
.Build();

var result = sidecar.Invoke(request);
```

Use it when request tracing, metrics, connectivity policy, local proxy behavior, or other companion responsibilities should be modeled beside the primary app capability. The runtime result records completed companion steps and reports primary or companion failures explicitly.

The source-generated path uses `[GenerateSidecar]`, `[SidecarBefore]`, `[SidecarAfter]`, and `[SidecarHandler]`. Import the example through `AddOrderTelemetrySidecarDemo()` or `AddPatternKitExamples()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@
href: cloud/gateway-routing.md
- name: Strangler Fig
href: cloud/strangler-fig.md
- name: Sidecar
href: cloud/sidecar.md
- name: Application Architecture
items:
- name: Anti-Corruption Layer
Expand Down
169 changes: 169 additions & 0 deletions src/PatternKit.Core/Cloud/Sidecar/Sidecar.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
namespace PatternKit.Cloud.Sidecar;

public sealed class SidecarContext<TRequest>
{
internal SidecarContext(TRequest request)
=> Request = request;

public TRequest Request { get; }

public IDictionary<string, object> Items { get; } = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);

public IList<string> Events { get; } = new List<string>();

public void Record(string eventName)
{
if (string.IsNullOrWhiteSpace(eventName))
throw new ArgumentException("Event name is required.", nameof(eventName));

Events.Add(eventName);
}
}

public sealed class SidecarResult<TResponse>
{
private SidecarResult(string sidecarName, TResponse? response, IReadOnlyList<string> events, Exception? exception, bool succeeded)
=> (SidecarName, Response, Events, Exception, Succeeded) = (sidecarName, response, events, exception, succeeded);

public string SidecarName { get; }

public TResponse? Response { get; }

public IReadOnlyList<string> Events { get; }

public Exception? Exception { get; }

public bool Succeeded { get; }

public bool Failed => !Succeeded;

public static SidecarResult<TResponse> Success(string sidecarName, TResponse response, IReadOnlyList<string> events)
=> new(sidecarName, response ?? throw new ArgumentNullException(nameof(response)), events, null, true);

public static SidecarResult<TResponse> Failure(string sidecarName, IReadOnlyList<string> events, Exception exception)
=> new(sidecarName, default, events, exception ?? throw new ArgumentNullException(nameof(exception)), false);
}

public sealed class Sidecar<TRequest, TResponse>
{
private readonly IReadOnlyList<BeforeStep> _before;
private readonly IReadOnlyList<AfterStep> _after;
private readonly Func<SidecarContext<TRequest>, TResponse> _handler;

private Sidecar(string name, IReadOnlyList<BeforeStep> before, IReadOnlyList<AfterStep> after, Func<SidecarContext<TRequest>, TResponse>? handler)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Sidecar name is required.", nameof(name));
if (before is null)
throw new ArgumentNullException(nameof(before));
if (after is null)
throw new ArgumentNullException(nameof(after));
if (before.Count == 0 && after.Count == 0)
throw new InvalidOperationException("Sidecar requires at least one companion step.");

Name = name;
_before = before;
_after = after;
_handler = handler ?? throw new InvalidOperationException("Sidecar requires a primary handler.");
}

public string Name { get; }

public SidecarResult<TResponse> Invoke(TRequest request)
{
if (request is null)
throw new ArgumentNullException(nameof(request));

var context = new SidecarContext<TRequest>(request);
try
{
foreach (var step in _before)
{
step.Execute(context);
context.Record(step.Name);
}

var response = _handler(context);
if (response is null)
return SidecarResult<TResponse>.Failure(Name, context.Events.ToArray(), new InvalidOperationException("Sidecar handler returned null."));

foreach (var step in _after)
{
step.Execute(context, response);
context.Record(step.Name);
}

return SidecarResult<TResponse>.Success(Name, response, context.Events.ToArray());
}
catch (Exception ex)
{
return SidecarResult<TResponse>.Failure(Name, context.Events.ToArray(), ex);
}
}

public static Builder Create(string name = "sidecar") => new(name);

public sealed class Builder
{
private readonly string _name;
private readonly List<BeforeStep> _before = [];
private readonly List<AfterStep> _after = [];
private Func<SidecarContext<TRequest>, TResponse>? _handler;

internal Builder(string name) => _name = name;

public Builder Before(string name, Action<SidecarContext<TRequest>> step)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Sidecar step name is required.", nameof(name));
if (step is null)
throw new ArgumentNullException(nameof(step));
if (_before.Any(item => string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"Sidecar before step '{name}' is already registered.");

Comment on lines +115 to +123
_before.Add(new(name, step));
return this;
}

public Builder After(string name, Action<SidecarContext<TRequest>, TResponse> step)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Sidecar step name is required.", nameof(name));
if (step is null)
throw new ArgumentNullException(nameof(step));
if (_after.Any(item => string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"Sidecar after step '{name}' is already registered.");

Comment on lines +128 to +136
_after.Add(new(name, step));
return this;
}

public Builder Handle(Func<SidecarContext<TRequest>, TResponse> handler)
{
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
return this;
}

public Sidecar<TRequest, TResponse> Build() => new(_name, _before.ToArray(), _after.ToArray(), _handler);
}

private sealed class BeforeStep
{
public BeforeStep(string name, Action<SidecarContext<TRequest>> execute)
=> (Name, Execute) = (name, execute);

public string Name { get; }

public Action<SidecarContext<TRequest>> Execute { get; }
}

private sealed class AfterStep
{
public AfterStep(string name, Action<SidecarContext<TRequest>, TResponse> execute)
=> (Name, Execute) = (name, execute);

public string Name { get; }

public Action<SidecarContext<TRequest>, TResponse> Execute { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
using PatternKit.Examples.RepositoryDemo;
using PatternKit.Examples.RetryDemo;
using PatternKit.Examples.ServiceLayerDemo;
using PatternKit.Examples.SidecarDemo;
using PatternKit.Examples.Singleton;
using PatternKit.Examples.SpecificationDemo;
using PatternKit.Examples.StranglerFigDemo;
Expand Down Expand Up @@ -206,6 +207,7 @@ public sealed record TenantExternalConfigurationStoreExample(TenantExternalConfi
public sealed record CustomerDashboardGatewayAggregationExample(CustomerDashboardGatewayAggregationDemoRunner Runner, CustomerDashboardGatewayService Service);
public sealed record CheckoutStranglerFigExample(CheckoutStranglerFigDemoRunner Runner, CheckoutMigrationService Service);
public sealed record ProductGatewayRoutingExample(ProductGatewayRoutingDemoRunner Runner, ProductGatewayRoutingService Service);
public sealed record OrderTelemetrySidecarExample(OrderTelemetrySidecarDemoRunner Runner, OrderTelemetrySidecarService Service);

/// <summary>
/// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection.
Expand Down Expand Up @@ -297,7 +299,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddTenantExternalConfigurationStoreExample()
.AddCustomerDashboardGatewayAggregationExample()
.AddCheckoutStranglerFigExample()
.AddProductGatewayRoutingExample();
.AddProductGatewayRoutingExample()
.AddOrderTelemetrySidecarExample();

public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services)
{
Expand Down Expand Up @@ -1053,6 +1056,15 @@ public static IServiceCollection AddProductGatewayRoutingExample(this IServiceCo
return services.RegisterExample<ProductGatewayRoutingExample>("Product Gateway Routing", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore);
}

public static IServiceCollection AddOrderTelemetrySidecarExample(this IServiceCollection services)
{
services.AddOrderTelemetrySidecarDemo();
services.AddSingleton<OrderTelemetrySidecarExample>(sp => new(
sp.GetRequiredService<OrderTelemetrySidecarDemoRunner>(),
sp.GetRequiredService<OrderTelemetrySidecarService>()));
return services.RegisterExample<OrderTelemetrySidecarExample>("Order Telemetry Sidecar", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore);
}

private static IServiceCollection RegisterExample<T>(
this IServiceCollection services,
string name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog
"docs/examples/product-gateway-routing.md",
ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore,
["Gateway Routing"],
["path-based downstream dispatch", "source-generated gateway router", "ASP.NET Core endpoint mapping"])
["path-based downstream dispatch", "source-generated gateway router", "ASP.NET Core endpoint mapping"]),
Descriptor(
"Order Telemetry Sidecar",
"src/PatternKit.Examples/SidecarDemo/OrderTelemetrySidecarDemo.cs",
"test/PatternKit.Examples.Tests/SidecarDemo/OrderTelemetrySidecarDemoTests.cs",
"docs/examples/order-telemetry-sidecar.md",
ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore,
["Sidecar"],
["trace context enrichment", "source-generated sidecar factory", "ASP.NET Core endpoint mapping"])
];

public IReadOnlyList<PatternKitExampleDescriptor> Entries => Items;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog
"test/PatternKit.Examples.Tests/GatewayRoutingDemo/ProductGatewayRoutingDemoTests.cs",
["fluent route dispatch", "generated gateway routing factory", "DI-importable ASP.NET Core product gateway example"]),

Pattern("Sidecar", PatternFamily.CloudArchitecture,
"docs/patterns/cloud/sidecar.md",
"src/PatternKit.Core/Cloud/Sidecar/Sidecar.cs",
"test/PatternKit.Tests/Cloud/Sidecar/SidecarTests.cs",
"docs/generators/sidecar.md",
"src/PatternKit.Generators/Sidecar/SidecarGenerator.cs",
"test/PatternKit.Generators.Tests/SidecarGeneratorTests.cs",
null,
"docs/examples/order-telemetry-sidecar.md",
"src/PatternKit.Examples/SidecarDemo/OrderTelemetrySidecarDemo.cs",
"test/PatternKit.Examples.Tests/SidecarDemo/OrderTelemetrySidecarDemoTests.cs",
["fluent companion pipeline", "generated sidecar factory", "DI-importable ASP.NET Core order telemetry example"]),

Pattern("CQRS", PatternFamily.ApplicationArchitecture,
"docs/generators/dispatcher.md",
"src/PatternKit.Core/Behavioral/Mediator/Mediator.cs",
Expand Down
Loading
Loading