Skip to content
Tutorial4/5

4. Serialize, send, and run

In this chapter you finish the last two steps, create the system host, and watch a real order travel the whole pipeline.

src/Send/Serializer.cs turns the transformed order back into bytes for the destination. Replace the throwing body:

src/Send/Serializer.cs
public class Serializer : SerializeStep<Out, Context>
{
public override Task<(TechnicalStepResult<string> Result, Context Context)> ExecuteAsync(
Out input, Context context, CancellationToken ct)
{
var json = JsonSerializer.Serialize(input);
return Task.FromResult<(TechnicalStepResult<string>, Context)>(
(new TechnicalStepResult<string>.Success(json), context));
}
}

The Sender writes through an IFileAdapter: a Dapr output binding under the hood, injected through the constructor so the step never knows whether the destination is a local folder, an SFTP share, or blob storage. This step does I/O, so add async to the signature along with the body:

src/Send/Sender.cs
public class Sender(IFileAdapter fileAdapter) : SendStep<Context>
{
public override async Task<(BusinessStepResult<string> Result, Context Context)> ExecuteAsync(
string input, Context context, CancellationToken ct)
{
var orderId = context.Metadata[Constants.ContextKeyOrderId];
var fileName = $"{orderId}.json";
await fileAdapter.WriteAsync(fileName, input, Encoding.UTF8);
return (new BusinessStepResult<string>.Success(fileName), context);
}
}

The output file is named after the order id from context — promoted in chapter 2, used here, never re-derived in between. It’s a BusinessStepResult because a destination that refuses delivery is an expected condition at the edge, same as the Validator’s failures.

Pin both steps down in their unit stubs:

test/OrderSync.Test.Unit/Send/SerializerTests.cs
[Fact]
public async Task ExecuteAsync_WithOrder_RoundTripsThroughJson()
{
var serializer = new Serializer();
var output = new Out(
OrderId: "ORD-1001",
CustomerId: "CUST-42",
ProcessedAt: new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.Zero),
TotalAmount: 25.0m,
Status: "Processed");
var (result, _) = await serializer.ExecuteAsync(
output, new Context(new Dictionary<string, string>()), CancellationToken.None);
var success = Assert.IsType<TechnicalStepResult<string>.Success>(result);
Assert.Equal(output, JsonSerializer.Deserialize<Out>(success.Value));
}
test/OrderSync.Test.Unit/Send/SenderTests.cs
[Fact]
public async Task ExecuteAsync_WithOrderIdInContext_WritesPayloadToOrderNamedFile()
{
var fileAdapter = Substitute.For<IFileAdapter>();
var sender = new Sender(fileAdapter);
var context = new Context(new Dictionary<string, string>
{
[Constants.ContextKeyOrderId] = "ORD-1001"
});
const string payload = """{"orderId":"ORD-1001"}""";
var (result, _) = await sender.ExecuteAsync(payload, context, CancellationToken.None);
await fileAdapter.Received(1).WriteAsync("ORD-1001.json", payload, Encoding.UTF8);
var success = Assert.IsType<BusinessStepResult<string>.Success>(result);
Assert.Equal("ORD-1001.json", success.Value);
}

Now run the whole suite, integration tests included:

Terminal window
dotnet test
Passed! - Failed: 0, Passed: 12, Skipped: 0, Total: 12 - OrderSync.Test.Unit.dll
[xUnit.net] OrderSync.Test.Integration.ReceivePipelineIntegrationTests.Execute_WithUnreadableFile_RoutesIncidentKeyedOnFileName [FAIL]
Failed! - Failed: 1, Passed: 7, Skipped: 0, Total: 8 - OrderSync.Test.Integration.dll

The send-pipeline specification from chapter 1, Execute_WithValidMessage_WritesOutput, just flipped green without a single test edit: a valid order now travels deserialize → extract → validate → transform → serialize → send through the real DI graph and lands in the faked destination. The CS9113 build warning is gone too: the Sender finally reads its file adapter. One red pin remains, and it belongs to chapter 5.

The component doesn’t run standalone; a System runs it. Step up to the workspace directory (the parent of order-sync/) and let the CLI assemble the System from the scaffold records it finds:

Terminal window
cd ..
intropy sys create -n OrderSync -o order-sync-host
fetching integrio-intropy/intropy-templates@v0.4.1
created order-sync-host from integrio-intropy/intropy-templates@v0.4.1 (template system-host)
assembled system "order-sync": 1 component(s), 0 topic(s), 2 port(s), no contracts project

-o is needed here because intropy sys create defaults its output directory to the kebab-cased system name (order-sync), and the component already owns that folder. The host must sit next to the component either way: it resolves each declared component to a sibling folder of the same name.

The host is a small Aspire project whose heart is the system definition, rendered from your component’s .intropy/scaffold.json:

order-sync-host/OrderSyncSystem.cs
public sealed class OrderSyncSystem : ISystemDefinition
{
public string SystemName => "order-sync";
public void Define(SystemBuilder builder)
{
builder.AddTransactionalIntegration("order-sync")
.From(Ports.OrderSyncSource)
.To(Ports.OrderSyncDestination)
.Uses(Services.Idempotency)
.Uses(Services.BusinessIncidents);
}
}

Alongside it, the development definition (OrderSyncDevelopment.cs) resolves each port to a folder under test/ and stands in OpenAPI-backed mocks for the two platform services (declared by the System already, though your component won’t call them until chapter 5). Systems and topology is the concept behind all of this.

Validate the declaration and look at the model it produces:

Terminal window
cd order-sync-host
dotnet run -- check
ok: 'order-sync' is valid (1 components).
Terminal window
dotnet run -- graph

The graph shows what makes a Transactional Integration different from other components: no System topics, just two ports and the component-owned internal hop (output abbreviated):

{
"apiVersion": "topology.intropy.io/v1",
"kind": "SystemTopology",
"system": "order-sync",
"components": [
{
"name": "order-sync",
"kind": "transactional-integration",
"ports": [
{ "port": "order-sync-source", "direction": "in" },
{ "port": "order-sync-destination", "direction": "out" }
],
"uses": ["idempotency-service", "business-incident-service"],
"internalQueue": { "pubsub": "internal-order-sync", "topic": "hop" }
}
]
}

A transactional integration is a run-to-completion job: the host runs it once at startup, and it sweeps whatever is in the source folder at that moment. So seed first, then start. The host ships a sample order and a seed task for exactly this:

Terminal window
task seed PORT_DIR=./test/order-sync-source
dotnet run
task: [seed] mkdir -p ./test/order-sync-source
task: [seed] cp ./sample-data/sample-order.json ./test/order-sync-source/
Seeded ./test/order-sync-source with sample-order.json

dotnet run needs Docker running: the mocked platform services and the pub/sub broker run as containers. Startup prints the Aspire dashboard’s login URL (the port and token vary per run) and then:

info: Aspire.Hosting.DistributedApplication[0]
Login to the dashboard at http://localhost:15170/login?t=<token>
info: Aspire.Hosting.DistributedApplication[0]
Distributed application started. Press Ctrl+C to shut down.

Within a few seconds the job sweeps the seeded file (the receive pipeline reads it, publishes it to the internal queue, deletes the source file) and the send pipeline pulls it through your six steps. The proof is in the destination folder:

Terminal window
cat test/order-sync-destination/ORD-1001.json
{"OrderId":"ORD-1001","CustomerId":"CUST-42","ProcessedAt":"2026-08-12T19:39:15.412914+00:00","TotalAmount":88.90,"Status":"Processed"}

Two line totals summed to 88.90 (2 × 19.95 + 49.00), the status stamped, and test/order-sync-source/ empty again: the Completer deleted the input. Open the dashboard login URL to watch the same run as resources, console logs, and traces. Stop the host with Ctrl+C; to re-run, seed again and start it again.