The caller sends an HTTP request, but the ordering rules operate on a typed OrderPlacementRequest. Something must read the body, reject invalid wire shapes, convert valid input, call the domain, and compose the HTTP response. Exceptions also need the correct caller-safe translation.
About the examples: Meridian Ordering is a private training reference project. The inline listings illustrate the techniques; the full C# and Python repositories are not currently public.
Those responsibilities belong to the service interface. Meridian verifies them alongside the maintained domain acceptance suite. Each category has an explicit boundary and assertions for the behavior it owns.
Assign each obligation to its owning boundary
| Category | Starts and ends at | What it verifies |
|---|---|---|
| Facade acceptance | Public domain operation and its effects | Business outcomes, persistence, fulfillment, confirmation, failure state |
| Controller Hook | Action, with the domain Hook arranged | Raw-body conversion, parsing refusal, typed request, composed response, exception transparency |
| Translator class | Exception and HTTP context | Classification, status, headers, reason, content type, safe body |
| Middleware class | Request delegate and middleware output | Handling, contextual enrichment, logging, translation |
| Hosted pipeline | HTTP request and HTTP response | Route, resource shape, and middleware wiring with the real domain beneath |
The categories form one maintained verification discipline with clear ownership. A controller comparison does not substitute for order-state assertions. A domain scenario cannot represent a fractional JSON quantity in an integer parameter; the wire parser owns that refusal.
Follow the production controller
Here is the complete action:
/// Act implementation: read, parse, invoke the domain Hook, and compose the response.
public async Task<IActionResult> PlaceOrder()
{
using var requestBodyReader = new StreamReader(Request.Body);
string requestBodyJson = await requestBodyReader.ReadToEndAsync();
OrderPlacementRequest orderPlacementRequest = ParserOrderPlacementRequest.Parse(requestBodyJson);
OrderPlacementResult orderPlacementResult = await PlaceOrderCoreAsync(orderPlacementRequest);
OrderPlacementResultResource orderPlacementResultResource = ComposerOrderPlacementResponse.ComposeResult(orderPlacementResult);
return Ok(orderPlacementResultResource);
}
The controller’s domain call is behind a protected virtual Hook. The production Hook delegates directly to the facade. A test subclass overrides it to capture the typed request and return an arranged domain outcome. That lets the controller test exercise its actual parsing and composition without creating a database scenario for every wire-shape variation.
Here is the production Hook, followed by the override in OrdersControllerForTest:
[ExcludeFromCodeCoverage]
protected virtual Task<OrderPlacementResult> PlaceOrderCoreAsync(OrderPlacementRequest orderPlacementRequest)
{
return _domainFacade.PlaceOrderAsync(orderPlacementRequest);
}
protected override Task<OrderPlacementResult> PlaceOrderCoreAsync(OrderPlacementRequest orderPlacementRequest)
{
CapturedOrderPlacementRequest = orderPlacementRequest;
if (_scriptedDomainException is not null)
{
throw _scriptedDomainException;
}
if (_scriptedOrderPlacementResult is null)
{
throw new InvalidOperationException(
"The domain hook was reached, but this scenario scripted nothing: the parser should have refused the body first");
}
return Task.FromResult(_scriptedOrderPlacementResult);
}
The subclass stores the supplied result in _scriptedOrderPlacementResult or
the supplied exception in _scriptedDomainException.
CapturedOrderPlacementRequest exposes the typed request to the test. The override captures it first, then throws the scripted exception or
returns the scripted result. The parameterless parsing-only constructor supplies
neither; reaching the Hook then fails because those scenarios expect refusal
before domain invocation.
Compare the request and response together
The valid-body scenario supplies raw JSON with mixed-case SKU text. It expects that text to reach the domain unchanged. Canonicalization belongs to the manager’s front door. It also supplies an arranged domain total and checks the composed wire representation of that result.
Here is the complete test:
/// Arrange, Act, Assert: verify the controller's forward and backward conversions.
public async Task PlaceOrder_WhenBodyCarriesAValidOrder_ThenTheHookReceivesTheTypedRequestAndTheResponseCarriesTheWireShapedResult()
{
/// Arrange - the raw wire body with snake_case fields. No catalog participates
// (the hook is scripted), so the SKU is any well-formed value - carried in
// MIXED casing, because the controller must hand it to the domain VERBATIM:
// canonicalization is the Manager's front-door step (TR-10), not the boundary's.
string wireSku = LetterCasing.SwapCase(DataGenerators.CreateRandomNonexistentSku());
int orderedQuantity = 7;
string requestBodyJson = ApiTestHelpers.CreatePlacementRequestBodyJson(
orderReference: _expectedOrderPlacementIdentifiers.OrderReference,
customerId: _expectedOrderPlacementIdentifiers.CustomerId,
sku: wireSku,
quantity: orderedQuantity);
// The typed request the controller must hand to the hook, and the scripted
// domain outcome the controller must compose into the wire response.
var expectedOrderPlacementRequest = new OrderPlacementRequest(
orderReference: _expectedOrderPlacementIdentifiers.OrderReference,
customerId: _expectedOrderPlacementIdentifiers.CustomerId,
orderPlacementLineItems: [new OrderPlacementLineItem(sku: wireSku, quantity: orderedQuantity)]);
var scriptedOrderPlacementResult = new OrderPlacementResult(
OrderReference: _expectedOrderPlacementIdentifiers.OrderReference,
OrderTotal: 458.97m,
OrderPlacementStatus: OrderPlacementStatus.Placed);
var expectedOrderPlacementResultResource = new OrderPlacementResultResource(
OrderReference: _expectedOrderPlacementIdentifiers.OrderReference,
OrderTotal: "458.97",
OrderPlacementStatus: "Placed");
var ordersController = new OrdersControllerForTest(scriptedOrderPlacementResult);
ApiTestHelpers.AttachHttpRequestWithBody(ordersController, requestBodyJson);
// Act
IActionResult actualActionResult = await ordersController.PlaceOrder();
/// Assert - request and response discrepancies are reported together.
OrderPlacementRequest? actualOrderPlacementRequest = ordersController.CapturedOrderPlacementRequest;
AsserterController.AssertOrderRequestAndResponseAreAsExpected(
expectedOrderPlacementRequest, expectedOrderPlacementResultResource, actualOrderPlacementRequest, actualActionResult);
}
The arranged amount is a composition input; this category does not calculate
catalog prices. AsserterController.AssertOrderRequestAndResponseAreAsExpected
compares the captured request and returned response together. It reports a missing
request, compares OrderReference and CustomerId, and uses SequenceEqual for
the OrderPlacementLineItems. That sequence comparison checks the line values, count,
and order, including the mixed-case SKU text supplied by this scenario.
For the response, it requires OkObjectResult with status 200 and a body of type
OrderPlacementResultResource. It compares that body with the expected resource,
including OrderReference, the formatted OrderTotal, and OrderPlacementStatus.
Available request and response discrepancies accumulate into one failure report.
Parsing scenarios separately verify malformed JSON, missing fields, wrong root shape, wrong line-item shape, and non-integer quantities. They compare the required problem information and verify that the Hook was never reached.
Preserve an exception until its owning handler processes it
The controller should pass a domain exception through unchanged. Testing only ReferenceEquals would miss mutation of that same object. The test snapshots its context before Act and checks both identity and context afterward:
/// Arrange, Act, Assert: preserve exception identity and its pre-operation context.
public async Task PlaceOrder_WhenTheDomainHookThrows_ThenTheVerySameExceptionInstancePassesThroughUntouched()
{
// domain rejection. The controller must not catch, wrap, re-create, or enrich
// it: translation is the middleware's job. The complete contextual data is
// snapshotted BEFORE the act, so the assertion can prove the instance came
// through not just reference-same but unmodified.
string requestBodyJson = ApiTestHelpers.CreatePlacementRequestBodyJson(
orderReference: _expectedOrderPlacementIdentifiers.OrderReference,
customerId: _expectedOrderPlacementIdentifiers.CustomerId,
sku: DataGenerators.CreateRandomNonexistentSku(),
quantity: 1);
var scriptedDomainException = new CustomerNotFoundException(
$"Order placement request was rejected: customer '{_expectedOrderPlacementIdentifiers.CustomerId}' does not exist");
Dictionary<string, object> expectedContextualDataSnapshot =
scriptedDomainException.ContextualDataByName.ToDictionary(
contextualEntry => contextualEntry.Key, contextualEntry => contextualEntry.Value);
var ordersController = new OrdersControllerForTest(scriptedDomainException);
ApiTestHelpers.AttachHttpRequestWithBody(ordersController, requestBodyJson);
// Act
var actualCustomerNotFoundException = await Assert.ThrowsAsync<CustomerNotFoundException>(
async () => await ordersController.PlaceOrder());
/// Assert - the very same instance, untouched
AsserterUntouchedExceptionPassThrough.AssertExceptionPassedThroughUntouched(
expectedSameExceptionInstance: scriptedDomainException,
expectedContextualDataSnapshot: expectedContextualDataSnapshot,
actualException: actualCustomerNotFoundException);
}
The pass-through asserter checks removed, added, and changed entries as well as reference identity. Middleware owns later enrichment and translation. This test ensures the controller does not silently acquire that responsibility.
Verify translation and hosted wiring at their own boundaries
Translator tests construct specific exceptions and inspect the exact response status, headers, content type, and body. Business refusal messages are intended for callers; infrastructure failures need safe responses that preserve useful classification without exposing internal diagnostic context.
The complete translator tests and middleware tests verify those responsibilities independently. The hosted smoke test then verifies that the actual HTTP pipeline reaches translation.
Here is the complete host factory:
/// Arrange: host the pipeline with the real facade built through test composition.
private static WebApplicationFactory<Program> CreateApiFactory()
{
// The whole production pipeline hosts as-is; only the facade registration is
// replaced with one built through ServiceLocatorTesting - the same seam every
// suite uses, moved to the composition root.
return new WebApplicationFactory<Program>().WithWebHostBuilder(webHostBuilder =>
webHostBuilder.ConfigureServices(services =>
{
services.RemoveAll<DomainFacade>();
services.AddSingleton(_ => DomainFacadeFactory.CreateDomainFacade().DomainFacade);
}));
}
The complete pipeline scenarios post genuine HTTP requests, use the real domain beneath, and assert route and response shape or translation presence. They do not repeat every order-state comparison. Their facade registration is replaced with test composition, so the run verifies this hosted graph rather than certifying every production registration or deployed setting.
Retain one coherent release argument
The primary behavioral artifact is the assembled domain suite with correct requirements, scenarios, expectations, and complete outcome comparisons. Interface obligations still need evidence. Meridian places that evidence in explicit categories rather than duplicating every business scenario at every layer.
The PWI Service-Boundary Testing chapter explains these ownership boundaries. Together they let a reviewer say which evidence verifies the business behavior, wire conversion, translation, and hosted registration. The purpose remains confidence to go to production with the required behavior verified before exposure.
Previous: Idempotency and Concurrent Requests | Series contents | Next: A Suite the Team Can Keep Trusting
