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.
The Serializer
Section titled “The Serializer”src/Send/Serializer.cs turns the transformed order back into bytes for the destination. Replace the throwing body:
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
Section titled “The Sender”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:
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:
[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));}[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:
dotnet testPassed! - 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.dllThe 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.
Create the system host
Section titled “Create the system host”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:
cd ..intropy sys create -n OrderSync -o order-sync-hostfetching integrio-intropy/intropy-templates@v0.4.1created 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:
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:
cd order-sync-hostdotnet run -- checkok: 'order-sync' is valid (1 components).dotnet run -- graphThe 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" } } ]}Check it
Section titled “Check it”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:
task seed PORT_DIR=./test/order-sync-sourcedotnet runtask: [seed] mkdir -p ./test/order-sync-sourcetask: [seed] cp ./sample-data/sample-order.json ./test/order-sync-source/Seeded ./test/order-sync-source with sample-order.jsondotnet 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:
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.