3. Extract, validate, transform
Implement the Extractor, Validator, and Transformer.
The Extractor: pass it through
Section titled “The Extractor: pass it through”This integration needs no enrichment. Keep the existing ExecuteAsync signature in src/Send/Extractor.cs. Replace only its body:
{ return Task.FromResult<(BusinessStepResult<In>, Context)>( (new BusinessStepResult<In>.Success(input), context));}The Validator: failures are data
Section titled “The Validator: failures are data”Validate the fields the pipeline needs. Keep the existing ExecuteAsync signature in src/Send/Validator.cs. Replace only its body:
{ var error = Validate(input); if (error is not null) { var incident = new BusinessIncidentData { Description = error, Context = new Dictionary<string, string> { { "orderId", input.OrderId } } }; return Task.FromResult<(BusinessStepResult<In>, Context)>( (new BusinessStepResult<In>.Failure(incident), context)); }
return Task.FromResult<(BusinessStepResult<In>, Context)>( (new BusinessStepResult<In>.Success(input), context));}Add this helper method inside Validator, below ExecuteAsync:
private static string? Validate(In order){ if (string.IsNullOrWhiteSpace(order.OrderId)) return "OrderId is required"; if (string.IsNullOrWhiteSpace(order.CustomerId)) return "CustomerId is required"; if (order.OrderDate > DateTimeOffset.UtcNow) return $"OrderDate {order.OrderDate:O} is in the future"; if (order.Lines.Count == 0) return "Order must contain at least one line"; return null;}The Validator returns BusinessStepResult.Failure for expected business conditions. The Transformer below returns TechnicalStepResult.
The Transformer: pure by construction
Section titled “The Transformer: pure by construction”First define the outbound shape in src/Model/Out.cs:
namespace OrderSync.Model;
public record Out( string OrderId, string CustomerId, DateTimeOffset ProcessedAt, decimal TotalAmount, string Status);Then map In to Out. Read ProcessedAt from context. Keep the existing ExecuteAsync signature in src/Send/Transformer.cs; replace only its body:
{ var total = input.Lines.Sum(l => l.UnitPrice * l.Quantity); var output = new Out( OrderId: input.OrderId, CustomerId: input.CustomerId, ProcessedAt: DateTimeOffset.Parse(context.Metadata[Constants.ContextKeyProcessedAt], CultureInfo.InvariantCulture), TotalAmount: total, Status: "Processed");
return Task.FromResult<(TechnicalStepResult<Out>, Context)>( (new TechnicalStepResult<Out>.Success(output), context));}Check it
Section titled “Check it”Add these tests to the empty class in test/OrderSync.Test.Unit/Send/ValidatorTests.cs:
[Fact]public async Task ExecuteAsync_WithValidOrder_ReturnsSuccess(){ var validator = new Validator();
var (result, _) = await validator.ExecuteAsync( CreateValidOrder(), new Context(new Dictionary<string, string>()), CancellationToken.None);
Assert.IsType<BusinessStepResult<In>.Success>(result);}
[Fact]public async Task ExecuteAsync_WithMissingOrderId_ReturnsFailure(){ var validator = new Validator(); var order = CreateValidOrder() with { OrderId = "" };
var (result, _) = await validator.ExecuteAsync( order, new Context(new Dictionary<string, string>()), CancellationToken.None);
var failure = Assert.IsType<BusinessStepResult<In>.Failure>(result); Assert.Equal("OrderId is required", failure.Value.Description);}
[Fact]public async Task ExecuteAsync_WithFutureOrderDate_ReturnsFailure(){ var validator = new Validator(); var order = CreateValidOrder() with { OrderDate = DateTimeOffset.UtcNow.AddDays(1) };
var (result, _) = await validator.ExecuteAsync( order, new Context(new Dictionary<string, string>()), CancellationToken.None);
Assert.IsType<BusinessStepResult<In>.Failure>(result);}
[Fact]public async Task ExecuteAsync_WithNoLines_ReturnsFailure(){ var validator = new Validator(); var order = CreateValidOrder() with { Lines = [] };
var (result, _) = await validator.ExecuteAsync( order, new Context(new Dictionary<string, string>()), CancellationToken.None);
var failure = Assert.IsType<BusinessStepResult<In>.Failure>(result); Assert.Equal("Order must contain at least one line", failure.Value.Description);}
private static In CreateValidOrder() => new( OrderId: "ORD-1001", CustomerId: "CUST-42", OrderDate: DateTimeOffset.UtcNow.AddDays(-1), Lines: [new Line("SKU-1", 2, 10.0m)]);Add these tests to the empty class in test/OrderSync.Test.Unit/Send/TransformerTests.cs:
[Fact]public async Task ExecuteAsync_WithMultipleLines_SumsLineTotals(){ var transformer = new Transformer(); var order = new In( OrderId: "ORD-1001", CustomerId: "CUST-42", OrderDate: DateTimeOffset.UtcNow.AddDays(-1), Lines: [new Line("SKU-1", 2, 10.0m), new Line("SKU-2", 1, 5.0m)]);
var (result, _) = await transformer.ExecuteAsync(order, CreateContext(), CancellationToken.None);
var success = Assert.IsType<TechnicalStepResult<Out>.Success>(result); Assert.Equal(25.0m, success.Value.TotalAmount); Assert.Equal("Processed", success.Value.Status);}
[Fact]public async Task ExecuteAsync_WithProcessedAtInContext_UsesItVerbatim(){ var transformer = new Transformer(); var processedAt = new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero); var order = new In("ORD-1001", "CUST-42", DateTimeOffset.UtcNow.AddDays(-1), [new Line("SKU-1", 1, 1.0m)]);
var (result, _) = await transformer.ExecuteAsync( order, CreateContext(processedAt), CancellationToken.None);
var success = Assert.IsType<TechnicalStepResult<Out>.Success>(result); Assert.Equal(processedAt, success.Value.ProcessedAt);}
private static Context CreateContext(DateTimeOffset? processedAt = null) => new( new Dictionary<string, string> { [Constants.ContextKeyProcessedAt] = (processedAt ?? DateTimeOffset.UtcNow).ToString("O", CultureInfo.InvariantCulture) });dotnet test ./test/OrderSync.Test.UnitPassed! - Failed: 0, Passed: 8, Skipped: 0, Total: 8 - OrderSync.Test.Unit.dll (net10.0)Eight unit tests should pass. The integration suite still has the expected red test.