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
8 changes: 8 additions & 0 deletions docs/examples/checkout-unit-of-work-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Checkout Unit of Work Pattern

The checkout unit-of-work example coordinates inventory reservation and payment capture as one application boundary. It demonstrates fluent and source-generated units of work, rollback compensation, TinyBDD tests, and `IServiceCollection` integration.

Files:

- `src/PatternKit.Examples/UnitOfWorkDemo/CheckoutUnitOfWorkDemo.cs`
- `test/PatternKit.Examples.Tests/UnitOfWorkDemo/CheckoutUnitOfWorkDemoTests.cs`
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@
- name: Order Repository Pattern
href: order-repository-pattern.md

- name: Checkout Unit of Work Pattern
href: checkout-unit-of-work-pattern.md

- name: Generated Mailbox
href: generated-mailbox.md

Expand Down
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Specification**](specification.md) | Named business-rule registries | `[GenerateSpecificationRegistry]` |
| [**Repository**](repository.md) | In-memory repository factories from key selectors | `[GenerateRepository]` |
| [**Anti-Corruption Layer**](anti-corruption-layer.md) | External-to-domain translation boundaries with validation | `[GenerateAntiCorruptionLayer]` |
| [**Unit of Work**](unit-of-work.md) | Ordered commit and rollback units | `[GenerateUnitOfWork]` |
| [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` |
| [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` |

Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@
- name: Template Method
href: template-method-generator.md

- name: Unit of Work
href: unit-of-work.md

- name: Visitor Generator
href: visitor-generator.md

Expand Down
23 changes: 23 additions & 0 deletions docs/generators/unit-of-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Unit of Work Generator

`[GenerateUnitOfWork]` emits a `UnitOfWork` factory from ordered static step methods.

```csharp
[GenerateUnitOfWork]
public static partial class CheckoutWork
{
[UnitOfWorkStep("reserve-inventory", 10, RollbackMethodName = nameof(UndoReserve))]
private static ValueTask Reserve(CancellationToken ct) => default;

private static ValueTask UndoReserve(CancellationToken ct) => default;
}
```

Diagnostics:

| ID | Meaning |
| --- | --- |
| `PKUOW001` | Host type must be partial. |
| `PKUOW002` | At least one `[UnitOfWorkStep]` method is required. |
| `PKUOW003` | Step methods must return `ValueTask` and accept one `CancellationToken`. |
| `PKUOW004` | Step names and orders must be unique. |
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| 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 |
| Application Architecture | Unit of Work | `UnitOfWork` | Unit of Work generator |
| Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer<TExternal, TDomain>` | Anti-Corruption Layer generator |

## Research Baselines
Expand Down
16 changes: 16 additions & 0 deletions docs/patterns/application/unit-of-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Unit of Work

Unit of Work coordinates a set of application operations as one logical commit boundary. PatternKit's `UnitOfWork` runs named steps in order and runs compensating rollback actions in reverse order when a later step fails.

```csharp
var unit = UnitOfWork.Create()
.Enlist("reserve-inventory", ReserveAsync, ReleaseInventoryAsync)
.Enlist("capture-payment", CaptureAsync, RefundAsync)
.Build();

var result = await unit.CommitAsync(ct);
```

Use it around repositories, adapters, and external-resource calls where the application owns the transaction or compensation policy.

See [Unit of Work Generator](../../generators/unit-of-work.md).
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@
href: application/anti-corruption-layer.md
- name: Repository
href: application/repository.md
- name: Unit of Work
href: application/unit-of-work.md
- name: Specification
href: application/specification.md
- name: Type-Dispatcher
Expand Down
165 changes: 165 additions & 0 deletions src/PatternKit.Core/Application/UnitOfWork/UnitOfWork.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
namespace PatternKit.Application.UnitOfWork;

/// <summary>
/// Coordinates ordered operations and compensating rollback actions as one logical unit.
/// </summary>
public sealed class UnitOfWork
{
private readonly IReadOnlyList<UnitOfWorkStep> _steps;

private UnitOfWork(IReadOnlyList<UnitOfWorkStep> steps)
{
_steps = steps;
}

/// <summary>Registered step names in commit order.</summary>
public IReadOnlyList<string> StepNames => _steps.Select(static step => step.Name).ToArray();

/// <summary>Creates a unit-of-work builder.</summary>
public static Builder Create() => new();

/// <summary>Commits all registered operations in order.</summary>
public async ValueTask<UnitOfWorkResult> CommitAsync(CancellationToken cancellationToken = default)
{
var committed = new List<string>();
for (var i = 0; i < _steps.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var step = _steps[i];
try
{
await step.Commit(cancellationToken).ConfigureAwait(false);
committed.Add(step.Name);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
var rollback = await RollbackCommittedAsync(committed.Count - 1, cancellationToken).ConfigureAwait(false);
return UnitOfWorkResult.Failed(committed, step.Name, ex, rollback);
}
}

return UnitOfWorkResult.Success(committed);
}

/// <summary>Runs all registered rollback actions in reverse order.</summary>
public async ValueTask<UnitOfWorkRollbackResult> RollbackAsync(CancellationToken cancellationToken = default)
=> await RollbackCommittedAsync(_steps.Count - 1, cancellationToken).ConfigureAwait(false);

private async ValueTask<UnitOfWorkRollbackResult> RollbackCommittedAsync(int index, CancellationToken cancellationToken)
{
var rolledBack = new List<string>();
var failures = new List<Exception>();
for (var i = index; i >= 0; i--)
{
cancellationToken.ThrowIfCancellationRequested();
var step = _steps[i];
try
{
await step.Rollback(cancellationToken).ConfigureAwait(false);
rolledBack.Add(step.Name);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
failures.Add(ex);
}
}

return new UnitOfWorkRollbackResult(rolledBack, failures);
}

/// <summary>Fluent unit-of-work builder.</summary>
public sealed class Builder
{
private readonly List<UnitOfWorkStep> _steps = new();

/// <summary>Adds a named commit operation with an optional compensating rollback action.</summary>
public Builder Enlist(
string name,
Func<CancellationToken, ValueTask> commit,
Func<CancellationToken, ValueTask>? rollback = null)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Unit-of-work step name is required.", nameof(name));
if (_steps.Any(step => string.Equals(step.Name, name, StringComparison.Ordinal)))
throw new ArgumentException($"Unit-of-work step '{name}' is already registered.", nameof(name));

_steps.Add(new UnitOfWorkStep(
name,
commit ?? throw new ArgumentNullException(nameof(commit)),
rollback ?? (static _ => default)));
return this;
}

/// <summary>Builds an immutable unit-of-work snapshot.</summary>
public UnitOfWork Build()
=> new(_steps.ToArray());
}
}

/// <summary>One named unit-of-work operation and compensation pair.</summary>
public sealed class UnitOfWorkStep
{
public UnitOfWorkStep(
string name,
Func<CancellationToken, ValueTask> commit,
Func<CancellationToken, ValueTask> rollback)
{
Name = string.IsNullOrWhiteSpace(name)
? throw new ArgumentException("Unit-of-work step name is required.", nameof(name))
: name;
Commit = commit ?? throw new ArgumentNullException(nameof(commit));
Rollback = rollback ?? throw new ArgumentNullException(nameof(rollback));
}

public string Name { get; }
public Func<CancellationToken, ValueTask> Commit { get; }
public Func<CancellationToken, ValueTask> Rollback { get; }
}

/// <summary>Commit result for a unit of work.</summary>
public sealed class UnitOfWorkResult
{
private UnitOfWorkResult(
bool committed,
IReadOnlyList<string> committedSteps,
string? failedStep,
Exception? exception,
UnitOfWorkRollbackResult? rollback)
{
Committed = committed;
CommittedSteps = committedSteps;
FailedStep = failedStep;
Exception = exception;
Rollback = rollback;
}
Comment on lines +122 to +134

public bool Committed { get; }
public IReadOnlyList<string> CommittedSteps { get; }
public string? FailedStep { get; }
public Exception? Exception { get; }
public UnitOfWorkRollbackResult? Rollback { get; }

public static UnitOfWorkResult Success(IReadOnlyList<string> committedSteps)
=> new(true, committedSteps, null, null, null);

public static UnitOfWorkResult Failed(
IReadOnlyList<string> committedSteps,
string failedStep,
Exception exception,
UnitOfWorkRollbackResult rollback)
=> new(false, committedSteps, failedStep, exception, rollback);
}

/// <summary>Rollback result for a unit of work.</summary>
public sealed class UnitOfWorkRollbackResult
{
public UnitOfWorkRollbackResult(IReadOnlyList<string> rolledBackSteps, IReadOnlyList<Exception> failures)
{
RolledBackSteps = rolledBackSteps;
Failures = failures;
}

public IReadOnlyList<string> RolledBackSteps { get; }
public IReadOnlyList<Exception> Failures { get; }
public bool Succeeded => Failures.Count == 0;
Comment on lines +154 to +164
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
using PatternKit.Examples.Strategies.Coercion;
using PatternKit.Examples.Strategies.Composed;
using PatternKit.Examples.TemplateDemo;
using PatternKit.Examples.UnitOfWorkDemo;
using PatternKit.Examples.VisitorDemo;
using PatternKit.Messaging.Routing;
using PatternKit.Messaging.Transformation;
Expand Down Expand Up @@ -122,6 +123,7 @@ public sealed record MessagingBackplaneFacadeExample(Func<CancellationToken, Val
public sealed record GeneratedInterpreterRulesExample(Interpreter<InterpreterRulesDemo.PricingContext, decimal> Pricing, Interpreter<InterpreterRulesDemo.PricingContext, bool> Eligibility);
public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry<LoanApprovalSpecificationDemo.LoanApplication> Registry, LoanApprovalService Service);
public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Runner, OrderRepositoryWorkflow Workflow);
public sealed record CheckoutUnitOfWorkPatternExample(CheckoutUnitOfWorkDemoRunner Runner, CheckoutUnitOfWorkWorkflow Workflow);
public sealed record PrototypeGameCharacterFactoryExample(Prototype<string, PrototypeDemo.PrototypeDemo.GameCharacter> Factory);
public sealed record ProxyPatternDemonstrationsExample(Proxy<int, string> RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy);
public sealed record FlyweightGlyphCacheExample(Func<string, IReadOnlyList<(FlyweightDemo.FlyweightDemo.Glyph Glyph, int X)>> RenderSentence);
Expand Down Expand Up @@ -179,6 +181,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddGeneratedInterpreterRulesExample()
.AddLoanApprovalSpecificationsExample()
.AddOrderRepositoryPatternExample()
.AddCheckoutUnitOfWorkPatternExample()
.AddPrototypeGameCharacterFactoryExample()
.AddProxyPatternDemonstrationsExample()
.AddFlyweightGlyphCacheExample()
Expand Down Expand Up @@ -520,6 +523,15 @@ public static IServiceCollection AddOrderRepositoryPatternExample(this IServiceC
return services.RegisterExample<OrderRepositoryPatternExample>("Order Repository Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddCheckoutUnitOfWorkPatternExample(this IServiceCollection services)
{
services.AddCheckoutUnitOfWorkDemo();
services.AddSingleton<CheckoutUnitOfWorkPatternExample>(sp => new(
sp.GetRequiredService<CheckoutUnitOfWorkDemoRunner>(),
sp.GetRequiredService<CheckoutUnitOfWorkWorkflow>()));
return services.RegisterExample<CheckoutUnitOfWorkPatternExample>("Checkout Unit of Work Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services)
{
services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog
ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost,
["Repository"],
["collection-like persistence boundary", "source-generated repository factory", "DI composition"]),
Descriptor(
"Checkout Unit of Work Pattern",
"src/PatternKit.Examples/UnitOfWorkDemo/CheckoutUnitOfWorkDemo.cs",
"test/PatternKit.Examples.Tests/UnitOfWorkDemo/CheckoutUnitOfWorkDemoTests.cs",
"docs/examples/checkout-unit-of-work-pattern.md",
ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost,
["UnitOfWork"],
["ordered commit boundary", "source-generated unit of work", "DI composition"]),
Descriptor(
"Generated Mailbox",
"src/PatternKit.Examples/Messaging/MailboxExample.cs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog
"test/PatternKit.Examples.Tests/RepositoryDemo/OrderRepositoryDemoTests.cs",
["fluent async repository", "generated repository factory", "DI-importable order persistence example"]),

Pattern("Unit of Work", PatternFamily.ApplicationArchitecture,
"docs/patterns/application/unit-of-work.md",
"src/PatternKit.Core/Application/UnitOfWork/UnitOfWork.cs",
"test/PatternKit.Tests/Application/UnitOfWork/UnitOfWorkTests.cs",
"docs/generators/unit-of-work.md",
"src/PatternKit.Generators/UnitOfWork/UnitOfWorkGenerator.cs",
"test/PatternKit.Generators.Tests/UnitOfWorkGeneratorTests.cs",
null,
"docs/examples/checkout-unit-of-work-pattern.md",
"src/PatternKit.Examples/UnitOfWorkDemo/CheckoutUnitOfWorkDemo.cs",
"test/PatternKit.Examples.Tests/UnitOfWorkDemo/CheckoutUnitOfWorkDemoTests.cs",
["fluent commit boundary", "generated unit-of-work factory", "DI-importable checkout example"]),

Pattern("Anti-Corruption Layer", PatternFamily.ApplicationArchitecture,
"docs/patterns/application/anti-corruption-layer.md",
"src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs",
Expand Down
Loading
Loading