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/commerce-backends-for-frontends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Commerce Backends for Frontends

The commerce Backends for Frontends example shapes one customer summary workflow for web, mobile, and default clients.

```csharp
services.AddCommerceBackendsForFrontendsDemo();

var runner = provider.GetRequiredService<CommerceBackendsForFrontendsDemoRunner>();
var summary = runner.RunGenerated("mobile", "C-100");
```

The example includes fluent and source-generated construction, an `IServiceCollection` extension, and an ASP.NET Core minimal API mapping through `MapCommerceBackendsForFrontends()`.
1 change: 1 addition & 0 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ dotnet test PatternKit.slnx -c Release
* **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.
* **Commerce Backends for Frontends:** `CommerceBackendsForFrontendsDemo` (+ `CommerceBackendsForFrontendsDemoTests`) — fluent and generated client-specific facade shaping 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 @@ -249,3 +249,6 @@

- name: Order Telemetry Sidecar
href: order-telemetry-sidecar.md

- name: Commerce Backends for Frontends
href: commerce-backends-for-frontends.md
23 changes: 23 additions & 0 deletions docs/generators/backends-for-frontends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Backends for Frontends Generator

`[GenerateBackendsForFrontends]` creates a typed `BackendsForFrontends<TRequest, TResponse>` factory from selector and handler method pairs.

```csharp
[GenerateBackendsForFrontends(typeof(CommerceClientRequest), typeof(CommerceClientResponse), GatewayName = "commerce-bff")]
public static partial class CommerceBff
{
[FrontendSelector("mobile")]
private static bool IsMobile(CommerceClientRequest request) => request.Client == "mobile";

[FrontendHandler("mobile")]
private static CommerceClientResponse Mobile(BackendsForFrontendsContext<CommerceClientRequest> ctx)
=> new(ctx.Request.CustomerId, "compact", 2, false);
}
```

Diagnostics:

- `PKBFF001`: host type must be partial.
- `PKBFF002`: selectors and handlers must be paired by frontend name.
- `PKBFF003`: selector, handler, or fallback signature is invalid.
- `PKBFF004`: frontend names must be unique.
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**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]` |
| [**Backends for Frontends**](backends-for-frontends.md) | Client-specific facade factories | `[GenerateBackendsForFrontends]` |

## Quick Reference

Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@
- name: Sidecar
href: sidecar.md

- name: Backends for Frontends
href: backends-for-frontends.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 @@ -90,6 +90,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| 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 |
| Cloud Architecture | Backends for Frontends | `BackendsForFrontends<TRequest,TResponse>` | Backends for Frontends 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/backends-for-frontends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Backends for Frontends

Backends for Frontends creates client-specific API facades over shared backend capabilities.

```csharp
var bff = BackendsForFrontends<CommerceClientRequest, CommerceClientResponse>
.Create("commerce-bff")
.Frontend("mobile", request => request.Client == "mobile", ctx => new(ctx.Request.CustomerId, "compact", 2, false))
.Frontend("web", request => request.Client == "web", ctx => new(ctx.Request.CustomerId, "rich", 2, true))
.Fallback(ctx => new(ctx.Request.CustomerId, "standard", 2, true))
.Build();

var result = bff.Dispatch(request);
```

Use it when mobile, web, partner, or internal clients need different response shapes or orchestration paths while the same backend services remain shared. The runtime path returns an explicit result with the selected frontend name and captures handler failures without forcing exception-based control flow.

The source-generated path uses `[GenerateBackendsForFrontends]`, `[FrontendSelector]`, `[FrontendHandler]`, and `[FrontendFallback]`. Import the example through `AddCommerceBackendsForFrontendsDemo()` or `AddPatternKitExamples()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@
href: cloud/strangler-fig.md
- name: Sidecar
href: cloud/sidecar.md
- name: Backends for Frontends
href: cloud/backends-for-frontends.md
- name: Application Architecture
items:
- name: Anti-Corruption Layer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
namespace PatternKit.Cloud.BackendsForFrontends;

public sealed class BackendsForFrontendsContext<TRequest>
{
internal BackendsForFrontendsContext(string frontendName, TRequest request)
=> (FrontendName, Request) = (frontendName, request);

public string FrontendName { get; }

public TRequest Request { get; }

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

public sealed class BackendsForFrontendsResult<TResponse>
{
private BackendsForFrontendsResult(string gatewayName, string? frontendName, TResponse? response, Exception? exception, bool handled)
=> (GatewayName, FrontendName, Response, Exception, Handled) = (gatewayName, frontendName, response, exception, handled);

public string GatewayName { get; }

public string? FrontendName { get; }

public TResponse? Response { get; }

public Exception? Exception { get; }

public bool Handled { get; }

public bool Failed => !Handled;

Comment on lines +28 to +31
public static BackendsForFrontendsResult<TResponse> Success(string gatewayName, string frontendName, TResponse response)
=> new(gatewayName, frontendName, response ?? throw new ArgumentNullException(nameof(response)), null, true);

public static BackendsForFrontendsResult<TResponse> Failure(string gatewayName, string? frontendName, Exception exception)
=> new(gatewayName, frontendName, default, exception ?? throw new ArgumentNullException(nameof(exception)), false);
}

public sealed class BackendsForFrontends<TRequest, TResponse>
{
private readonly IReadOnlyList<Frontend> _frontends;
private readonly Func<BackendsForFrontendsContext<TRequest>, TResponse>? _fallback;

private BackendsForFrontends(string name, IReadOnlyList<Frontend> frontends, Func<BackendsForFrontendsContext<TRequest>, TResponse>? fallback)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Backends for Frontends name is required.", nameof(name));
if (frontends is null)
throw new ArgumentNullException(nameof(frontends));
if (frontends.Count == 0)
throw new InvalidOperationException("Backends for Frontends requires at least one frontend.");

Name = name;
_frontends = frontends;
_fallback = fallback;
}

public string Name { get; }

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

foreach (var frontend in _frontends)
{
if (!frontend.Matches(request))
continue;

return Invoke(frontend.Name, request, frontend.Handle);
}

return _fallback is null
? BackendsForFrontendsResult<TResponse>.Failure(Name, null, new InvalidOperationException("No frontend matched the request."))
: Invoke("fallback", request, _fallback);
}

public static Builder Create(string name = "backends-for-frontends") => new(name);

private BackendsForFrontendsResult<TResponse> Invoke(string frontendName, TRequest request, Func<BackendsForFrontendsContext<TRequest>, TResponse> handler)
{
try
{
var response = handler(new(frontendName, request));
if (response is null)
return BackendsForFrontendsResult<TResponse>.Failure(Name, frontendName, new InvalidOperationException($"Frontend '{frontendName}' returned null."));

return BackendsForFrontendsResult<TResponse>.Success(Name, frontendName, response);
}
catch (Exception ex)
{
return BackendsForFrontendsResult<TResponse>.Failure(Name, frontendName, ex);
}
}

public sealed class Builder
{
private readonly string _name;
private readonly List<Frontend> _frontends = [];
private Func<BackendsForFrontendsContext<TRequest>, TResponse>? _fallback;

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

public Builder Frontend(string name, Func<TRequest, bool> match, Func<BackendsForFrontendsContext<TRequest>, TResponse> handle)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Frontend name is required.", nameof(name));
if (match is null)
throw new ArgumentNullException(nameof(match));
if (handle is null)
throw new ArgumentNullException(nameof(handle));
if (_frontends.Any(frontend => string.Equals(frontend.Name, name, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"Frontend '{name}' is already registered.");

_frontends.Add(new(name, match, handle));
return this;
}

public Builder Fallback(Func<BackendsForFrontendsContext<TRequest>, TResponse> handle)
{
_fallback = handle ?? throw new ArgumentNullException(nameof(handle));
return this;
}

public BackendsForFrontends<TRequest, TResponse> Build()
=> new(_name, _frontends.ToArray(), _fallback);
}

private sealed class Frontend
{
public Frontend(string name, Func<TRequest, bool> matches, Func<BackendsForFrontendsContext<TRequest>, TResponse> handle)
=> (Name, Matches, Handle) = (name, matches, handle);

public string Name { get; }

public Func<TRequest, bool> Matches { get; }

public Func<BackendsForFrontendsContext<TRequest>, TResponse> Handle { get; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PatternKit.Cloud.BackendsForFrontends;
using PatternKit.Generators.BackendsForFrontends;

namespace PatternKit.Examples.BackendsForFrontendsDemo;

public sealed record CommerceClientRequest(string Client, string CustomerId);

public sealed record CommerceClientResponse(string CustomerId, string Shape, int ItemCount, bool IncludesPromotions);

public interface ICommerceSummaryBackend
{
int GetOpenItemCount(string customerId);
}

public sealed class DemoCommerceSummaryBackend : ICommerceSummaryBackend
{
public int GetOpenItemCount(string customerId) => customerId.Length % 3 + 1;
}

public sealed class CommerceBackendsForFrontendsService(BackendsForFrontends<CommerceClientRequest, CommerceClientResponse> bff)
{
public CommerceClientResponse GetSummary(string client, string customerId)
{
var result = bff.Dispatch(new CommerceClientRequest(client, customerId));
if (result.Failed)
throw new InvalidOperationException("Commerce client summary could not be shaped.", result.Exception);

return result.Response!;
}
}

public static class CommerceBackendsForFrontends
{
public static BackendsForFrontends<CommerceClientRequest, CommerceClientResponse> CreateFluent(ICommerceSummaryBackend backend)
=> BackendsForFrontends<CommerceClientRequest, CommerceClientResponse>.Create("commerce-bff")
.Frontend("mobile", static request => request.Client.Equals("mobile", StringComparison.OrdinalIgnoreCase), ctx => Mobile(ctx, backend))
.Frontend("web", static request => request.Client.Equals("web", StringComparison.OrdinalIgnoreCase), ctx => Web(ctx, backend))
.Fallback(ctx => Standard(ctx, backend))
.Build();

public static CommerceClientResponse Mobile(BackendsForFrontendsContext<CommerceClientRequest> ctx, ICommerceSummaryBackend backend)
=> new(ctx.Request.CustomerId, "compact", backend.GetOpenItemCount(ctx.Request.CustomerId), IncludesPromotions: false);

public static CommerceClientResponse Web(BackendsForFrontendsContext<CommerceClientRequest> ctx, ICommerceSummaryBackend backend)
=> new(ctx.Request.CustomerId, "rich", backend.GetOpenItemCount(ctx.Request.CustomerId), IncludesPromotions: true);

public static CommerceClientResponse Standard(BackendsForFrontendsContext<CommerceClientRequest> ctx, ICommerceSummaryBackend backend)
=> new(ctx.Request.CustomerId, "standard", backend.GetOpenItemCount(ctx.Request.CustomerId), IncludesPromotions: true);
}

[GenerateBackendsForFrontends(typeof(CommerceClientRequest), typeof(CommerceClientResponse), FactoryMethodName = "Create", GatewayName = "commerce-bff")]
public static partial class GeneratedCommerceBackendsForFrontends
{
[FrontendSelector("mobile")]
private static bool IsMobile(CommerceClientRequest request) => request.Client.Equals("mobile", StringComparison.OrdinalIgnoreCase);

[FrontendHandler("mobile")]
private static CommerceClientResponse Mobile(BackendsForFrontendsContext<CommerceClientRequest> ctx)
=> new(ctx.Request.CustomerId, "compact", 2, IncludesPromotions: false);

[FrontendSelector("web")]
private static bool IsWeb(CommerceClientRequest request) => request.Client.Equals("web", StringComparison.OrdinalIgnoreCase);

[FrontendHandler("web")]
private static CommerceClientResponse Web(BackendsForFrontendsContext<CommerceClientRequest> ctx)
=> new(ctx.Request.CustomerId, "rich", 2, IncludesPromotions: true);

[FrontendFallback]
private static CommerceClientResponse Standard(BackendsForFrontendsContext<CommerceClientRequest> ctx)
=> new(ctx.Request.CustomerId, "standard", 2, IncludesPromotions: true);
}

public sealed class CommerceBackendsForFrontendsDemoRunner(CommerceBackendsForFrontendsService service)
{
public CommerceClientResponse RunGenerated(string client, string customerId) => service.GetSummary(client, customerId);

public static CommerceClientResponse RunFluent()
{
var bff = CommerceBackendsForFrontends.CreateFluent(new DemoCommerceSummaryBackend());
var result = bff.Dispatch(new CommerceClientRequest("web", "C-100"));
return result.Response!;
}
}

public static class CommerceBackendsForFrontendsServiceCollectionExtensions
{
public static IServiceCollection AddCommerceBackendsForFrontendsDemo(this IServiceCollection services)
{
services.AddSingleton<ICommerceSummaryBackend, DemoCommerceSummaryBackend>();
services.AddSingleton(static _ => GeneratedCommerceBackendsForFrontends.Create());
services.AddSingleton<CommerceBackendsForFrontendsService>();
services.AddSingleton<CommerceBackendsForFrontendsDemoRunner>();
return services;
}

public static IEndpointRouteBuilder MapCommerceBackendsForFrontends(this IEndpointRouteBuilder endpoints, string pattern = "/commerce/{client}/{customerId}/summary")
{
endpoints.MapGet(pattern, (string client, string customerId, CommerceBackendsForFrontendsService service) => Results.Ok(service.GetSummary(client, customerId)))
.WithName("CommerceBackendsForFrontends");
return endpoints;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
using PatternKit.Examples.AntiCorruptionDemo;
using PatternKit.Examples.AsyncStateDemo;
using PatternKit.Examples.AuditLogDemo;
using PatternKit.Examples.BackendsForFrontendsDemo;
using PatternKit.Examples.BulkheadDemo;
using PatternKit.Examples.CacheAsideDemo;
using PatternKit.Examples.CanonicalDataModelDemo;
Expand Down Expand Up @@ -208,6 +209,7 @@ public sealed record CustomerDashboardGatewayAggregationExample(CustomerDashboar
public sealed record CheckoutStranglerFigExample(CheckoutStranglerFigDemoRunner Runner, CheckoutMigrationService Service);
public sealed record ProductGatewayRoutingExample(ProductGatewayRoutingDemoRunner Runner, ProductGatewayRoutingService Service);
public sealed record OrderTelemetrySidecarExample(OrderTelemetrySidecarDemoRunner Runner, OrderTelemetrySidecarService Service);
public sealed record CommerceBackendsForFrontendsExample(CommerceBackendsForFrontendsDemoRunner Runner, CommerceBackendsForFrontendsService Service);

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

public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services)
{
Expand Down Expand Up @@ -1065,6 +1068,15 @@ public static IServiceCollection AddOrderTelemetrySidecarExample(this IServiceCo
return services.RegisterExample<OrderTelemetrySidecarExample>("Order Telemetry Sidecar", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore);
}

public static IServiceCollection AddCommerceBackendsForFrontendsExample(this IServiceCollection services)
{
services.AddCommerceBackendsForFrontendsDemo();
services.AddSingleton<CommerceBackendsForFrontendsExample>(sp => new(
sp.GetRequiredService<CommerceBackendsForFrontendsDemoRunner>(),
sp.GetRequiredService<CommerceBackendsForFrontendsService>()));
return services.RegisterExample<CommerceBackendsForFrontendsExample>("Commerce Backends for Frontends", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore);
}

private static IServiceCollection RegisterExample<T>(
this IServiceCollection services,
string name,
Expand Down
Loading
Loading