diff --git a/docs/examples/checkout-unit-of-work-pattern.md b/docs/examples/checkout-unit-of-work-pattern.md new file mode 100644 index 00000000..ad8b7e23 --- /dev/null +++ b/docs/examples/checkout-unit-of-work-pattern.md @@ -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` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 22a4406d..4760664b 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -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 diff --git a/docs/generators/index.md b/docs/generators/index.md index dd230ed4..03aa7f33 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -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]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index a885b3f5..c466e9d3 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -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 diff --git a/docs/generators/unit-of-work.md b/docs/generators/unit-of-work.md new file mode 100644 index 00000000..a7a0ab9e --- /dev/null +++ b/docs/generators/unit-of-work.md @@ -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. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 190645f5..68701b1a 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -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` and named registries | Specification generator | | Application Architecture | Repository | `IRepository` and `InMemoryRepository` | Repository generator | +| Application Architecture | Unit of Work | `UnitOfWork` | Unit of Work generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/unit-of-work.md b/docs/patterns/application/unit-of-work.md new file mode 100644 index 00000000..ac0eca93 --- /dev/null +++ b/docs/patterns/application/unit-of-work.md @@ -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). diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index a9901f99..117bf08b 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -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 diff --git a/src/PatternKit.Core/Application/UnitOfWork/UnitOfWork.cs b/src/PatternKit.Core/Application/UnitOfWork/UnitOfWork.cs new file mode 100644 index 00000000..6009f56b --- /dev/null +++ b/src/PatternKit.Core/Application/UnitOfWork/UnitOfWork.cs @@ -0,0 +1,165 @@ +namespace PatternKit.Application.UnitOfWork; + +/// +/// Coordinates ordered operations and compensating rollback actions as one logical unit. +/// +public sealed class UnitOfWork +{ + private readonly IReadOnlyList _steps; + + private UnitOfWork(IReadOnlyList steps) + { + _steps = steps; + } + + /// Registered step names in commit order. + public IReadOnlyList StepNames => _steps.Select(static step => step.Name).ToArray(); + + /// Creates a unit-of-work builder. + public static Builder Create() => new(); + + /// Commits all registered operations in order. + public async ValueTask CommitAsync(CancellationToken cancellationToken = default) + { + var committed = new List(); + 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); + } + + /// Runs all registered rollback actions in reverse order. + public async ValueTask RollbackAsync(CancellationToken cancellationToken = default) + => await RollbackCommittedAsync(_steps.Count - 1, cancellationToken).ConfigureAwait(false); + + private async ValueTask RollbackCommittedAsync(int index, CancellationToken cancellationToken) + { + var rolledBack = new List(); + var failures = new List(); + 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); + } + + /// Fluent unit-of-work builder. + public sealed class Builder + { + private readonly List _steps = new(); + + /// Adds a named commit operation with an optional compensating rollback action. + public Builder Enlist( + string name, + Func commit, + Func? 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; + } + + /// Builds an immutable unit-of-work snapshot. + public UnitOfWork Build() + => new(_steps.ToArray()); + } +} + +/// One named unit-of-work operation and compensation pair. +public sealed class UnitOfWorkStep +{ + public UnitOfWorkStep( + string name, + Func commit, + Func 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 Commit { get; } + public Func Rollback { get; } +} + +/// Commit result for a unit of work. +public sealed class UnitOfWorkResult +{ + private UnitOfWorkResult( + bool committed, + IReadOnlyList committedSteps, + string? failedStep, + Exception? exception, + UnitOfWorkRollbackResult? rollback) + { + Committed = committed; + CommittedSteps = committedSteps; + FailedStep = failedStep; + Exception = exception; + Rollback = rollback; + } + + public bool Committed { get; } + public IReadOnlyList CommittedSteps { get; } + public string? FailedStep { get; } + public Exception? Exception { get; } + public UnitOfWorkRollbackResult? Rollback { get; } + + public static UnitOfWorkResult Success(IReadOnlyList committedSteps) + => new(true, committedSteps, null, null, null); + + public static UnitOfWorkResult Failed( + IReadOnlyList committedSteps, + string failedStep, + Exception exception, + UnitOfWorkRollbackResult rollback) + => new(false, committedSteps, failedStep, exception, rollback); +} + +/// Rollback result for a unit of work. +public sealed class UnitOfWorkRollbackResult +{ + public UnitOfWorkRollbackResult(IReadOnlyList rolledBackSteps, IReadOnlyList failures) + { + RolledBackSteps = rolledBackSteps; + Failures = failures; + } + + public IReadOnlyList RolledBackSteps { get; } + public IReadOnlyList Failures { get; } + public bool Succeeded => Failures.Count == 0; +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index c3e53a2e..cd8540f5 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -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; @@ -122,6 +123,7 @@ public sealed record MessagingBackplaneFacadeExample(Func Pricing, Interpreter Eligibility); public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry Registry, LoanApprovalService Service); public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Runner, OrderRepositoryWorkflow Workflow); +public sealed record CheckoutUnitOfWorkPatternExample(CheckoutUnitOfWorkDemoRunner Runner, CheckoutUnitOfWorkWorkflow Workflow); public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); public sealed record ProxyPatternDemonstrationsExample(Proxy RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy); public sealed record FlyweightGlyphCacheExample(Func> RenderSentence); @@ -179,6 +181,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddGeneratedInterpreterRulesExample() .AddLoanApprovalSpecificationsExample() .AddOrderRepositoryPatternExample() + .AddCheckoutUnitOfWorkPatternExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -520,6 +523,15 @@ public static IServiceCollection AddOrderRepositoryPatternExample(this IServiceC return services.RegisterExample("Order Repository Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddCheckoutUnitOfWorkPatternExample(this IServiceCollection services) + { + services.AddCheckoutUnitOfWorkDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("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()); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 38856518..feb9aef2 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -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", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 059163f0..f5262a0d 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -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", diff --git a/src/PatternKit.Examples/UnitOfWorkDemo/CheckoutUnitOfWorkDemo.cs b/src/PatternKit.Examples/UnitOfWorkDemo/CheckoutUnitOfWorkDemo.cs new file mode 100644 index 00000000..abbf9bb5 --- /dev/null +++ b/src/PatternKit.Examples/UnitOfWorkDemo/CheckoutUnitOfWorkDemo.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.UnitOfWork; +using PatternKit.Generators.UnitOfWork; + +namespace PatternKit.Examples.UnitOfWorkDemo; + +public static class CheckoutUnitOfWorkDemo +{ + public static async ValueTask RunFluentAsync() + { + var log = new List(); + var unit = UnitOfWork.Create() + .Enlist("reserve-inventory", _ => { log.Add("reserve"); return default; }, _ => { log.Add("undo-reserve"); return default; }) + .Enlist("capture-payment", _ => { log.Add("capture"); return default; }, _ => { log.Add("refund"); return default; }) + .Build(); + var result = await unit.CommitAsync(); + return new(result.Committed, log); + } + + public static async ValueTask RunGeneratedAsync() + { + GeneratedCheckoutUnitOfWork.Log.Clear(); + var result = await GeneratedCheckoutUnitOfWork.Create().CommitAsync(); + return new(result.Committed, GeneratedCheckoutUnitOfWork.Log.ToArray()); + } + + public static async ValueTask RunRollbackAsync() + { + var log = new List(); + var unit = UnitOfWork.Create() + .Enlist("reserve-inventory", _ => { log.Add("reserve"); return default; }, _ => { log.Add("undo-reserve"); return default; }) + .Enlist("persist-order", _ => throw new InvalidOperationException("database failed")) + .Build(); + var result = await unit.CommitAsync(); + return new(result.Committed, log); + } +} + +public sealed record CheckoutUnitOfWorkSummary(bool Committed, IReadOnlyList Log); + +public sealed class CheckoutUnitOfWorkWorkflow +{ + public ValueTask RunAsync() + => CheckoutUnitOfWorkDemo.RunFluentAsync(); +} + +public sealed record CheckoutUnitOfWorkDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync, + Func> RunRollbackAsync); + +public static class CheckoutUnitOfWorkServiceCollectionExtensions +{ + public static IServiceCollection AddCheckoutUnitOfWorkDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(new CheckoutUnitOfWorkDemoRunner( + CheckoutUnitOfWorkDemo.RunFluentAsync, + CheckoutUnitOfWorkDemo.RunGeneratedAsync, + CheckoutUnitOfWorkDemo.RunRollbackAsync)); + return services; + } +} + +[GenerateUnitOfWork] +public static partial class GeneratedCheckoutUnitOfWork +{ + public static List Log { get; } = new(); + + [UnitOfWorkStep("reserve-inventory", 10, RollbackMethodName = nameof(UndoReserve))] + private static ValueTask Reserve(CancellationToken cancellationToken) + { + Log.Add("reserve"); + return default; + } + + private static ValueTask UndoReserve(CancellationToken cancellationToken) + { + Log.Add("undo-reserve"); + return default; + } + + [UnitOfWorkStep("capture-payment", 20, RollbackMethodName = nameof(Refund))] + private static ValueTask Capture(CancellationToken cancellationToken) + { + Log.Add("capture"); + return default; + } + + private static ValueTask Refund(CancellationToken cancellationToken) + { + Log.Add("refund"); + return default; + } +} diff --git a/src/PatternKit.Generators.Abstractions/UnitOfWork/UnitOfWorkAttributes.cs b/src/PatternKit.Generators.Abstractions/UnitOfWork/UnitOfWorkAttributes.cs new file mode 100644 index 00000000..f890e901 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/UnitOfWork/UnitOfWorkAttributes.cs @@ -0,0 +1,22 @@ +using System; + +namespace PatternKit.Generators.UnitOfWork; + +/// Generates a UnitOfWork factory from attributed commit and rollback methods. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateUnitOfWorkAttribute : Attribute +{ + public string FactoryName { get; set; } = "Create"; +} + +/// Marks a commit step for a generated unit of work. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class UnitOfWorkStepAttribute(string name, int order) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Unit-of-work step name is required.", nameof(name)) + : name; + + public int Order { get; } = order; + public string? RollbackMethodName { get; set; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 6e6a55aa..140e88ac 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -63,6 +63,10 @@ PKTMP004 | PatternKit.Generators.Template | Error | TemplateGenerator PKTMP005 | PatternKit.Generators.Template | Error | TemplateGenerator PKTMP007 | PatternKit.Generators.Template | Warning | TemplateGenerator PKTMP008 | PatternKit.Generators.Template | Error | TemplateGenerator +PKUOW001 | PatternKit.Generators.UnitOfWork | Error | Unit of work host must be partial. +PKUOW002 | PatternKit.Generators.UnitOfWork | Error | Unit of work must declare at least one step. +PKUOW003 | PatternKit.Generators.UnitOfWork | Error | Unit of work step signature is invalid. +PKUOW004 | PatternKit.Generators.UnitOfWork | Error | Unit of work step name or order is duplicated. PKVIS001 | PatternKit.Generators.Visitor | Warning | No concrete types found for visitor generation PKVIS002 | PatternKit.Generators.Visitor | Error | Type must be partial for Accept method generation PKVIS004 | PatternKit.Generators.Visitor | Error | Derived type must be partial for Accept method generation diff --git a/src/PatternKit.Generators/UnitOfWork/UnitOfWorkGenerator.cs b/src/PatternKit.Generators/UnitOfWork/UnitOfWorkGenerator.cs new file mode 100644 index 00000000..fc249482 --- /dev/null +++ b/src/PatternKit.Generators/UnitOfWork/UnitOfWorkGenerator.cs @@ -0,0 +1,170 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.UnitOfWork; + +[Generator] +public sealed class UnitOfWorkGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.UnitOfWork.GenerateUnitOfWorkAttribute"; + private const string StepAttributeName = "PatternKit.Generators.UnitOfWork.UnitOfWorkStepAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKUOW001", "Unit of work host must be partial", + "Type '{0}' is marked with [GenerateUnitOfWork] but is not declared as partial", + "PatternKit.Generators.UnitOfWork", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingSteps = new( + "PKUOW002", "Unit of work has no steps", + "Type '{0}' must declare at least one [UnitOfWorkStep] method", + "PatternKit.Generators.UnitOfWork", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidStep = new( + "PKUOW003", "Unit of work step signature is invalid", + "Unit-of-work step '{0}' must be static and return ValueTask with one CancellationToken parameter", + "PatternKit.Generators.UnitOfWork", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateStep = new( + "PKUOW004", "Unit of work step is duplicated", + "Unit-of-work step names and orders must be unique", + "PatternKit.Generators.UnitOfWork", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == GenerateAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var steps = GetSteps(type); + if (steps.Count == 0) + { + context.ReportDiagnostic(Diagnostic.Create(MissingSteps, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (steps.Select(static s => s.Name).Distinct().Count() != steps.Count || steps.Select(static s => s.Order).Distinct().Count() != steps.Count) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateStep, node.Identifier.GetLocation())); + return; + } + + foreach (var step in steps) + { + if (!IsStep(step.Commit) || (step.Rollback is not null && !IsStep(step.Rollback))) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidStep, step.Commit.Locations.FirstOrDefault(), step.Commit.Name)); + return; + } + } + + context.AddSource($"{type.Name}.UnitOfWork.g.cs", SourceText.From( + GenerateSource(type, steps.OrderBy(static s => s.Order).ToArray(), GetNamedString(attribute, "FactoryName") ?? "Create"), + Encoding.UTF8)); + } + + private static List GetSteps(INamedTypeSymbol type) + { + var methods = type.GetMembers().OfType().ToArray(); + return methods.SelectMany(method => method.GetAttributes() + .Where(static attr => attr.AttributeClass?.ToDisplayString() == StepAttributeName) + .Select(attr => new StepConfig( + method, + attr.ConstructorArguments[0].Value?.ToString() ?? method.Name, + (int)(attr.ConstructorArguments[1].Value ?? 0), + GetNamedString(attr, "RollbackMethodName")))) + .Select(step => step with { Rollback = step.RollbackName is null ? null : methods.FirstOrDefault(m => m.Name == step.RollbackName) }) + .ToList(); + } + + private static string GenerateSource(INamedTypeSymbol type, IReadOnlyList steps, string factoryName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Application.UnitOfWork.UnitOfWork ").Append(factoryName).AppendLine("()"); + sb.AppendLine(" {"); + sb.AppendLine(" var builder = global::PatternKit.Application.UnitOfWork.UnitOfWork.Create();"); + foreach (var step in steps) + { + sb.Append(" builder.Enlist(\"").Append(Escape(step.Name)).Append("\", ").Append(step.Commit.Name); + if (step.Rollback is not null) + sb.Append(", ").Append(step.Rollback.Name); + sb.AppendLine(");"); + } + sb.AppendLine(" return builder.Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsStep(IMethodSymbol method) + => method.IsStatic + && !method.IsGenericMethod + && method.ReturnType.ToDisplayString() == "System.Threading.Tasks.ValueTask" + && method.Parameters.Length == 1 + && method.Parameters[0].Type.ToDisplayString() == "System.Threading.CancellationToken"; + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private sealed record StepConfig( + IMethodSymbol Commit, + string Name, + int Order, + string? RollbackName) + { + public IMethodSymbol? Rollback { get; init; } + } +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 32d8e22d..44869f3b 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -12,6 +12,7 @@ using PatternKit.Examples.RateLimitingDemo; using PatternKit.Examples.RepositoryDemo; using PatternKit.Examples.Strategies.Composed; +using PatternKit.Examples.UnitOfWorkDemo; using Showcase = PatternKit.Examples.PatternShowcase.PatternShowcase; using WidgetDemo = PatternKit.Examples.AbstractFactoryDemo.AbstractFactoryDemo; using TinyBDD; @@ -100,6 +101,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var interpreter = provider.GetRequiredService(); var specifications = provider.GetRequiredService(); var orderRepository = provider.GetRequiredService(); + var unitOfWork = provider.GetRequiredService(); var inventoryRetry = provider.GetRequiredService(); var fulfillmentBreaker = provider.GetRequiredService(); var shippingBulkhead = provider.GetRequiredService(); @@ -171,6 +173,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("generated interpreter evaluates VIP eligibility", interpreter.Eligibility.Interpret(PatternKit.Examples.InterpreterDemo.InterpreterDemo.VipEligibilityRule, new PatternKit.Examples.InterpreterDemo.InterpreterDemo.PricingContext { CartTotal = 150m, CustomerTier = "Gold" })), ("generated specification registry approves prime loans", specifications.Service.Evaluate(PatternKit.Examples.SpecificationDemo.LoanApprovalSpecificationDemo.CreatePrimeApplication()).Approved), ("repository example rejects duplicate order keys", orderRepository.Workflow.RunAsync().AsTask().GetAwaiter().GetResult().DuplicateRejected), + ("unit of work example commits checkout steps", unitOfWork.Workflow.RunAsync().AsTask().GetAwaiter().GetResult().Committed), ("generated retry policy recovers inventory lookups", inventoryRetry.Service.CheckAsync("SKU-42").GetAwaiter().GetResult().Available), ("generated circuit breaker isolates fulfillment outages", CircuitBreakerOpens(fulfillmentBreaker.Service)), ("generated bulkhead reserves shipping allocations", shippingBulkhead.Service.ReserveAsync("ORDER-100").GetAwaiter().GetResult().Succeeded), diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 15260ccf..bce126a3 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -62,6 +62,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "CQRS", "Specification", "Repository", + "Unit of Work", "Anti-Corruption Layer" ]; @@ -106,7 +107,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(13, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(5, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(4, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(5, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/UnitOfWorkDemo/CheckoutUnitOfWorkDemoTests.cs b/test/PatternKit.Examples.Tests/UnitOfWorkDemo/CheckoutUnitOfWorkDemoTests.cs new file mode 100644 index 00000000..6752e0eb --- /dev/null +++ b/test/PatternKit.Examples.Tests/UnitOfWorkDemo/CheckoutUnitOfWorkDemoTests.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.UnitOfWorkDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.UnitOfWorkDemo; + +[Feature("Checkout unit of work example")] +public sealed class CheckoutUnitOfWorkDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated unit of work commit ordered steps")] + [Fact] + public Task Fluent_And_Generated_Unit_Of_Work_Commit_Ordered_Steps() + => Given( + "fluent and generated checkout units of work", + (Func>)(async () => new CheckoutCommitSummaries( + await CheckoutUnitOfWorkDemo.RunFluentAsync(), + await CheckoutUnitOfWorkDemo.RunGeneratedAsync()))) + .Then("both paths commit the same ordered steps", result => + { + ScenarioExpect.True(result.Fluent.Committed); + ScenarioExpect.True(result.Generated.Committed); + ScenarioExpect.Equal(["reserve", "capture"], result.Fluent.Log); + ScenarioExpect.Equal(["reserve", "capture"], result.Generated.Log); + }) + .AssertPassed(); + + [Scenario("Unit of work rollback compensates committed steps")] + [Fact] + public Task Unit_Of_Work_Rollback_Compensates_Committed_Steps() + => Given("a checkout unit of work with a failing persist step", CheckoutUnitOfWorkDemo.RunRollbackAsync) + .Then("committed steps are compensated", summary => + { + ScenarioExpect.False(summary.Committed); + ScenarioExpect.Equal(["reserve", "undo-reserve"], summary.Log); + }) + .AssertPassed(); + + [Scenario("Unit of work example is importable through IServiceCollection")] + [Fact] + public Task Unit_Of_Work_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection importing the checkout unit of work example", () => + { + var services = new ServiceCollection(); + services.AddCheckoutUnitOfWorkDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("an importing application runs the workflow", provider => + { + using (provider) + return provider.GetRequiredService().RunAsync().AsTask().GetAwaiter().GetResult(); + }) + .Then("the workflow commits", summary => ScenarioExpect.True(summary.Committed)) + .AssertPassed(); + + private sealed record CheckoutCommitSummaries( + CheckoutUnitOfWorkSummary Fluent, + CheckoutUnitOfWorkSummary Generated); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 08ad26bb..b5f1c711 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -24,6 +24,7 @@ using PatternKit.Generators.Specification; using PatternKit.Generators.State; using PatternKit.Generators.Template; +using PatternKit.Generators.UnitOfWork; using PatternKit.Generators.Visitors; using PatternKit.Generators; using PatternKit.Generators.AntiCorruption; @@ -162,6 +163,8 @@ private enum TestTrigger { typeof(TemplateAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(TemplateStepAttribute), AttributeTargets.Method, false, false }, { typeof(TemplateHookAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateUnitOfWorkAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(UnitOfWorkStepAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateVisitorAttribute), AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, false, false } }; @@ -979,6 +982,14 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() { StepOrder = 3 }; + var unitOfWork = new GenerateUnitOfWorkAttribute + { + FactoryName = "BuildCheckout" + }; + var unitOfWorkStep = new UnitOfWorkStepAttribute("persist", 20) + { + RollbackMethodName = "UndoPersist" + }; ScenarioExpect.Equal(typeof(TestState), stateMachine.StateType); ScenarioExpect.Equal(typeof(TestTrigger), stateMachine.TriggerType); @@ -1006,6 +1017,11 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.True(step.Optional); ScenarioExpect.Equal(HookPoint.OnError, hook.HookPoint); ScenarioExpect.Equal(3, hook.StepOrder); + ScenarioExpect.Equal("BuildCheckout", unitOfWork.FactoryName); + ScenarioExpect.Equal("persist", unitOfWorkStep.Name); + ScenarioExpect.Equal(20, unitOfWorkStep.Order); + ScenarioExpect.Equal("UndoPersist", unitOfWorkStep.RollbackMethodName); + ScenarioExpect.Throws(() => new UnitOfWorkStepAttribute("", 1)); AssertEnumValues(StateMachineInvalidTriggerPolicy.Throw, StateMachineInvalidTriggerPolicy.Ignore, StateMachineInvalidTriggerPolicy.ReturnFalse); AssertEnumValues(StateMachineGuardFailurePolicy.Throw, StateMachineGuardFailurePolicy.Ignore, StateMachineGuardFailurePolicy.ReturnFalse); AssertEnumValues(HookPoint.BeforeAll, HookPoint.AfterAll, HookPoint.OnError); diff --git a/test/PatternKit.Generators.Tests/UnitOfWorkGeneratorTests.cs b/test/PatternKit.Generators.Tests/UnitOfWorkGeneratorTests.cs new file mode 100644 index 00000000..c5efdec3 --- /dev/null +++ b/test/PatternKit.Generators.Tests/UnitOfWorkGeneratorTests.cs @@ -0,0 +1,94 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Application.UnitOfWork; +using PatternKit.Generators.UnitOfWork; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class UnitOfWorkGeneratorTests +{ + [Scenario("Generates unit of work factory")] + [Fact] + public void GeneratesUnitOfWorkFactory() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.UnitOfWork; + + [GenerateUnitOfWork(FactoryName = "Build")] + public static partial class CheckoutWork + { + [UnitOfWorkStep("reserve", 10, RollbackMethodName = nameof(UndoReserve))] + private static ValueTask Reserve(CancellationToken ct) => default; + + private static ValueTask UndoReserve(CancellationToken ct) => default; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesUnitOfWorkFactory)); + var gen = new UnitOfWorkGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("CheckoutWork.UnitOfWork.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("Build", text); + ScenarioExpect.Contains("builder.Enlist(\"reserve\", Reserve, UndoReserve);", text); + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("Reports diagnostic for non partial unit of work")] + [Fact] + public void ReportsDiagnosticForNonPartialUnitOfWork() + { + var comp = CreateCompilation(""" + using PatternKit.Generators.UnitOfWork; + [GenerateUnitOfWork] + public static class CheckoutWork; + """, nameof(ReportsDiagnosticForNonPartialUnitOfWork)); + _ = RoslynTestHelpers.Run(comp, new UnitOfWorkGenerator(), out var run, out _); + + ScenarioExpect.Equal("PKUOW001", ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)).Id); + } + + [Scenario("Reports diagnostic for missing unit of work steps")] + [Fact] + public void ReportsDiagnosticForMissingUnitOfWorkSteps() + { + var comp = CreateCompilation(""" + using PatternKit.Generators.UnitOfWork; + [GenerateUnitOfWork] + public static partial class CheckoutWork; + """, nameof(ReportsDiagnosticForMissingUnitOfWorkSteps)); + _ = RoslynTestHelpers.Run(comp, new UnitOfWorkGenerator(), out var run, out _); + + ScenarioExpect.Equal("PKUOW002", ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)).Id); + } + + [Scenario("Reports diagnostic for invalid unit of work step")] + [Fact] + public void ReportsDiagnosticForInvalidUnitOfWorkStep() + { + var comp = CreateCompilation(""" + using PatternKit.Generators.UnitOfWork; + [GenerateUnitOfWork] + public static partial class CheckoutWork + { + [UnitOfWorkStep("reserve", 10)] + private static void Reserve() { } + } + """, nameof(ReportsDiagnosticForInvalidUnitOfWorkStep)); + _ = RoslynTestHelpers.Run(comp, new UnitOfWorkGenerator(), out var run, out _); + + ScenarioExpect.Equal("PKUOW003", ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)).Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Application.UnitOfWork.UnitOfWork).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Application/UnitOfWork/UnitOfWorkTests.cs b/test/PatternKit.Tests/Application/UnitOfWork/UnitOfWorkTests.cs new file mode 100644 index 00000000..e8eef9f8 --- /dev/null +++ b/test/PatternKit.Tests/Application/UnitOfWork/UnitOfWorkTests.cs @@ -0,0 +1,77 @@ +using PatternKit.Application.UnitOfWork; +using TinyBDD; + +namespace PatternKit.Tests.Application.UnitOfWork; + +public sealed class UnitOfWorkTests +{ + [Scenario("CommitAsync ExecutesStepsInOrder")] + [Fact] + public async Task CommitAsync_ExecutesStepsInOrder() + { + var log = new List(); + var unit = PatternKit.Application.UnitOfWork.UnitOfWork.Create() + .Enlist("reserve", _ => { log.Add("reserve"); return default; }) + .Enlist("persist", _ => { log.Add("persist"); return default; }) + .Build(); + + var result = await unit.CommitAsync(); + + ScenarioExpect.True(result.Committed); + ScenarioExpect.Equal(["reserve", "persist"], log); + ScenarioExpect.Equal(["reserve", "persist"], result.CommittedSteps); + } + + [Scenario("CommitAsync RollsBackCommittedStepsWhenLaterStepFails")] + [Fact] + public async Task CommitAsync_RollsBackCommittedStepsWhenLaterStepFails() + { + var log = new List(); + var unit = PatternKit.Application.UnitOfWork.UnitOfWork.Create() + .Enlist("reserve", _ => { log.Add("reserve"); return default; }, _ => { log.Add("undo-reserve"); return default; }) + .Enlist("persist", _ => throw new InvalidOperationException("db failed")) + .Build(); + + var result = await unit.CommitAsync(); + + ScenarioExpect.False(result.Committed); + ScenarioExpect.Equal("persist", result.FailedStep); + ScenarioExpect.Equal(["reserve", "undo-reserve"], log); + ScenarioExpect.True(result.Rollback!.Succeeded); + ScenarioExpect.Equal(["reserve"], result.Rollback.RolledBackSteps); + } + + [Scenario("RollbackAsync RunsCompensationsInReverseOrder")] + [Fact] + public async Task RollbackAsync_RunsCompensationsInReverseOrder() + { + var log = new List(); + var unit = PatternKit.Application.UnitOfWork.UnitOfWork.Create() + .Enlist("one", _ => default, _ => { log.Add("undo-one"); return default; }) + .Enlist("two", _ => default, _ => { log.Add("undo-two"); return default; }) + .Build(); + + var result = await unit.RollbackAsync(); + + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal(["undo-two", "undo-one"], log); + ScenarioExpect.Equal(["two", "one"], result.RolledBackSteps); + } + + [Scenario("UnitOfWork ValidatesInputsAndCancellation")] + [Fact] + public async Task UnitOfWork_ValidatesInputsAndCancellation() + { + ScenarioExpect.Throws(() => PatternKit.Application.UnitOfWork.UnitOfWork.Create().Enlist("", _ => default)); + ScenarioExpect.Throws(() => PatternKit.Application.UnitOfWork.UnitOfWork.Create().Enlist("step", null!)); + ScenarioExpect.Throws(() => PatternKit.Application.UnitOfWork.UnitOfWork.Create().Enlist("step", _ => default).Enlist("step", _ => default)); + ScenarioExpect.Throws(() => new UnitOfWorkStep("", _ => default, _ => default)); + ScenarioExpect.Throws(() => new UnitOfWorkStep("step", null!, _ => default)); + ScenarioExpect.Throws(() => new UnitOfWorkStep("step", _ => default, null!)); + + using var source = new CancellationTokenSource(); + source.Cancel(); + var unit = PatternKit.Application.UnitOfWork.UnitOfWork.Create().Enlist("step", _ => default).Build(); + await ScenarioExpect.ThrowsAsync(async () => await unit.CommitAsync(source.Token)); + } +}