Skip to content
Tutorial3/5

3. Extract, validate, transform

Implement the Extractor, Validator, and Transformer.

This integration needs no enrichment. Keep the existing ExecuteAsync signature in src/Send/Extractor.cs. Replace only its body:

src/Send/Extractor.cs: ExecuteAsync body
{
return Task.FromResult<(BusinessStepResult<In>, Context)>(
(new BusinessStepResult<In>.Success(input), context));
}

Validate the fields the pipeline needs. Keep the existing ExecuteAsync signature in src/Send/Validator.cs. Replace only its body:

src/Send/Validator.cs: ExecuteAsync 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:

src/Send/Validator.cs: Validate helper
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.

First define the outbound shape in src/Model/Out.cs:

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:

src/Send/Transformer.cs: ExecuteAsync 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));
}

Add these tests to the empty class in test/OrderSync.Test.Unit/Send/ValidatorTests.cs:

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:

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)
});
Terminal window
dotnet test ./test/OrderSync.Test.Unit
Passed! - 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.