Route business incidents
Wire a transactional integration’s send and receive pipelines to the Business Incident Service, so failures that need action from someone outside the integration reach the owning team as tracked incidents instead of stopping at the trace.
Before you begin
Section titled “Before you begin”- Read Business incidents — what counts as a business incident, who owns one, and the incident lifecycle this page wires you into
- Have an integration built with the pipeline builder whose Validator returns business failures — chapter 3 of the transactional tutorial builds one
- Have a Business Incident Service reachable from your target environment
What routing does
Section titled “What routing does”A business failure is data, not an exception: a business-domain step returns a Failure carrying a BusinessIncidentData payload, the pipeline skips the remaining steps for that message, and the run completes normally with the others. The routing step is what turns the payload into an incident: it sends it to the Business Incident Service, which notifies the owning team’s inbox. A repeat failure of the same message increments the existing incident’s retry count instead of opening a duplicate, and a successful run of a retried delivery resolves it automatically.
1. Register the client
Section titled “1. Register the client”dotnet add package Intropy.BusinessIncidentService.Clientservices.AddBusinessIncidentServiceClient();The client targets the Dapr app id business-incident-service by default; pass a delegate to override it. Registration lives in the Intropy.BusinessIncidentService.Client namespace; the client interface and the BusinessIncidentData type live in Intropy.Contracts.BusinessIncidentService. A scaffolded component already has this call in its composition class.
2. Return BusinessIncidentData from a business step
Section titled “2. Return BusinessIncidentData from a business step”The payload the router carries is created where the rule is checked. In the Validator, describe the violation as data and return it as a Failure — don’t throw:
public class ValidateVat : ValidateStep<CustomerRecord, Context>{ public override Task<(BusinessStepResult<CustomerRecord> Result, Context Context)> ExecuteAsync( CustomerRecord customer, Context context, CancellationToken ct) { if (string.IsNullOrWhiteSpace(customer.VatNumber) && customer.Country == "DE") { var incident = new BusinessIncidentData { Description = "DE customer has no VAT number", Context = new Dictionary<string, string> { ["customer.id"] = customer.Id, ["customer.country"] = customer.Country, ["source.system"] = customer.Source, }, }; return Task.FromResult<(BusinessStepResult<CustomerRecord>, Context)>( (new BusinessStepResult<CustomerRecord>.Failure(incident), context)); }
return Task.FromResult<(BusinessStepResult<CustomerRecord>, Context)>( (new BusinessStepResult<CustomerRecord>.Success(customer), context)); }}An uncaught exception in a business-domain step also becomes a business incident: the step’s domain decides the failure category, so an exception thrown by rule code is routed to the owning team rather than retried as a technical failure.
3. Wire the routing at composition
Section titled “3. Wire the routing at composition”Routing is declared when the pipeline is built, not inside your steps. WithBusinessIncidents takes the client as its first argument, resolved inside the registration callback, followed by the two functions that identify an incident from your pipeline context. The call is the same on the send and the receive pipeline:
services.AddSendPipeline<In, Out, Context>("OrderSync.Send", (builder, provider) =>{ var incidentClient = provider.GetRequiredService<IBusinessIncidentServiceClient>();
return builder // ... pipeline steps ... .WithBusinessIncidents( incidentClient, messageIdExtractor: ctx => ctx.Metadata.GetValueOrDefault("message.id", "unknown"), // stable across retries subjectExtractor: ctx => ctx.Metadata.GetValueOrDefault("customer.number", "unknown")); // meaningful to a process owner});Chapter 5 of the transactional tutorial wires a send and a receive pipeline end to end.
The two extractors are the routing contract:
messageIdExtractormust return the same value every time the same message is processed. It ties a repeat failure to the existing incident (the retry count increments, no duplicate opens) and lets a successful retry auto-resolve it. A value that changes per attempt — a timestamp, a fresh GUID — silently breaks both.subjectExtractorshould return an identifier you can put in front of a process owner: an order number, a customer number. Not an internal GUID.
4. Write the incident description
Section titled “4. Write the incident description”The Description is the first thing the receiving team reads, and the Context map is what they act on:
- State the rule, not the exception: “DE customer has no VAT number”, not “Validation failed in ValidateVat”.
- Stable across sources: the same rule violated by data from Salesforce or NetSuite should produce the same
Description, so the same problem reads as the same problem in the inbox. Put the source inContext, not in the text. Contextcarries the specifics: ids, the source system, the offending values — everything a resolver needs to find the message without opening a trace.
Verify
Section titled “Verify”In your target environment, feed the pipeline a message that violates the rule (in the example above, a DE customer with no VAT).
You should see: the run completes, the trace shows the validation span with step.result=business_failure, and the incident appears in the owning team’s inbox with your Description, subject, and context. No technical retry is triggered.
Resolution is automatic but keyed to retries: when a delivery the host has marked as a retry (the pipeline context’s IsRetry flag, set from the broker’s redelivery metadata) runs through successfully, the pipeline sends a resolve event (io.intropy.business_incident.resolved) and the incident closes without anyone touching the inbox. A fresh send of corrected data opens no duplicate, but it does not auto-resolve the incident either — that is what manual resolution in the inbox is for.
Symptoms and where to go back to:
- The trace shows
business_failurebut no incident appears — routing was never wired at composition. Revisit Step 3. GetRequiredService<IBusinessIncidentServiceClient>()throws at startup — the client registration is missing. Revisit Step 1.- Each retry opens a new incident instead of incrementing the existing one —
messageIdExtractorreturns a per-attempt value. Revisit Step 3. - A technical failure (an unreachable endpoint, a timeout) shows up as an incident — the work sits in a business-domain step; move it to a technical one. Intropy Framework covers which step belongs where.