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/customer-dashboard-gateway-aggregation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Customer Dashboard Gateway Aggregation

The customer dashboard gateway aggregation example composes profile, order, and recommendation clients into one API-facing dashboard response.

```csharp
services.AddCustomerDashboardGatewayAggregationDemo();

var runner = provider.GetRequiredService<CustomerDashboardGatewayAggregationDemoRunner>();
var dashboard = runner.RunGenerated("C-100");
```

The example includes fluent and source-generated construction, an `IServiceCollection` extension, and an ASP.NET Core minimal API mapping through `MapCustomerDashboardGatewayAggregation()`.
3 changes: 3 additions & 0 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ Welcome! This section collects small, focused demos that show **how to compose b
* **Fulfillment Health Endpoint Monitoring**
Shows fluent and source-generated health checks with `IServiceCollection`, Generic Host-friendly services, and ASP.NET Core route mapping. See [Fulfillment Health Endpoint Monitoring](fulfillment-health-endpoint-monitoring.md).

* **Customer Dashboard Gateway Aggregation**
Shows fluent and source-generated API gateway aggregation with `IServiceCollection` and ASP.NET Core endpoint mapping. See [Customer Dashboard Gateway Aggregation](customer-dashboard-gateway-aggregation.md).

* **Generated Message Envelope**
Shows fluent and source-generated message envelope contracts side by side, with an importable `IServiceCollection` extension. See [Generated Message Envelope](generated-message-envelope.md).

Expand Down
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,6 @@

- name: Tenant External Configuration Store
href: tenant-external-configuration-store.md

- name: Customer Dashboard Gateway Aggregation
href: customer-dashboard-gateway-aggregation.md
23 changes: 23 additions & 0 deletions docs/generators/gateway-aggregation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Gateway Aggregation Generator

`[GenerateGatewayAggregation]` creates a typed `GatewayAggregation<TRequest, TResponse>` factory from downstream fetch methods and one composer.

```csharp
[GenerateGatewayAggregation(typeof(CustomerDashboardRequest), typeof(CustomerDashboardResponse), GatewayName = "customer-dashboard")]
public static partial class CustomerDashboardGateway
{
[GatewayAggregationFetch("profile")]
private static CustomerProfile Profile(CustomerDashboardRequest request) => new(request.CustomerId, "Ada");

[GatewayAggregationComposer]
private static CustomerDashboardResponse Compose(GatewayAggregationContext<CustomerDashboardRequest> ctx)
=> new(ctx.Require<CustomerProfile>("profile").CustomerId, 0);
}
```

Diagnostics:

- `PKGA001`: host type must be partial.
- `PKGA002`: at least one fetch and exactly one composer are required.
- `PKGA003`: fetch or composer signature is invalid.
- `PKGA004`: fetch names must be unique.
5 changes: 5 additions & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Cache-Aside**](cache-aside.md) | Read-through cache policy factories with TTL and cache predicates | `[GenerateCacheAsidePolicy]` |
| [**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]` |

## Quick Reference

Expand Down Expand Up @@ -293,6 +294,10 @@ public static partial class ProductCatalogCachePolicy { }
// Rate limiting - generated tenant or key budget policy
[GenerateRateLimitPolicy(typeof(SearchResponse), PermitLimit = 2, WindowMilliseconds = 60000)]
public static partial class ProductSearchRateLimitPolicy { }

// Gateway aggregation - generated downstream response composition
[GenerateGatewayAggregation(typeof(CustomerDashboardRequest), typeof(CustomerDashboardResponse))]
public static partial class CustomerDashboardGateway { }
```

## Examples
Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@
- name: External Configuration Store
href: external-configuration-store.md

- name: Gateway Aggregation
href: gateway-aggregation.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 @@ -86,6 +86,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Cloud Architecture | Cache-Aside | `CacheAsidePolicy<T>` | Cache-Aside generator |
| 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 |
| 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
20 changes: 20 additions & 0 deletions docs/patterns/cloud/gateway-aggregation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Gateway Aggregation

Gateway Aggregation composes several downstream calls behind one API-facing operation.

```csharp
var gateway = GatewayAggregation<CustomerDashboardRequest, CustomerDashboardResponse>
.Create("customer-dashboard")
.Fetch<CustomerProfile>("profile", profiles.GetProfile)
.Fetch<CustomerOrderSummary>("orders", orders.GetOrders)
.Compose(ctx => new(
ctx.Require<CustomerProfile>("profile").CustomerId,
ctx.Require<CustomerOrderSummary>("orders").OpenOrders))
.Build();

var result = gateway.Aggregate(request);
```

Use it at an API gateway, BFF, or application facade boundary when the caller needs one response but the data lives behind multiple internal services. The runtime path captures downstream failures per part and returns an explicit aggregate failure when the response cannot be composed.

The source-generated path uses `[GenerateGatewayAggregation]`, `[GatewayAggregationFetch]`, and `[GatewayAggregationComposer]`. Import the example through `AddCustomerDashboardGatewayAggregationDemo()` or `AddPatternKitExamples()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,8 @@
href: cloud/rate-limiting.md
- name: External Configuration Store
href: cloud/external-configuration-store.md
- name: Gateway Aggregation
href: cloud/gateway-aggregation.md
- name: Application Architecture
items:
- name: Anti-Corruption Layer
Expand Down
170 changes: 170 additions & 0 deletions src/PatternKit.Core/Cloud/GatewayAggregation/GatewayAggregation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
namespace PatternKit.Cloud.GatewayAggregation;

public sealed class GatewayAggregationPart
{
private GatewayAggregationPart(string name, object? value, Exception? exception, bool succeeded)
=> (Name, Value, Exception, Succeeded) = (name, value, exception, succeeded);

public string Name { get; }

public object? Value { get; }

public Exception? Exception { get; }

public bool Succeeded { get; }

public bool Failed => !Succeeded;

public static GatewayAggregationPart Success(string name, object value)
=> new(name, value ?? throw new ArgumentNullException(nameof(value)), null, true);

public static GatewayAggregationPart Failure(string name, Exception exception)
=> new(name, null, exception ?? throw new ArgumentNullException(nameof(exception)), false);
}

public sealed class GatewayAggregationContext<TRequest>
{
internal GatewayAggregationContext(TRequest request, IReadOnlyDictionary<string, GatewayAggregationPart> parts)
=> (Request, Parts) = (request, parts);

public TRequest Request { get; }

public IReadOnlyDictionary<string, GatewayAggregationPart> Parts { get; }

public TPart Require<TPart>(string name)
{
if (!Parts.TryGetValue(name, out var part))
throw new InvalidOperationException($"Gateway aggregation part '{name}' is not registered.");
if (part.Failed)
throw new InvalidOperationException($"Gateway aggregation part '{name}' failed.", part.Exception);
if (part.Value is not TPart value)
throw new InvalidOperationException($"Gateway aggregation part '{name}' is not '{typeof(TPart).FullName}'.");

return value;
}
}

public sealed class GatewayAggregationResult<TResponse>
{
private GatewayAggregationResult(string gatewayName, TResponse? response, IReadOnlyDictionary<string, GatewayAggregationPart> parts, Exception? exception, bool aggregated)
=> (GatewayName, Response, Parts, Exception, Aggregated) = (gatewayName, response, parts, exception, aggregated);

public string GatewayName { get; }

public TResponse? Response { get; }

public IReadOnlyDictionary<string, GatewayAggregationPart> Parts { get; }

public Exception? Exception { get; }

public bool Aggregated { get; }

public bool Failed => !Aggregated;

public static GatewayAggregationResult<TResponse> Success(string gatewayName, TResponse response, IReadOnlyDictionary<string, GatewayAggregationPart> parts)
=> new(gatewayName, response, parts, null, true);

public static GatewayAggregationResult<TResponse> Failure(string gatewayName, IReadOnlyDictionary<string, GatewayAggregationPart> parts, Exception exception)
=> new(gatewayName, default, parts, exception ?? throw new ArgumentNullException(nameof(exception)), false);
}

public sealed class GatewayAggregation<TRequest, TResponse>
{
private readonly IReadOnlyList<Fetch> _fetches;
private readonly Func<GatewayAggregationContext<TRequest>, TResponse> _compose;

private GatewayAggregation(string name, IReadOnlyList<Fetch> fetches, Func<GatewayAggregationContext<TRequest>, TResponse>? compose)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Gateway aggregation name is required.", nameof(name));
if (fetches is null)
throw new ArgumentNullException(nameof(fetches));
if (fetches.Count == 0)
throw new InvalidOperationException("Gateway aggregation requires at least one downstream fetch.");

Name = name;
_fetches = fetches;
_compose = compose ?? throw new InvalidOperationException("Gateway aggregation requires a response composer.");
}

public string Name { get; }

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

var parts = new Dictionary<string, GatewayAggregationPart>(StringComparer.OrdinalIgnoreCase);
foreach (var fetch in _fetches)
{
try
{
var value = fetch.Execute(request);
if (value is null)
parts[fetch.Name] = GatewayAggregationPart.Failure(fetch.Name, new InvalidOperationException($"Gateway aggregation part '{fetch.Name}' returned null."));
else
parts[fetch.Name] = GatewayAggregationPart.Success(fetch.Name, value);
}
catch (Exception ex)
{
parts[fetch.Name] = GatewayAggregationPart.Failure(fetch.Name, ex);
}
}

try
{
var response = _compose(new(request, parts));
if (response is null)
return GatewayAggregationResult<TResponse>.Failure(Name, parts, new InvalidOperationException("Gateway aggregation composer returned null."));

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

public static Builder Create(string name = "gateway-aggregation") => new(name);

public sealed class Builder
{
private readonly string _name;
private readonly List<Fetch> _fetches = [];
private Func<GatewayAggregationContext<TRequest>, TResponse>? _compose;

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

public Builder Fetch<TPart>(string name, Func<TRequest, TPart> fetch)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Gateway aggregation fetch name is required.", nameof(name));
if (fetch is null)
throw new ArgumentNullException(nameof(fetch));
if (_fetches.Any(part => string.Equals(part.Name, name, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"Gateway aggregation fetch '{name}' is already registered.");

_fetches.Add(new(name, request => fetch(request)!));
return this;
}

public Builder Compose(Func<GatewayAggregationContext<TRequest>, TResponse> compose)
{
_compose = compose ?? throw new ArgumentNullException(nameof(compose));
return this;
}

public GatewayAggregation<TRequest, TResponse> Build()
=> new(_name, _fetches.ToArray(), _compose);
}

private sealed class Fetch
{
public Fetch(string name, Func<TRequest, object?> execute)
=> (Name, Execute) = (name, execute);

public string Name { get; }

public Func<TRequest, object?> Execute { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
using PatternKit.Examples.ExternalConfigurationStoreDemo;
using PatternKit.Examples.FeatureToggleDemo;
using PatternKit.Examples.FlyweightDemo;
using PatternKit.Examples.GatewayAggregationDemo;
using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo;
using PatternKit.Examples.Generators.Visitors;
using PatternKit.Examples.HealthEndpointMonitoringDemo;
Expand Down Expand Up @@ -200,6 +201,7 @@ public sealed record FulfillmentPriorityQueueExample(PriorityQueuePolicy<Fulfill
public sealed record ProductCatalogCacheAsideExample(CacheAsidePolicy<ProductReadModel> Policy, ProductCatalogCacheAsideService Service);
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);

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

public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services)
{
Expand Down Expand Up @@ -1017,6 +1020,15 @@ public static IServiceCollection AddTenantExternalConfigurationStoreExample(this
return services.RegisterExample<TenantExternalConfigurationStoreExample>("Tenant External Configuration Store", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddCustomerDashboardGatewayAggregationExample(this IServiceCollection services)
{
services.AddCustomerDashboardGatewayAggregationDemo();
services.AddSingleton<CustomerDashboardGatewayAggregationExample>(sp => new(
sp.GetRequiredService<CustomerDashboardGatewayAggregationDemoRunner>(),
sp.GetRequiredService<CustomerDashboardGatewayService>()));
return services.RegisterExample<CustomerDashboardGatewayAggregationExample>("Customer Dashboard Gateway Aggregation", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore);
}

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