Skip to content

Commit ae1df49

Browse files
authored
refactor: dogfood PatternKit middleware chain (#29)
* refactor: dogfood PatternKit middleware chain * ci: harden CodeQL build * ci: scope CodeQL build to core library
1 parent cc5b0d6 commit ae1df49

4 files changed

Lines changed: 92 additions & 11 deletions

File tree

.github/workflows/codeql-analysis.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,9 @@ jobs:
2626
- uses: github/codeql-action/init@v3
2727
with:
2828
languages: ${{ matrix.language }}
29-
- run: dotnet build WorkflowFramework.slnx -c Release
29+
- name: Restore
30+
run: dotnet restore src/WorkflowFramework/WorkflowFramework.csproj --use-lock-file
31+
- name: Build
32+
timeout-minutes: 10
33+
run: dotnet build src/WorkflowFramework/WorkflowFramework.csproj -c Release --no-restore /p:ContinuousIntegrationBuild=true
3034
- uses: github/codeql-action/analyze@v3

docs/patternkit-adoption.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ This document lists every point in the WorkflowFramework codebase where a Patter
3232
| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Core/WorkflowStatusMachineScenarios.cs` |
3333
| **Limitation note** | The `Running→Compensated` transition is driven by compensation event from `WorkflowEngine` rather than a built-in PatternKit saga hook — PatternKit's state machine builder does not expose a compensation lifecycle hook, so the event is fired manually by the engine. |
3434

35+
### 2a. `WorkflowEngine` middleware pipeline — Chain of responsibility pattern
36+
37+
| Item | Detail |
38+
|------|--------|
39+
| **File** | `src/WorkflowFramework/WorkflowEngine.cs` |
40+
| **PatternKit namespace** | `PatternKit.Behavioral.Chain` |
41+
| **Primitive** | `AsyncActionChain<MiddlewareInvocationState>` |
42+
| **Purpose** | Composes registered `IWorkflowMiddleware` instances in registration order around each step execution. The public `IWorkflowMiddleware` and `StepDelegate` contracts are unchanged; PatternKit owns the runtime chain execution and short-circuit behavior. |
43+
| **Phase introduced** | PatternKit dogfood pass / issue #28 |
44+
| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Core/Engine/WorkflowEngineScenarios.cs` — middleware order, short-circuit, and context mutation scenarios. |
45+
| **Public API change** | None — swap is internal-only. |
46+
3547
### 3. `ContentBasedRouterStep` — Strategy pattern
3648

3749
| Item | Detail |

src/WorkflowFramework/WorkflowEngine.cs

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using PatternKit.Behavioral.Chain;
12
using WorkflowFramework.Internal;
23
using WfEvent = WorkflowFramework.Internal.WorkflowStatusMachine.WorkflowEvent;
34

@@ -10,6 +11,7 @@ public sealed class WorkflowEngine : IWorkflow
1011
{
1112
private readonly IStep[] _steps;
1213
private readonly IWorkflowMiddleware[] _middleware;
14+
private readonly AsyncActionChain<MiddlewareInvocationState> _middlewareChain;
1315
private readonly IWorkflowEvents[] _events;
1416
private readonly bool _enableCompensation;
1517

@@ -31,6 +33,7 @@ public WorkflowEngine(
3133
Name = name ?? throw new ArgumentNullException(nameof(name));
3234
_steps = steps ?? throw new ArgumentNullException(nameof(steps));
3335
_middleware = middleware ?? throw new ArgumentNullException(nameof(middleware));
36+
_middlewareChain = BuildMiddlewareChain(_middleware);
3437
_events = events ?? throw new ArgumentNullException(nameof(events));
3538
_enableCompensation = enableCompensation;
3639
}
@@ -123,23 +126,49 @@ private static void FireTransition(ref WorkflowStatus status, WfEvent @event)
123126

124127
private async Task ExecuteWithMiddlewareAsync(IWorkflowContext context, IStep step)
125128
{
126-
if (_middleware.Length == 0)
129+
var state = new MiddlewareInvocationState(context, step);
130+
await _middlewareChain.ExecuteAsync(state, context.CancellationToken).ConfigureAwait(false);
131+
}
132+
133+
private static AsyncActionChain<MiddlewareInvocationState> BuildMiddlewareChain(
134+
IReadOnlyList<IWorkflowMiddleware> middleware)
135+
{
136+
var builder = AsyncActionChain<MiddlewareInvocationState>.Create();
137+
138+
foreach (var item in middleware)
127139
{
128-
await step.ExecuteAsync(context).ConfigureAwait(false);
129-
return;
140+
builder.Use(async (state, ct, next) =>
141+
{
142+
await item.InvokeAsync(
143+
state.Context,
144+
state.Step,
145+
InvokeNextAsync).ConfigureAwait(false);
146+
147+
async Task InvokeNextAsync(IWorkflowContext ctx)
148+
{
149+
await next(new MiddlewareInvocationState(ctx, state.Step), ct).ConfigureAwait(false);
150+
}
151+
});
130152
}
131153

132-
// Build middleware chain
133-
StepDelegate current = ctx => step.ExecuteAsync(ctx);
154+
builder.Finally(async (state, _) =>
155+
{
156+
await state.Step.ExecuteAsync(state.Context).ConfigureAwait(false);
157+
});
158+
159+
return builder.Build();
160+
}
134161

135-
for (var i = _middleware.Length - 1; i >= 0; i--)
162+
private sealed class MiddlewareInvocationState
163+
{
164+
public MiddlewareInvocationState(IWorkflowContext context, IStep step)
136165
{
137-
var middleware = _middleware[i];
138-
var next = current;
139-
current = ctx => middleware.InvokeAsync(ctx, step, next);
166+
Context = context;
167+
Step = step;
140168
}
141169

142-
await current(context).ConfigureAwait(false);
170+
public IWorkflowContext Context { get; }
171+
public IStep Step { get; }
143172
}
144173

145174
private static async Task CompensateAsync(IWorkflowContext context, List<IStep> completedSteps)

tests/WorkflowFramework.Tests.TinyBDD/Core/Engine/WorkflowEngineScenarios.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,32 @@ await Given("middleware that does not call next()", () => (result, stepRan))
393393
.AssertPassed();
394394
}
395395

396+
[Scenario("Middleware chain preserves context mutations around step execution"), Fact]
397+
public async Task MiddlewareChainPreservesContextMutationsAroundStepExecution()
398+
{
399+
var wf = Workflow.Create("mw-context")
400+
.Use(new ContextMutationMiddleware())
401+
.Step("step", ctx =>
402+
{
403+
ctx.Properties["step-saw-before"] = ctx.Properties["before"];
404+
return Task.CompletedTask;
405+
})
406+
.Build();
407+
var context = new WorkflowContext();
408+
409+
var result = await wf.ExecuteAsync(context);
410+
411+
await Given("a workflow middleware mutates context before and after next", () => (result, context.Properties))
412+
.Then("the step sees the before mutation and after mutation is retained", t =>
413+
{
414+
t.result.IsSuccess.Should().BeTrue();
415+
t.Properties["step-saw-before"].Should().Be(true);
416+
t.Properties["after"].Should().Be(true);
417+
return true;
418+
})
419+
.AssertPassed();
420+
}
421+
396422
[Scenario("Workflow name is exposed on the IWorkflow interface"), Fact]
397423
public async Task WorkflowNameIsExposed()
398424
{
@@ -494,4 +520,14 @@ private sealed class ShortCircuitMiddleware : IWorkflowMiddleware
494520
{
495521
public Task InvokeAsync(IWorkflowContext ctx, IStep step, StepDelegate next) => Task.CompletedTask;
496522
}
523+
524+
private sealed class ContextMutationMiddleware : IWorkflowMiddleware
525+
{
526+
public async Task InvokeAsync(IWorkflowContext ctx, IStep step, StepDelegate next)
527+
{
528+
ctx.Properties["before"] = true;
529+
await next(ctx);
530+
ctx.Properties["after"] = true;
531+
}
532+
}
497533
}

0 commit comments

Comments
 (0)