Skip to main content

en ~ 14 min read ~

Natural Transformations: Coherent Change Across Systems

Share this post

This article is part 6 of a 11-part series: Categorical Solutions Architecture

See the full series navigation at the end of this article.

“A natural transformation is a morphism of functors—a systematic way to transform one functor into another while respecting the structure of both.”

You have two different ways to process orders—a legacy system and a new one. Both work, but you need to migrate from one to the other while the system is running. How do you ensure consistency? Natural transformations answer this: they’re systematic, structure-preserving ways to morph between different architectural approaches.

Recall that a functor F:CDF: \mathcal{C} \to \mathcal{D} maps objects and morphisms between categories. But what if you have two functors doing similar jobs?

F,G:CDF, G: \mathcal{C} \to \mathcal{D}

A natural transformation1 α:FG\alpha: F \Rightarrow G is a systematic way to convert FF into GG.

F(A)αₐG(A)
Natural transformation α: F ⇒ G

For every object AA in C\mathcal{C}, there’s a morphism:

αA:F(A)G(A)\alpha_A: F(A) \to G(A)

And these morphisms must be coherent: for any morphism f:ABf: A \to B in C\mathcal{C}, this diagram commutes2:

F(A) ----F(f)----> F(B)
| |
α_A α_B
| |
v v
G(A) ----G(f)----> G(B)

In equations:

αBF(f)=G(f)αA\alpha_B \circ F(f) = G(f) \circ \alpha_A

The naturality condition3 says: it doesn’t matter whether you transform first or process first—you get the same result.

This is exactly what you need for live migrations, feature flags, and gradual rollouts.

You have two order processing pipelines:

// Legacy functor F
const legacyProcess = (order: Order): ProcessedOrder => {
const validated = legacyValidate(order);
const priced = legacyPrice(validated);
return legacyFinalize(priced);
};
// New functor G
const newProcess = (order: Order): ProcessedOrder => {
const validated = newValidate(order);
const priced = newPrice(validated);
return newFinalize(priced);
};

A natural transformation α:FG\alpha: F \Rightarrow G must satisfy:

// For any operation f (e.g., applying a discount):
// F(f) = legacyApplyDiscount — the operation as legacy runs it
// G(f) = newApplyDiscount — the operation as the new system runs it
// α = transformResult — the migration map
// Path 1: legacy operation, then migrate
const path1 = transformResult(legacyApplyDiscount(order));
// Path 2: migrate, then new operation
const path2 = newApplyDiscount(transformResult(order));
// Naturality requires: path1 === path2

If this doesn’t hold, your migration will produce inconsistent results depending on when orders hit which system.


Natural Transformations in Practice

Section titled Natural Transformations in Practice

You’re migrating from v1 to v2 API:

// Functor F: v1 API
interface V1OrderService {
createOrder(items: V1Item[]): V1Order;
getOrder(id: string): V1Order;
updateOrder(id: string, items: V1Item[]): V1Order;
}
// Functor G: v2 API
interface V2OrderService {
createOrder(request: V2CreateRequest): V2Order;
getOrder(id: string): V2Order;
updateOrder(id: string, request: V2UpdateRequest): V2Order;
}

The natural transformation components:

// α_CreateOrder: V1 creation → V2 creation
const migrateCreate = (items: V1Item[]): V2CreateRequest => ({
lineItems: items.map(v1ToV2Item),
metadata: { migratedFrom: 'v1' }
});
// α_Order: V1Order → V2Order
const migrateOrder = (v1: V1Order): V2Order => ({
id: v1.id,
lineItems: v1.items.map(v1ToV2Item),
status: mapStatus(v1.status),
// ...
});

For the updateOrder morphism:

V1Order ----v1.updateOrder(id, items)----> V1Order
| |
α_Order α_Order
| |
v v
V2Order ----v2.updateOrder(id, req)-----> V2Order

Naturality requires: Updating then migrating = migrating then updating

// This must hold for all orders and updates:
migrateOrder(v1Service.updateOrder(id, items))
===
v2Service.updateOrder(id, migrateUpdate(items))

If it doesn’t, you’ll have data inconsistencies during migration.


Before any migration, verify:

For every object type, do you have a migration function?

// Check: α exists for all objects
type MigrationMap = {
order: (v1: V1Order) => V2Order;
item: (v1: V1Item) => V2Item;
user: (v1: V1User) => V2User;
payment: (v1: V1Payment) => V2Payment;
};

For every operation, does the square commute?

| Operation | Legacy then Migrate | Migrate then New | Commutes? |
|-----------|--------------------|--------------------|-----------|
| Create | ✓ | ✓ | ✓ |
| Read | ✓ | ✓ | ✓ |
| Update | ✓ | ? | Verify! |
| Delete | ✓ | ✓ | ✓ |

What happens at boundaries?

// Empty orders
assert(migrateOrder({ items: [] }).lineItems.length === 0);
// Maximum size
const bigOrder = { items: Array(10000).fill(defaultItem) };
assert(migrateOrder(bigOrder).lineItems.length === 10000);
// Null/undefined handling
assert(migrateOrder(null) === null); // or throws consistently

Feature Flags as Natural Transformations

Section titled Feature Flags as Natural Transformations

Feature flags control which functor handles each request:

// Feature flag controls which functor to use
const processOrder = (order: Order): ProcessedOrder => {
if (featureFlags.isEnabled('new-order-processing', order.userId)) {
return newProcess(order); // G
} else {
return legacyProcess(order); // F
}
};

For this to work correctly:

// Consistent routing: same user stays on the same path
// — a precondition for sanity, not naturality itself
const user = { id: '123' };
const order1 = createOrder(user, items1);
const order2 = createOrder(user, items2);
// If user is on new system for order1, they should be on new for order2
// (Assuming we want user-level consistency)

One caveat: the flag itself is not the natural transformation. It supplies no components αA:F(A)G(A)\alpha_A: F(A) \to G(A) — it just routes each input to FF or GG. Pinning a user to one path is session stickiness. What makes mixed operation coherent is the underlying migration map α\alpha: legacy results must migrate to what the new system would have produced.

Tip:

Gradual Rollout

A gradual rollout is a time-indexed family of functors FtF_t — the per-request routing in effect at time tt — with F0=FF_0 = F and F1=GF_1 = G, connected by natural transformations between successive stages. The rollout moves through intermediate functors, not through “partial” transformations: any α:FG\alpha: F \Rightarrow G is already the full transformation.


Vertical and Horizontal Composition

Section titled Vertical and Horizontal Composition

Natural transformations compose in two ways:

Given α:FG\alpha: F \Rightarrow G and β:GH\beta: G \Rightarrow H, their vertical composition is βα:FH\beta \circ \alpha: F \Rightarrow H.

Architecture meaning: Sequential migrations

// v1 → v2 → v3
const v1ToV3 = compose(v2ToV3, v1ToV2);
// Each step is a natural transformation
// Composition gives you the full migration

Given α:FG\alpha: F \Rightarrow G and β:HK\beta: H \Rightarrow K where these functors are composable, you get βα:HFKG\beta * \alpha: H \circ F \Rightarrow K \circ G.

Architecture meaning: Parallel system changes

// Migrating both order system AND payment system
// The combined migration is the horizontal composition
// Old: PaymentF ∘ OrderF
// New: PaymentG ∘ OrderG
// Combined migration: (paymentMigration) * (orderMigration)

A natural isomorphism6 is a natural transformation where every component is an isomorphism:

α:FG\alpha: F \stackrel{\cong}{\Rightarrow} G

This means:

  • Every αA:F(A)G(A)\alpha_A: F(A) \to G(A) is invertible
  • There exists α1:GF\alpha^{-1}: G \Rightarrow F

Architecture meaning: Lossless, reversible migration

// Perfect migration: can go back and forth
const toV2 = (v1: V1Order): V2Order => { /* ... */ };
const toV1 = (v2: V2Order): V1Order => { /* ... */ };
// Isomorphism means:
assert(deepEqual(toV1(toV2(v1Order)), v1Order));
assert(deepEqual(toV2(toV1(v2Order)), v2Order));

Most real migrations are not isomorphisms:

// v2 has new required field
interface V2Order {
// ... v1 fields
priority: 'low' | 'medium' | 'high'; // New!
}
// toV2 must invent priority
const toV2 = (v1: V1Order): V2Order => ({
...migrateFields(v1),
priority: 'medium' // Default value
});
// toV1 loses information
const toV1 = (v2: V2Order): V1Order => ({
...migrateFieldsBack(v2)
// priority is lost!
});
// NOT an isomorphism: toV1(toV2(v1)) might equal v1
// but toV2(toV1(v2)) won't preserve priority

Natural Transformations in AWS

Section titled Natural Transformations in AWS

Request/response transformations are natural transformations:

# Transform external format to internal
x-amazon-apigateway-integration:
requestTemplates:
application/json: |
#set($input = $input.json('$'))
{
"internalOrderId": "$input.orderId",
"lineItems": $input.items,
"source": "api-gateway"
}
responseTemplates:
application/json: |
#set($output = $input.json('$'))
{
"orderId": "$output.internalOrderId",
"items": $output.lineItems,
"status": "$output.orderStatus"
}

These must be natural: processing then transforming = transforming then processing (in the appropriate categories).

Lambda Layers as Natural Transformations

Section titled Lambda Layers as Natural Transformations

A Lambda layer that wraps all handlers:

// Layer provides transformation
export const withLogging = (handler: Handler): Handler =>
async (event, context) => {
console.log('Input:', JSON.stringify(event));
const result = await handler(event, context);
console.log('Output:', JSON.stringify(result));
return result;
};

This is a natural transformation from the “plain handler” functor to the “logged handler” functor.

Step Functions State Transformations

Section titled Step Functions State Transformations

Step Functions InputPath/OutputPath/ResultPath are data-mapping morphisms:

{
"ProcessOrder": {
"Type": "Task",
"InputPath": "$.order",
"ResultPath": "$.processedOrder",
"OutputPath": "$",
"Resource": "arn:aws:lambda:...:process"
}
}

The paths define how data transforms between states—each is a single data-mapping function, chained by ordinary function composition. No functor pair, no naturality square — just morphisms.


A blue-green deployment is a natural transformation with extra structure:

F(Request) --F(handle)--> F(Response)
| |
α_Request α_Response
| |
v v
G(Request) --G(handle)--> G(Response)

Blue is FF, green is GG; handle is request processing. The square must commute: handle in blue then migrate the response, or migrate the request then handle in green — same result.

// All requests during cutover must satisfy:
// 1. Requests to Blue produce Blue-compatible responses
// 2. Requests to Green produce Green-compatible responses
// 3. The "switch" is atomic at the load balancer level
// If a request spans the switch, naturality tells us
// whether the result is coherent

Canary8 as Partial Natural Transformation

Section titled Canary8 as Partial Natural Transformation
// 10% of traffic goes to new system
const route = (request: Request): Response => {
if (hash(request.userId) % 100 < 10) {
return greenEnvironment.handle(request); // G
} else {
return blueEnvironment.handle(request); // F
}
};
// Consistent routing: same user always hits same environment
// (until we change the percentage) — a precondition, not naturality.
// Naturality proper: run blue then migrate === migrate then run green

import * as fc from 'fast-check';
// Test naturality square for updateOrder
fc.assert(
fc.property(
orderArbitrary,
updateArbitrary,
(order, update) => {
// Path 1: Update in v1, then migrate
const path1 = migrateOrder(v1UpdateOrder(order, update));
// Path 2: Migrate, then update in v2
const path2 = v2UpdateOrder(migrateOrder(order), migrateUpdate(update));
return deepEqual(path1, path2);
}
)
);
describe('Migration Naturality', () => {
test.each([
['create', createOrderScenario],
['update', updateOrderScenario],
['cancel', cancelOrderScenario],
])('naturality holds for %s', async (name, scenario) => {
const { order, operation, expected } = scenario;
// Path 1
const v1Result = await v1Service[operation](order);
const migrated1 = migrateOrder(v1Result);
// Path 2
const migratedOrder = migrateOrder(order);
const v2Result = await v2Service[operation](migratedOrder);
expect(migrated1).toEqual(v2Result);
});
});

Natural transformations ensure coherent change:

  1. Component-wise: Every type has a migration function
  2. Natural: Migration commutes with operations
  3. Testable: Naturality squares can be verified
  4. Composable: Sequential and parallel migrations combine

When you’re doing a live migration, ask: “Is my transformation natural?” If the squares don’t commute, you’ll have inconsistencies.

Design transformations to be natural, and your migrations will be predictable.


Next in the series: Equivalence of Categories: When Different Architectures Are “The Same” — Where we learn that radically different implementations can be mathematically identical.

  1. A natural transformation is one of the most important concepts in category theory — some say it was the reason category theory was invented. Eilenberg and Mac Lane originally developed categories and functors primarily to formalize the notion of natural transformation. Informally, a natural transformation is a “morphism between functors” — a way to systematically convert one functor into another. The word “natural” here has a precise meaning: the transformation doesn’t depend on arbitrary choices but arises “naturally” from the structure. When mathematicians say something is “natural” or “canonical,” they often mean there’s a natural transformation lurking.

  2. A diagram commutes when all paths between the same two points yield the same result. In the naturality square, there are two paths from F(A)F(A) to G(B)G(B): go right then down (αBF(f)\alpha_B \circ F(f)), or go down then right (G(f)αAG(f) \circ \alpha_A). Commutativity means these give the same morphism. This is the essence of “coherence” — it doesn’t matter which path you take, you end up in the same place. Commutative diagrams are the primary tool for expressing equations in category theory, replacing algebraic manipulation with geometric intuition.

  3. The naturality condition (αBF(f)=G(f)αA\alpha_B \circ F(f) = G(f) \circ \alpha_A) is what makes a family of morphisms into a natural transformation rather than just an arbitrary collection. It ensures that the transformation “respects” the structure of the categories. In programming terms: if you have a transformation from List<A> to Option<A> (like “get first element”), naturality says that transforming then mapping equals mapping then transforming. This is why map distributes over these operations — it’s naturality at work.

  4. Vertical composition of natural transformations stacks them: given α:FG\alpha: F \Rightarrow G and β:GH\beta: G \Rightarrow H (both between the same categories), their vertical composition βα:FH\beta \circ \alpha: F \Rightarrow H has components (βα)A=βAαA(\beta \circ \alpha)_A = \beta_A \circ \alpha_A. It’s called “vertical” because if you draw functors as vertical arrows and natural transformations as horizontal double arrows, this composition stacks vertically. This corresponds to chaining migrations: v1 → v2 → v3.

  5. Horizontal composition (also called the Godement product) is more subtle. Given α:FG\alpha: F \Rightarrow G (between C\mathcal{C} and D\mathcal{D}) and β:HK\beta: H \Rightarrow K (between D\mathcal{D} and E\mathcal{E}), you get βα:HFKG\beta * \alpha: H \circ F \Rightarrow K \circ G. The components are (βα)A=βG(A)H(αA)=K(αA)βF(A)(\beta * \alpha)_A = \beta_{G(A)} \circ H(\alpha_A) = K(\alpha_A) \circ \beta_{F(A)} (these are equal by naturality of β\beta). This corresponds to migrating multiple systems in parallel — the order doesn’t matter because the transformations are independent. Whiskering is the special case where one of the two transformations is an identity — composing a natural transformation with a functor on one side, written HαH\alpha or αH\alpha H.

  6. A natural isomorphism is a natural transformation where every component αA:F(A)G(A)\alpha_A: F(A) \to G(A) is an isomorphism (has an inverse). This means the two functors FF and GG are “essentially the same” — they capture the same structure, just represented differently. Natural isomorphisms are written FGF \cong G. Two categories are equivalent when there are functors F:CDF: \mathcal{C} \to \mathcal{D} and G:DCG: \mathcal{D} \to \mathcal{C} whose composites are naturally isomorphic to the identities (GFIdCG \circ F \cong \mathrm{Id}_{\mathcal{C}} and FGIdDF \circ G \cong \mathrm{Id}_{\mathcal{D}}) — the subject of the next post.

  7. Blue-green deployment is a release strategy that maintains two identical production environments: “blue” (current) and “green” (new). Traffic initially goes to blue. You deploy updates to green, test it, then switch traffic atomically (usually via load balancer or DNS). If problems occur, you switch back to blue. The key insight: at any moment, only one environment serves production traffic, eliminating the “mixed state” problem of rolling deployments. AWS services supporting this include Elastic Beanstalk, CodeDeploy, and Route 53 weighted routing.

  8. A canary deployment (named after canaries in coal mines) routes a small percentage of traffic to the new version while most traffic stays on the old version. Unlike blue-green’s atomic switch, canary is gradual: start at 1%, monitor metrics, increase to 10%, monitor again, eventually reach 100%. This catches problems that only manifest at scale or under real traffic. AWS CodeDeploy and App Mesh support canary deployments natively. The key requirement: consistent routing so the same user doesn’t flip between versions mid-session.

  9. Property-based testing (PBT) generates random inputs to test that properties hold universally, rather than testing specific examples. Libraries like fast-check (TypeScript), QuickCheck (Haskell), and Hypothesis (Python) generate hundreds or thousands of test cases automatically. For testing naturality, PBT is ideal: you want to verify that the naturality equation holds for all inputs, not just a few hand-picked ones. When a property fails, PBT “shrinks” the failing input to find the minimal counterexample, making debugging easier.

Share this post

Comments

Favorite Books

Links are Amazon affiliate links.