Skip to content
Tutorial2/5

2. The model and the Deserializer

In this chapter you define the message the pipeline works on and implement the one step that’s allowed to be impure.

The send pipeline receives raw bytes from the internal queue and works on a typed message from then on. The scaffold left src/Model/In.cs as an empty shell (public record In();) and Line.cs as an empty file. Fill them in with only the fields the integration actually maps — nothing speculative:

src/Model/In.cs
namespace OrderSync.Model;
public record In(
string OrderId,
string CustomerId,
DateTimeOffset OrderDate,
IReadOnlyList<Line> Lines);
src/Model/Line.cs
namespace OrderSync.Model;
public record Line(
string Sku,
int Quantity,
decimal UnitPrice);

Out stays an empty shell until chapter 3 — the Transformer defines what leaves the pipeline, and you haven’t met it yet.

Replace the NotImplementedException body in src/Send/Deserializer.cs (the usings are already in place):

src/Send/Deserializer.cs
public class Deserializer : DeserializeStep<In, Context>
{
public override Task<(BusinessStepResult<In> Result, Context Context)> ExecuteAsync(
ReadOnlyMemory<byte> input, Context context, CancellationToken ct)
{
var order = JsonSerializer.Deserialize<In>(input.Span,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
context.Metadata[Constants.ContextKeyOrderId] = order.OrderId;
context.Metadata[Constants.ContextKeyProcessedAt] = DateTimeOffset.UtcNow.ToString("O");
return Task.FromResult<(BusinessStepResult<In>, Context)>(
(new BusinessStepResult<In>.Success(order), context));
}
}

The two context lines are the point of this chapter, and they’re there for two different reasons. ProcessedAt is the impure one: it comes from a clock, not from the message, so a later step couldn’t recompute it without getting a different answer. It’s read once, here, and every step after this one stays pure — same input, same output, no clocks, no GUIDs. That’s what makes the rest of the pipeline unit-testable without mocks, and it’s a discipline worth keeping even when nothing enforces it.

OrderId is an ordinary field from the payload, promoted into Context.Metadata for a different reason: later steps won’t hold the typed message. By the time the Sender runs, the pipeline is working on a serialized string, and the idempotency configuration in chapter 5 sees only the context. Promoting the id means any step can name the order without re-parsing. If you’re coming from BizTalk, this is the same move as promoting a property into the message context. The keys live in src/Configuration/Constants.cs so no step spells a metadata key twice.

The scaffold left a skipped placeholder in test/OrderSync.Test.Unit/Send/DeserializerTests.cs. Replace it with two real tests, one per responsibility (the file’s usings are already in place):

test/OrderSync.Test.Unit/Send/DeserializerTests.cs
[Fact]
public async Task ExecuteAsync_WithValidJson_ReturnsSuccess()
{
var deserializer = new Deserializer();
var context = new Context(new Dictionary<string, string>());
var json = """{"orderId":"ORD-1001","customerId":"CUST-42","orderDate":"2026-01-15T09:30:00+00:00","lines":[{"sku":"SKU-1","quantity":2,"unitPrice":10.0}]}""";
var (result, _) = await deserializer.ExecuteAsync(
Encoding.UTF8.GetBytes(json), context, CancellationToken.None);
var success = Assert.IsType<BusinessStepResult<In>.Success>(result);
Assert.Equal("ORD-1001", success.Value.OrderId);
Assert.Single(success.Value.Lines);
}
[Fact]
public async Task ExecuteAsync_WithValidJson_StoresOrderIdAndProcessedAtInContext()
{
var deserializer = new Deserializer();
var context = new Context(new Dictionary<string, string>());
var json = """{"orderId":"ORD-1001","customerId":"CUST-42","orderDate":"2026-01-15T09:30:00+00:00","lines":[]}""";
var (_, resultContext) = await deserializer.ExecuteAsync(
Encoding.UTF8.GetBytes(json), context, CancellationToken.None);
Assert.Equal("ORD-1001", resultContext.Metadata[Constants.ContextKeyOrderId]);
Assert.True(resultContext.Metadata.ContainsKey(Constants.ContextKeyProcessedAt));
}

Run the fast unit suite on its own — the scaffold’s Taskfile.yml splits it out precisely for this phase, where the integration suite’s two red pins are expected:

Terminal window
dotnet test ./test/OrderSync.Test.Unit
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4 - OrderSync.Test.Unit.dll

Four green: your two new Deserializer tests plus the two Receiver tests that shipped with the scaffold.