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
12 changes: 12 additions & 0 deletions docs/examples/checkout-strangler-fig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Checkout Strangler Fig Migration

The checkout Strangler Fig example routes migrated tenants and pilot orders to a modern checkout service while all other traffic falls back to the legacy system.

```csharp
services.AddCheckoutStranglerFigDemo();

var runner = provider.GetRequiredService<CheckoutStranglerFigDemoRunner>();
var result = runner.RunGenerated(new CheckoutMigrationRequest("enterprise-west", "O-100", 100m));
```

The example includes fluent and source-generated construction, an `IServiceCollection` extension, and an ASP.NET Core minimal API mapping through `MapCheckoutStranglerFig()`.
1 change: 1 addition & 0 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ dotnet test PatternKit.slnx -c Release
* **Resilient Checkout:** `ResilientCheckoutDemo` (+ `ResilientCheckoutDemoTests`) — fallback checkout routes with compensation.
* **Collaborating Mailboxes:** `ServiceCollaborationMailboxDemo` (+ `ServiceCollaborationMailboxDemoTests`) — inventory, payment, shipping, and notification services collaborating through serialized mailboxes.
* **Messaging Backplane Facade:** `BackplaneFacadeDemo` (+ `BackplaneFacadeDemoTests`) — host setup, request/reply, pub/sub, outbox, idempotency, and mailbox-backed transport subscribers.
* **Checkout Strangler Fig Migration:** `CheckoutStranglerFigDemo` (+ `CheckoutStranglerFigDemoTests`) — fluent and generated legacy-to-modern checkout routing 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
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,6 @@

- name: Customer Dashboard Gateway Aggregation
href: customer-dashboard-gateway-aggregation.md

- name: Checkout Strangler Fig Migration
href: checkout-strangler-fig.md
5 changes: 5 additions & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Rate Limiting**](rate-limiting.md) | Key-partitioned fixed-window rate limit policy factories | `[GenerateRateLimitPolicy]` |
| [**External Configuration Store**](external-configuration-store.md) | Typed centralized configuration loaders | `[GenerateExternalConfigurationStore]` |
| [**Gateway Aggregation**](gateway-aggregation.md) | API gateway response composition factories | `[GenerateGatewayAggregation]` |
| [**Strangler Fig**](strangler-fig.md) | Legacy-to-modern migration routing factories | `[GenerateStranglerFig]` |

## Quick Reference

Expand Down Expand Up @@ -298,6 +299,10 @@ public static partial class ProductSearchRateLimitPolicy { }
// Gateway aggregation - generated downstream response composition
[GenerateGatewayAggregation(typeof(CustomerDashboardRequest), typeof(CustomerDashboardResponse))]
public static partial class CustomerDashboardGateway { }

// Strangler Fig - generated legacy-to-modern migration routing
[GenerateStranglerFig(typeof(CheckoutMigrationRequest), typeof(CheckoutMigrationResponse))]
public static partial class CheckoutMigration { }
```

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

`[GenerateStranglerFig]` creates a typed `StranglerFig<TRequest, TResponse>` factory from route predicates and one handler for each side of the migration.

```csharp
[GenerateStranglerFig(typeof(CheckoutMigrationRequest), typeof(CheckoutMigrationResponse), MigrationName = "checkout-strangler")]
public static partial class CheckoutMigration
{
[StranglerFigRoute("enterprise-tenant")]
private static bool IsEnterprise(CheckoutMigrationRequest request) => request.TenantId.StartsWith("enterprise-");

[StranglerFigLegacy]
private static CheckoutMigrationResponse Legacy(CheckoutMigrationRequest request) => LegacySystem.Submit(request);

[StranglerFigModern]
private static CheckoutMigrationResponse Modern(CheckoutMigrationRequest request) => ModernSystem.Submit(request);
}
```

Diagnostics:

- `PKSF001`: host type must be partial.
- `PKSF002`: at least one route, exactly one legacy handler, and exactly one modern handler are required.
- `PKSF003`: route or handler signature is invalid.
- `PKSF004`: route 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 @@ -163,6 +163,9 @@
- name: Gateway Aggregation
href: gateway-aggregation.md

- name: Strangler Fig
href: strangler-fig.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 @@ -87,6 +87,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Cloud Architecture | Rate Limiting | `RateLimitPolicy<T>` | Rate Limiting generator |
| Cloud Architecture | External Configuration Store | `ExternalConfigurationStore<TSettings>` | External Configuration Store generator |
| Cloud Architecture | Gateway Aggregation | `GatewayAggregation<TRequest,TResponse>` | Gateway Aggregation generator |
| Cloud Architecture | Strangler Fig | `StranglerFig<TRequest,TResponse>` | Strangler Fig 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
19 changes: 19 additions & 0 deletions docs/patterns/cloud/strangler-fig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Strangler Fig

Strangler Fig routes traffic between a legacy implementation and a modern replacement while migration rules are rolled out incrementally.

```csharp
var migration = StranglerFig<CheckoutMigrationRequest, CheckoutMigrationResponse>
.Create("checkout-strangler")
.RouteToModern("enterprise-tenant", request => request.TenantId.StartsWith("enterprise-"))
.RouteToModern("large-order-pilot", request => request.Total >= 1_000m)
.Legacy(legacy.Submit)
.Modern(modern.Submit)
.Build();

var result = migration.Route(request);
```

Use it at an API facade, gateway, service layer, or endpoint boundary when an existing system is being replaced feature-by-feature. The runtime result records whether the request used the legacy or modern path and which migration rule matched.

The source-generated path uses `[GenerateStranglerFig]`, `[StranglerFigRoute]`, `[StranglerFigLegacy]`, and `[StranglerFigModern]`. Import the example through `AddCheckoutStranglerFigDemo()` or `AddPatternKitExamples()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,8 @@
href: cloud/external-configuration-store.md
- name: Gateway Aggregation
href: cloud/gateway-aggregation.md
- name: Strangler Fig
href: cloud/strangler-fig.md
- name: Application Architecture
items:
- name: Anti-Corruption Layer
Expand Down
133 changes: 133 additions & 0 deletions src/PatternKit.Core/Cloud/StranglerFig/StranglerFig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
namespace PatternKit.Cloud.StranglerFig;

public enum StranglerFigRoute
{
Legacy,
Modern
}

public sealed class StranglerFigDecision
{
private StranglerFigDecision(StranglerFigRoute route, string ruleName)
=> (Route, RuleName) = (route, ruleName);

public StranglerFigRoute Route { get; }

public string RuleName { get; }

public bool UsedLegacy => Route == StranglerFigRoute.Legacy;

public bool UsedModern => Route == StranglerFigRoute.Modern;

public static StranglerFigDecision Legacy(string ruleName = "fallback")
=> new(StranglerFigRoute.Legacy, string.IsNullOrWhiteSpace(ruleName) ? "fallback" : ruleName);

public static StranglerFigDecision Modern(string ruleName)
=> new(StranglerFigRoute.Modern, string.IsNullOrWhiteSpace(ruleName) ? throw new ArgumentException("Rule name is required.", nameof(ruleName)) : ruleName);
}

public sealed class StranglerFigResult<TResponse>
{
private StranglerFigResult(string migrationName, StranglerFigDecision decision, TResponse response)
=> (MigrationName, Decision, Response) = (migrationName, decision, response);

public string MigrationName { get; }

public StranglerFigDecision Decision { get; }

public TResponse Response { get; }

public bool UsedLegacy => Decision.UsedLegacy;

public bool UsedModern => Decision.UsedModern;

public static StranglerFigResult<TResponse> From(string migrationName, StranglerFigDecision decision, TResponse response)
=> new(migrationName, decision ?? throw new ArgumentNullException(nameof(decision)), response ?? throw new ArgumentNullException(nameof(response)));
}

public sealed class StranglerFig<TRequest, TResponse>
{
private readonly IReadOnlyList<RouteRule> _routes;
private readonly Func<TRequest, TResponse> _legacy;
private readonly Func<TRequest, TResponse> _modern;

private StranglerFig(string name, IReadOnlyList<RouteRule> routes, Func<TRequest, TResponse>? legacy, Func<TRequest, TResponse>? modern)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Strangler Fig migration name is required.", nameof(name));
if (routes is null)
throw new ArgumentNullException(nameof(routes));
if (routes.Count == 0)
throw new InvalidOperationException("Strangler Fig requires at least one modern route rule.");

Name = name;
_routes = routes;
_legacy = legacy ?? throw new InvalidOperationException("Strangler Fig requires a legacy handler.");
_modern = modern ?? throw new InvalidOperationException("Strangler Fig requires a modern handler.");
}

public string Name { get; }

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

foreach (var route in _routes)
{
if (route.Matches(request))
return StranglerFigResult<TResponse>.From(Name, StranglerFigDecision.Modern(route.Name), _modern(request));
}

return StranglerFigResult<TResponse>.From(Name, StranglerFigDecision.Legacy(), _legacy(request));
}
Comment on lines +76 to +83

public static Builder Create(string name = "strangler-fig") => new(name);

public sealed class Builder
{
private readonly string _name;
private readonly List<RouteRule> _routes = [];
private Func<TRequest, TResponse>? _legacy;
private Func<TRequest, TResponse>? _modern;

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

public Builder RouteToModern(string name, Func<TRequest, bool> predicate)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Strangler Fig route name is required.", nameof(name));
if (predicate is null)
throw new ArgumentNullException(nameof(predicate));
if (_routes.Any(route => string.Equals(route.Name, name, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"Strangler Fig route '{name}' is already registered.");

_routes.Add(new(name, predicate));
return this;
}

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

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

public StranglerFig<TRequest, TResponse> Build() => new(_name, _routes.ToArray(), _legacy, _modern);
}

private sealed class RouteRule
{
public RouteRule(string name, Func<TRequest, bool> matches)
=> (Name, Matches) = (name, matches);

public string Name { get; }

public Func<TRequest, bool> Matches { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
using PatternKit.Examples.ServiceLayerDemo;
using PatternKit.Examples.Singleton;
using PatternKit.Examples.SpecificationDemo;
using PatternKit.Examples.StranglerFigDemo;
using PatternKit.Examples.Strategies.Coercion;
using PatternKit.Examples.Strategies.Composed;
using PatternKit.Examples.TableDataGatewayDemo;
Expand Down Expand Up @@ -202,6 +203,7 @@ public sealed record ProductCatalogCacheAsideExample(CacheAsidePolicy<ProductRea
public sealed record ProductSearchRateLimitingExample(RateLimitPolicy<SearchResponse> Policy, ProductSearchRateLimitService Service);
public sealed record TenantExternalConfigurationStoreExample(TenantExternalConfigurationStoreDemoRunner Runner, TenantExternalConfigurationService Service);
public sealed record CustomerDashboardGatewayAggregationExample(CustomerDashboardGatewayAggregationDemoRunner Runner, CustomerDashboardGatewayService Service);
public sealed record CheckoutStranglerFigExample(CheckoutStranglerFigDemoRunner Runner, CheckoutMigrationService Service);

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

public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services)
{
Expand Down Expand Up @@ -1029,6 +1032,15 @@ public static IServiceCollection AddCustomerDashboardGatewayAggregationExample(t
return services.RegisterExample<CustomerDashboardGatewayAggregationExample>("Customer Dashboard Gateway Aggregation", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore);
}

public static IServiceCollection AddCheckoutStranglerFigExample(this IServiceCollection services)
{
services.AddCheckoutStranglerFigDemo();
services.AddSingleton<CheckoutStranglerFigExample>(sp => new(
sp.GetRequiredService<CheckoutStranglerFigDemoRunner>(),
sp.GetRequiredService<CheckoutMigrationService>()));
return services.RegisterExample<CheckoutStranglerFigExample>("Checkout Strangler Fig Migration", 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 @@ -735,7 +735,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog
"docs/examples/customer-dashboard-gateway-aggregation.md",
ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore,
["Gateway Aggregation"],
["downstream dashboard composition", "source-generated gateway aggregation", "ASP.NET Core endpoint mapping"])
["downstream dashboard composition", "source-generated gateway aggregation", "ASP.NET Core endpoint mapping"]),
Descriptor(
"Checkout Strangler Fig Migration",
"src/PatternKit.Examples/StranglerFigDemo/CheckoutStranglerFigDemo.cs",
"test/PatternKit.Examples.Tests/StranglerFigDemo/CheckoutStranglerFigDemoTests.cs",
"docs/examples/checkout-strangler-fig.md",
ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore,
["Strangler Fig"],
["legacy fallback", "source-generated migration router", "ASP.NET Core endpoint mapping"])
];

public IReadOnlyList<PatternKitExampleDescriptor> Entries => Items;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog
"test/PatternKit.Examples.Tests/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemoTests.cs",
["fluent downstream aggregation", "generated gateway aggregation factory", "DI-importable ASP.NET Core dashboard example"]),

Pattern("Strangler Fig", PatternFamily.CloudArchitecture,
"docs/patterns/cloud/strangler-fig.md",
"src/PatternKit.Core/Cloud/StranglerFig/StranglerFig.cs",
"test/PatternKit.Tests/Cloud/StranglerFig/StranglerFigTests.cs",
"docs/generators/strangler-fig.md",
"src/PatternKit.Generators/StranglerFig/StranglerFigGenerator.cs",
"test/PatternKit.Generators.Tests/StranglerFigGeneratorTests.cs",
null,
"docs/examples/checkout-strangler-fig.md",
"src/PatternKit.Examples/StranglerFigDemo/CheckoutStranglerFigDemo.cs",
"test/PatternKit.Examples.Tests/StranglerFigDemo/CheckoutStranglerFigDemoTests.cs",
["fluent migration routing", "generated Strangler Fig factory", "DI-importable ASP.NET Core checkout migration example"]),

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