3. Extract, validate, transform
In this chapter you implement the pipeline’s business steps, and meet the line Intropy draws between business failures and technical ones.
The Extractor: pass it through
Section titled “The Extractor: pass it through”src/Send/Extractor.cs is the enrichment seam: the place to split a batch message or pull in reference data before validation. This integration needs neither, so the step passes the order straight through. Replace the throwing body:
public class Extractor : ExtractStep<In, Context>{ public override Task<(BusinessStepResult<In> Result, Context Context)> ExecuteAsync( In input, Context context, CancellationToken ct) { return Task.FromResult<(BusinessStepResult<In>, Context)>( (new BusinessStepResult<In>.Success(input), context)); }}The Validator: failures are data
Section titled “The Validator: failures are data”The Validator checks that the order is rich enough to process. When it isn’t, that’s not an exception to throw. It’s an expected operational condition, described as data and returned as a Failure:
public class Validator : ValidateStep<In, Context>{ public override Task<(BusinessStepResult<In> Result, Context Context)> ExecuteAsync( In input, Context context, CancellationToken ct) { 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)); }
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; }}Note the return types across the three steps in this chapter. The Extractor and Validator return BusinessStepResult: their failures are business failures, expected and routable to a process owner as business incidents. The Transformer below returns TechnicalStepResult — by the time data reaches it, the edge has done its job, and a failure there is a bug, not a condition to route.
The Transformer: pure by construction
Section titled “The Transformer: pure by construction”src/Send/Transformer.cs maps In to the outbound shape. First fill in the empty Out shell:
namespace OrderSync.Model;
public record Out( string OrderId, string CustomerId, DateTimeOffset ProcessedAt, decimal TotalAmount, string Status);Then the step. ProcessedAt is read from the context the Deserializer stamped in chapter 2 — no clock in sight:
public class Transformer : TransformStep<In, Out, Context>{ public override Task<(TechnicalStepResult<Out> Result, Context Context)> ExecuteAsync( In input, Context context, CancellationToken ct) { 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”Purity pays off immediately: none of these tests needs a mock, just an input and a context. Replace the placeholder comment in test/OrderSync.Test.Unit/Send/ValidatorTests.cs — one test for the happy path, one per failure the incident text must name:
[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)]);And the placeholder 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: 10, Skipped: 0, Total: 10 - OrderSync.Test.Unit.dllTen green: Receiver (2), Deserializer (2), Validator (4), Transformer (2). The integration suite’s two red pins are unchanged — the send pipeline still ends in a throwing Serializer, and that’s chapter 4’s job.