Skip to main content

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.

In any category, composition is guaranteed:

Given f:AB and g:BC, there exists gf:AC\text{Given } f: A \to B \text{ and } g: B \to C, \text{ there exists } g \circ f: A \to C

Moreover, composition is associative:

(hg)f=h(gf)(h \circ g) \circ f = h \circ (g \circ f)

And identity morphisms are neutral:

fidA=f=idBff \circ \text{id}_A = f = \text{id}_B \circ f

These aren’t arbitrary rules. They’re the minimal requirements for things to fit together coherently.


Real systems fail to compose in predictable ways. Each failure mode corresponds to a violation of categorical structure.

The most basic failure: the codomain1 of ff doesn’t match the domain of gg.

// 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: cod(f)dom(g)\text{cod}(f) \neq \text{dom}(g). Both morphisms live in the same category — you need a translation morphism h:OrderResponseFulfillmentRequesth: \text{OrderResponse} \to \text{FulfillmentRequest} so that ghfg \circ h \circ f 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 ∘ f
// Looks composable
const getUser = (id: string): Promise<User> => fetchFromCache(id);
const updatePreferences = (user: User, prefs: Prefs): Promise<User> =>
saveToDatabase({ ...user, preferences: prefs });
// But composition fails unpredictably
const 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 Operations
const createOrder = (items: Item[]): Promise<Order> => { /* creates in DB */ };
const processPayment = (order: Order): Promise<Payment> => { /* charges card */ };
// First composition
const result1 = await processPayment(await createOrder(items));
// Retry after timeout - same composition
const 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 resource
const updateInventory = (orderId: string) => decrementStock(orderId);
const updateShipping = (orderId: string) => scheduleShipment(orderId);
// These don't commute
await updateInventory(orderId);
await updateShipping(orderId);
// vs
await 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 f,g:AAf, g: A \to A — operations on the same object — generally gffgg \circ f \neq f \circ g. (For mismatched objects, fgf \circ g 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 pattern
const 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 Framework

When integration fails, ask these questions:

f: A → B
g: B' → C
Is B = B'?

If not, you need a translation. This might be:

  • Explicit adapter function
  • API Gateway transformation
  • Schema evolution handling
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
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
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
Tip:

The Diagnostic Checklist

Type mismatch? → Add translation layer
Hidden state? → Make state explicit
Non-idempotent? → Add idempotency keys
Order-dependent? → Add orchestration


Composition must be associative: (hg)f=h(gf)(h \circ g) \circ f = h \circ (g \circ f)

This seems obvious, but it fails in subtle ways:

// Each service has 30s timeout
const a = () => callServiceA(); // up to 30s
const b = () => callServiceB(); // up to 30s
const 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 way

Note 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
// Each can fail independently
declare 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 types

Strictly, 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>;

Every object needs an identity morphism: idA:AA\text{id}_A: A \to A such that fid=f=idff \circ \text{id} = f = \text{id} \circ f

// The simplest morphism: ignore input, return success
app.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 idA\text{id}_A 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.

For any service, ask: “Can I send a request that returns unchanged?”

// Good: supports identity
PATCH /users/123
Body: {}
Returns: User (unchanged)
// Bad: no identity possible
PATCH /users/123
Body: {}
Error: "At least one field required"

Services that don’t support identity-like operations are harder to compose and test.


Principle 1: Types as Contracts

Section titled Principle 1: Types as Contracts

Make the types explicit and stable:

// Version your contracts
interface OrderV1 {
version: '1';
orderId: string;
items: ItemV1[];
}
interface OrderV2 {
version: '2';
orderId: string;
lineItems: LineItemV2[]; // Renamed, restructured
metadata: Metadata; // Added
}
// Explicit translation between versions
const v1ToV2 = (order: OrderV1): OrderV2 => { /* ... */ };

Principle 2: Explicit Failure Modes

Section titled Principle 2: Explicit Failure Modes

Don’t hide failures in the happy path:

// Bad: failure hidden
declare const getUser: (id: string) => Promise<User | null>;
// Better: failure explicit
declare const getUser: (id: string) => Promise<Result<User, NotFound | Timeout>>;
// Best: categorical — a Kleisli arrow
type 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 Default

Design all write operations to be safely repeatable:

// Include idempotency in the interface
interface 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 Handling

Errors should compose as cleanly as successes — this is railway-oriented programming4:

// Railway-oriented programming
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 fails

API Gateway: Composition Frontend

Section titled API Gateway: Composition Frontend

API Gateway enables composition at the edge:

# Transform between external and internal types
x-amazon-apigateway-request-validators:
all:
validateRequestBody: true
validateRequestParameters: true
# Handle type translation
x-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 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

Section titled EventBridge: Decoupled Composition

Events decouple composition in time:

// Producer doesn't know about consumers
await 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 bus

Composition is not just convenient—it’s the foundation of system integration. When composition fails:

  1. Identify the failure mode (type, state, idempotency, order)
  2. Apply the categorical fix (translation, explicit state, keys, orchestration)
  3. 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.

  1. In category theory and type theory, the codomain (also called “target”) of a morphism f:ABf: A \to B is BB — the set or type that the function maps into. The domain (or “source”) is AA — where the function maps from. For composition gfg \circ f to be defined, the codomain of ff must equal the domain of gg. In programming: if f returns string and g expects number, they don’t compose.

  2. A Kleisli category is a category constructed from a monad. Given a monad TT on a category C\mathcal{C}, the Kleisli category CT\mathcal{C}_T has the same objects as C\mathcal{C}, but morphisms ABA \to B in CT\mathcal{C}_T are morphisms AT(B)A \to T(B) in C\mathcal{C}. In programming terms: if TT is Promise, then Kleisli morphisms are functions that return promises. If TT is Result<_, 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.

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

  4. 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 Result or Either monad, where bind/flatMap only executes the next function if the previous succeeded. The result is clean, linear composition of fallible operations without nested try-catch blocks.

Share this post

Comments

Favorite Books

Links are Amazon affiliate links.