Skip to content
Tutorial4/5

4. Serialize, send, and run

Finish the send pipeline, create the host, and run two orders.

Keep the existing ExecuteAsync signature in src/Send/Serializer.cs. Replace only its body:

src/Send/Serializer.cs: ExecuteAsync body
{
var json = JsonSerializer.Serialize(input);
return Task.FromResult<(TechnicalStepResult<string>, Context)>(
(new TechnicalStepResult<string>.Success(json), context));
}

The Sender writes through IFileAdapter. Keep the existing ExecuteAsync signature in src/Send/Sender.cs and add async to it. Replace only its body:

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

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);
// 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:

Terminal window
dotnet test
Passed! - 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 host from the workspace directory, the parent of order-sync/:

Terminal window
cd ..
intropy sys create -n OrderSync -o order-sync-host
using cached integrio-intropy/intropy-templates@v2.0.0
created 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 project

Use -o because the Component already owns the order-sync/ directory.

Open the generated system definition:

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)
.Calls(Services.Idempotency)
.Calls(Services.BusinessIncidents);
}
}

Validate the declaration and print the model:

Terminal window
cd order-sync-host
dotnet run -- check
ok: '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.

Terminal window
dotnet run -- graph

The 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.

Seed the source folder with the two sample orders before starting the host:

Terminal window
mkdir -p test/order-sync-source
cp 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:

Terminal window
dotnet run

Docker 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:

Terminal window
ls test/order-sync-destination
cat test/order-sync-destination/ORD-1001.json
ORD-1001.json
ORD-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.