📝 en ~ 11 min read ~ ☕
Composition as Architectural Law: Diagnosing Integration Failures
Share this post
This article is part 4 of a 7-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
In 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
Real systems fail to compose in predictable ways. Each failure mode corresponds to a violation of categorical structure.
Failure Mode 1: Type Mismatch
The 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: You’re trying to compose morphisms in different categories. You need a functor (transformation) between them.
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
// 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
const 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: True morphisms are deterministic. Side effects mean you’re in a different category (e.g., Kleisli category for IO).
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
// 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: In most categories, in general. But 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
When integration fails, ask these questions:
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?
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?
f(x) = f(x)? (Always?)g(y) = g(y)? (Always?)Non-repeatable operations aren’t true morphisms. Solutions:
- Idempotency keys
- At-most-once / at-least-once semantics
- Outbox pattern3
4. Does order matter?
g ∘ f = f ∘ g? (Does it need to?)If order matters, encode it explicitly:
- Workflow orchestration
- Saga pattern
- FIFO queues
Associativity: Why It Matters
Composition must be associative:
This seems obvious, but it fails in subtle ways:
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// Total possible time: 90s, but your gateway times out at 60s
// Composition 2: c ∘ (b ∘ a)// Same problem from a different angleThe timeouts don’t compose associatively. You need:
- Per-stage timeout budgets
- Total timeout that accounts for all stages
- Circuit breakers
Error Accumulation
// Each can fail independentlyconst parse = (raw: string): Result<Data, ParseError>;const validate = (data: Data): Result<Data, ValidationError>;const transform = (data: Data): Result<Output, TransformError>;
// Do errors compose?// (transform ∘ validate) ∘ parse// transform ∘ (validate ∘ parse)
// Need consistent error handling strategyFor associativity, use consistent error types:
type PipelineError = ParseError | ValidationError | TransformError;type Pipeline<T> = Result<T, PipelineError>;Identity: The Do-Nothing Test
Every object needs an identity morphism: such that
The Health Check as Identity
// The simplest morphism: do nothing, return successapp.get('/health', (req, res) => res.status(200).send('OK'));If your health check passes but composition fails, the problem is in the composition, not the service.
The Passthrough Test
For 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
Principle 1: Types as Contracts
Make 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
Don’t hide failures in the happy path:
// Bad: failure hiddenconst getUser = (id: string): Promise<User | null>;
// Better: failure explicitconst getUser = (id: string): Promise<Result<User, NotFound | Timeout>>;
// Best: categoricaltype UserF<T> = Kleisli<Result<T, UserError>>;const getUser: UserF<UserId, User> = (id) => /* ... */;Principle 3: Idempotency by Default
Design 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
Errors should compose as cleanly as successes:
// Railway-oriented programming[^4]const 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
API Gateway: Composition Frontend
API 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
Step 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
Events 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
Composition 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
-
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. ↩