4. Serialize, send, and run
Finish the send pipeline, create the host, and run two orders.
The Serializer
Section titled “The Serializer”Keep the existing ExecuteAsync signature in src/Send/Serializer.cs. Replace only its body:
{ 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 IFileAdapter. Keep the existing ExecuteAsync signature in src/Send/Sender.cs and add async to it. Replace only its body:
{ 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 uses the order id from context.
Add a test to each step’s empty test class:
[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);
// The text write is an extension over the adapter's binary WriteAsync: assert on the bytes. await fileAdapter.Received(1).WriteAsync("ORD-1001.json", Arg.Is<byte[]>(bytes => Encoding.UTF8.GetString(bytes) == payload), Arg.Any<string?>(), Arg.Any<CancellationToken>()); var success = Assert.IsType<BusinessStepResult<string>.Success>(result); Assert.Equal("ORD-1001.json", success.Value);}Run the whole test suite:
dotnet testPassed! - Failed: 0, Passed: 10, Skipped: 0, Total: 10 - OrderSync.Test.Unit.dll (net10.0)Passed! - Failed: 0, Passed: 5, Skipped: 0, Total: 5 - OrderSync.Test.Integration.dll (net10.0)The valid-message integration test now passes, and the build no longer warns about fileAdapter.
Create the system host
Section titled “Create the system host”Create the host from the workspace directory, the parent of order-sync/:
cd ..intropy sys create -n OrderSync -o order-sync-hostusing cached integrio-intropy/intropy-templates@v2.0.0created order-sync-host from integrio-intropy/intropy-templates@v2.0.0 (template system-host)assembled system "order-sync": 1 component(s), 0 topic(s), 2 port(s), no contracts projectUse -o because the Component already owns the order-sync/ directory.
Open the generated system definition:
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) .Calls(Services.Idempotency) .Calls(Services.BusinessIncidents); }}Validate the declaration and print the model:
cd order-sync-hostdotnet run -- checkok: 'order-sync' is valid (1 components).The build before it prints warning ASPIRE010 about AspireUseCliBundle=false. The host does not use the Aspire CLI bundle; ignore the warning.
dotnet run -- graphThe JSON follows the build output. It has two Ports and the Component-owned internal hop:
{ "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": "order-sync-pubsub", "topic": "internal-order-sync", "resource": "order-sync-subscription", "defaultPath": "/unhandled" }, "channel": { "pubsub": "order-sync-pubsub", "topic": "internal-order-sync" } } ]}The internal queue is the topic internal-order-sync on the System’s pub/sub, order-sync-pubsub. channel is the pub/sub and topic the host passes to the Component as INTROPY_PUBSUB_NAME and INTROPY_TOPIC_NAME. The full output also aggregates the same declarations into top-level ports and services sections, one entry per name with its users, and lists a messagegroups entry for order-sync with no messages, because this Component declares no message contracts.
Check it
Section titled “Check it”Seed the source folder with the two sample orders before starting the host:
mkdir -p test/order-sync-sourcecp sample-data/sample-order-status-0.json sample-data/sample-order-status-1.json test/order-sync-source/task seed PORT_DIR=./test/order-sync-source runs the same commands if you have Task installed. Then start the host:
dotnet runDocker must be running, and dapr init must have completed — without the installed runtime, each sidecar starts and exits immediately. Open the dashboard URL if you want to inspect the run:
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.In a second terminal, from order-sync-host/, check the destination folder:
ls test/order-sync-destinationcat test/order-sync-destination/ORD-1001.jsonORD-1001.jsonORD-1002.json{"OrderId":"ORD-1001","CustomerId":"CUST-42","ProcessedAt":"2026-10-08T14:33:26.432968+00:00","TotalAmount":88.90,"Status":"Processed"}The source folder is empty again. Stop the host with Ctrl+C. To re-run, seed again and start it again.