en ~ 12 min read ~
Composition as Architectural Law: Diagnosing Integration Failures
Share this post
This article is part 4 of a 11-part series: Categorical Solutions Architecture
See the full series navigation at the end of this article.
“The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise.”
— Edsger W. Dijkstra
Category theory’s composition axiom seems trivially obvious: if you can go from A to B, and B to C, you can go from A to C. But in distributed systems, composition fails constantly — and these failures have categorical explanations. Understanding why composition fails gives us a systematic approach to designing systems that actually work together.
The Composition Axiom
Section titled The Composition AxiomIn any category, composition is guaranteed:
Moreover, composition is associative:
And identity morphisms are neutral:
These aren’t arbitrary rules. They’re the minimal requirements for things to fit together coherently.
When Composition Fails
Section titled When Composition FailsReal systems fail to compose in predictable ways. Each failure mode corresponds to a violation of categorical structure.
Failure Mode 1: Type Mismatch
Section titled Failure Mode 1: Type MismatchThe most basic failure: the codomain1 of doesn’t match the domain of .
// Service A returns:interface OrderResponse { orderId: string; items: Array<{ productId: string; quantity: number }>; total: number;}
// Service B expects:interface FulfillmentRequest { order_id: string; // Different naming convention line_items: Array<{ sku: string; qty: number }>; // Different structure amount: Money; // Different type for money}
// f: Request → OrderResponse// g: FulfillmentRequest → Shipment// g ∘ f doesn't exist! Types don't match.Diagnosis: The objects don’t match: . Both morphisms live in the same category — you need a translation morphism so that is defined.
Fix: Explicit translation layer
const translate = (order: OrderResponse): FulfillmentRequest => ({ order_id: order.orderId, line_items: order.items.map(i => ({ sku: i.productId, qty: i.quantity })), amount: { value: order.total, currency: 'USD' }});
// Now composition works: g ∘ translate ∘ fFailure Mode 2: Hidden State
Section titled Failure Mode 2: Hidden State// Looks composableconst getUser = (id: string): Promise<User> => fetchFromCache(id);const updatePreferences = (user: User, prefs: Prefs): Promise<User> => saveToDatabase({ ...user, preferences: prefs });
// But composition fails unpredictablyconst updateUserPrefs = async (id: string, prefs: Prefs) => { const user = await getUser(id); // Gets cached version return updatePreferences(user, prefs); // Updates stale data};Diagnosis: getUser isn’t a pure morphism — it depends on hidden state (cache). The “type” of User is actually User × CacheState, but this isn’t reflected in the signature.
Categorical interpretation: You’re in a Kleisli category2 for some state monad, but pretending you’re not.
Fix: Make state explicit
const getUser = (id: string): Promise<{ user: User; version: number }> => fetchWithVersion(id);
const updatePreferences = ( user: User, version: number, prefs: Prefs): Promise<{ user: User; version: number }> => saveWithOptimisticLocking(user, version, prefs);Failure Mode 3: Non-Idempotent Operations
Section titled Failure Mode 3: Non-Idempotent Operationsconst createOrder = (items: Item[]): Promise<Order> => { /* creates in DB */ };const processPayment = (order: Order): Promise<Payment> => { /* charges card */ };
// First compositionconst result1 = await processPayment(await createOrder(items));
// Retry after timeout - same compositionconst result2 = await processPayment(await createOrder(items));// Oops: two orders, two charges!Diagnosis: These aren’t really morphisms—they’re effects. The “same” input produces different outputs on different invocations.
Categorical interpretation: In the naive category of types and functions, morphisms are deterministic. Side effects mean you’re in a different category — a Kleisli category for IO, where effectful arrows are perfectly good morphisms — but you have to acknowledge it and compose accordingly.
Fix: Make operations idempotent with idempotency keys
const createOrder = ( items: Item[], idempotencyKey: string): Promise<Order> => upsertOrder(idempotencyKey, items);
const processPayment = ( order: Order, idempotencyKey: string): Promise<Payment> => chargeOnce(idempotencyKey, order);Failure Mode 4: Order Dependencies
Section titled Failure Mode 4: Order Dependencies// Both update the same resourceconst updateInventory = (orderId: string) => decrementStock(orderId);const updateShipping = (orderId: string) => scheduleShipment(orderId);
// These don't commuteawait updateInventory(orderId);await updateShipping(orderId);// vsawait updateShipping(orderId);await updateInventory(orderId);// Different results if shipping checks inventory!Diagnosis: Morphisms don’t commute—order matters. You have a non-commutative structure.
Categorical interpretation: For — operations on the same object — generally . (For mismatched objects, isn’t even defined.) If your architecture requires a specific order, that’s extra structure you need to enforce.
Fix: Make ordering explicit with sequence or saga
// Step Functions / Saga patternconst fulfillmentWorkflow = sequence([ step('reserve-inventory', reserveInventory), step('process-payment', processPayment), step('schedule-shipping', scheduleShipment), step('confirm-order', confirmOrder)]);The Composition Diagnostic Framework
Section titled The Composition Diagnostic FrameworkWhen integration fails, ask these questions:
1. Do the types match?
Section titled 1. Do the types match?f: A → Bg: B' → CIs B = B'?If not, you need a translation. This might be:
- Explicit adapter function
- API Gateway transformation
- Schema evolution handling
2. Is state hidden?
Section titled 2. Is state hidden?Does f depend on anything not in A?Does g depend on anything not in B?Hidden dependencies break composition. Solutions:
- Make state explicit in types
- Use versioning / ETags
- Introduce state monad
3. Are operations repeatable?
Section titled 3. Are operations repeatable?Does calling f(x) twice give the same result?Does calling g(y) twice give the same result?(Are they actually functions?)Non-repeatable operations aren’t true morphisms. Solutions:
- Idempotency keys
- At-most-once / at-least-once semantics
- Outbox pattern3
4. Does order matter?
Section titled 4. Does order matter?For operations on the same object:g ∘ f = f ∘ g? (Does it need to?)If order matters, encode it explicitly:
- Workflow orchestration
- Saga pattern
- FIFO queues
The Diagnostic Checklist
Type mismatch? → Add translation layer
Hidden state? → Make state explicit
Non-idempotent? → Add idempotency keys
Order-dependent? → Add orchestration
Associativity: Why It Matters
Section titled Associativity: Why It MattersComposition must be associative:
This seems obvious, but it fails in subtle ways:
Timeout Cascades
Section titled Timeout Cascades// Each service has 30s timeoutconst a = () => callServiceA(); // up to 30sconst b = () => callServiceB(); // up to 30sconst c = () => callServiceC(); // up to 30s
// Composition 1: (c ∘ b) ∘ a// Worst case: 90s, but your gateway times out at 60s
// Composition 2: c ∘ (b ∘ a)// Same worst case — 90s either wayNote that associativity holds here — both groupings give the same 90s worst case. The failure is different: composition amplifies worst-case latency beyond the gateway’s 60s budget. The composite exceeds a resource constraint no individual stage violates. (Genuine non-associativity would require timeout wrappers around composites — timeout(60, c ∘ b) ∘ a really does differ from c ∘ timeout(60, b ∘ a).) You need:
- Per-stage timeout budgets
- Total timeout that accounts for all stages
- Circuit breakers
Error Accumulation
Section titled Error Accumulation// Each can fail independentlydeclare const parse: (raw: string) => Result<Data, ParseError>;declare const validate: (data: Data) => Result<Data, ValidationError>;declare const transform: (data: Data) => Result<Output, TransformError>;
// Do these compose at all?// (transform ∘ validate) ∘ parse// transform ∘ (validate ∘ parse)
// Not without widening the error typesStrictly, heterogeneous error types are a composability problem, not an associativity problem — the composites aren’t even defined until the errors unify. Once they do, Kleisli associativity comes for free from the monad laws. Use consistent error types:
type PipelineError = ParseError | ValidationError | TransformError;type Pipeline<T> = Result<T, PipelineError>;Identity: The Do-Nothing Test
Section titled Identity: The Do-Nothing TestEvery object needs an identity morphism: such that
The Health Check as Baseline
Section titled The Health Check as Baseline// The simplest morphism: ignore input, return successapp.get('/health', (req, res) => res.status(200).send('OK'));Strictly, this is a constant morphism, not an identity — it ignores its input and returns the same thing every time, while must return exactly what it received. But it serves the same diagnostic role: if your health check passes but composition fails, the problem is in the composition, not the service.
The Passthrough Test
Section titled The Passthrough TestFor any service, ask: “Can I send a request that returns unchanged?”
// Good: supports identityPATCH /users/123Body: {}→ Returns: User (unchanged)
// Bad: no identity possiblePATCH /users/123Body: {}→ Error: "At least one field required"Services that don’t support identity-like operations are harder to compose and test.
Designing for Composition
Section titled Designing for CompositionPrinciple 1: Types as Contracts
Section titled Principle 1: Types as ContractsMake the types explicit and stable:
// Version your contractsinterface OrderV1 { version: '1'; orderId: string; items: ItemV1[];}
interface OrderV2 { version: '2'; orderId: string; lineItems: LineItemV2[]; // Renamed, restructured metadata: Metadata; // Added}
// Explicit translation between versionsconst v1ToV2 = (order: OrderV1): OrderV2 => { /* ... */ };Principle 2: Explicit Failure Modes
Section titled Principle 2: Explicit Failure ModesDon’t hide failures in the happy path:
// Bad: failure hiddendeclare const getUser: (id: string) => Promise<User | null>;
// Better: failure explicitdeclare const getUser: (id: string) => Promise<Result<User, NotFound | Timeout>>;
// Best: categorical — a Kleisli arrowtype UserF<A, B> = (a: A) => Promise<Result<B, UserError>>;declare const getUser: UserF<UserId, User>;Principle 3: Idempotency by Default
Section titled Principle 3: Idempotency by DefaultDesign all write operations to be safely repeatable:
// Include idempotency in the interfaceinterface OrderService { // Idempotency key is part of the contract createOrder( request: CreateOrderRequest, idempotencyKey: string ): Promise<Order>;
// Updates are naturally idempotent (overwrite semantics) updateOrder( orderId: string, request: UpdateOrderRequest ): Promise<Order>;}Principle 4: Composition-Friendly Error Handling
Section titled Principle 4: Composition-Friendly Error HandlingErrors should compose as cleanly as successes — this is railway-oriented programming4:
// Railway-oriented programmingconst pipeline = pipe( parseOrder, // Result<Order, ParseError> validateOrder, // Result<Order, ValidationError> processPayment, // Result<Order, PaymentError> scheduleShipment // Result<Shipment, ShippingError>);
// All errors are handled uniformly// Composition works regardless of which stage failsAWS Patterns for Composition
Section titled AWS Patterns for CompositionAPI Gateway: Composition Frontend
Section titled API Gateway: Composition FrontendAPI Gateway enables composition at the edge:
# Transform between external and internal typesx-amazon-apigateway-request-validators: all: validateRequestBody: true validateRequestParameters: true
# Handle type translationx-amazon-apigateway-integration: requestTemplates: application/json: | { "internal_order_id": $input.json('$.orderId'), "line_items": $input.json('$.items') }Step Functions: Explicit Composition
Section titled Step Functions: Explicit CompositionStep Functions make composition explicit and visual:
{ "StartAt": "ValidateOrder", "States": { "ValidateOrder": { "Type": "Task", "Resource": "arn:aws:lambda:...:validate", "Next": "ProcessPayment" }, "ProcessPayment": { "Type": "Task", "Resource": "arn:aws:lambda:...:payment", "Next": "FulfillOrder", "Retry": [{ "ErrorEquals": ["PaymentRetryable"], "MaxAttempts": 3 }] }, "FulfillOrder": { "Type": "Task", "Resource": "arn:aws:lambda:...:fulfill", "End": true } }}Composition, retries, and error handling are all explicit.
EventBridge: Decoupled Composition
Section titled EventBridge: Decoupled CompositionEvents decouple composition in time:
// Producer doesn't know about consumersawait eventBridge.putEvents({ Entries: [{ Source: 'orders', DetailType: 'OrderCreated', Detail: JSON.stringify(order) }]});
// Consumers compose independently// Rule 1: OrderCreated → Inventory// Rule 2: OrderCreated → Notifications// Rule 3: OrderCreated → Analytics
// Composition happens through the event busThe Takeaway
Section titled The TakeawayComposition is not just convenient—it’s the foundation of system integration. When composition fails:
- Identify the failure mode (type, state, idempotency, order)
- Apply the categorical fix (translation, explicit state, keys, orchestration)
- Design for composition from the start
Systems that compose well aren’t accidents. They’re designed with categorical discipline.
Next in the series: Functors: The Mathematics of Migration — Where we learn how to move entire systems between categories while preserving their structure.
Footnotes
Section titled Footnotes-
In category theory and type theory, the codomain (also called “target”) of a morphism is — the set or type that the function maps into. The domain (or “source”) is — where the function maps from. For composition to be defined, the codomain of must equal the domain of . In programming: if
freturnsstringandgexpectsnumber, they don’t compose. ↩ -
A Kleisli category is a category constructed from a monad. Given a monad on a category , the Kleisli category has the same objects as , but morphisms in are morphisms in . In programming terms: if is
Promise, then Kleisli morphisms are functions that return promises. If isResult<_, Error>, they’re functions that might fail. The Kleisli category lets you compose these “effectful” functions as if they were pure, with the monad handling the plumbing. Named after mathematician Heinrich Kleisli. ↩ -
The Outbox pattern solves the dual-write problem: when you need to update a database AND publish an event, but can’t do both atomically. Instead of publishing directly, you write the event to an “outbox” table in the same transaction as your data change. A separate process reads the outbox and publishes events, with at-least-once delivery guarantees. This makes the “publish event” operation idempotent — retrying the transaction just overwrites the same outbox row. Common in event-driven architectures and particularly useful with CDC (Change Data Capture) tools like Debezium. ↩
-
Railway-oriented programming is a metaphor coined by Scott Wlaschin for composing functions that can fail. Imagine two parallel railway tracks: the “success track” and the “failure track.” Each function is a switch: if it succeeds, it stays on the success track; if it fails, it switches to the failure track. Once on the failure track, you stay there (subsequent functions are bypassed). This maps directly to the
ResultorEithermonad, wherebind/flatMaponly executes the next function if the previous succeeded. The result is clean, linear composition of fallible operations without nested try-catch blocks. ↩