Skip to main content

📝 en ~ 13 min read ~ ☕☕

Functors: The Mathematics of Migration

Share this post

This article is part 5 of a 7-part series: Categorical Solutions Architecture

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

“A functor is a morphism of categories — it maps objects to objects and morphisms to morphisms, preserving composition and identity.”

— Standard definition

When you migrate from one database to another, refactor a monolith to microservices, or upgrade from one API version to another, you’re applying a functor. A functor is a structure-preserving map between categories. Understanding functors tells you exactly what “structure-preserving” means—and what happens when preservation fails.

What Is a Functor?

A functor1 F:CDF: \mathcal{C} \to \mathcal{D} between categories C\mathcal{C} and D\mathcal{D} consists of:

  1. Object mapping: For every object AA in C\mathcal{C}, an object F(A)F(A) in D\mathcal{D}
  2. Morphism mapping: For every morphism f:ABf: A \to B in C\mathcal{C}, a morphism F(f):F(A)F(B)F(f): F(A) \to F(B) in D\mathcal{D}

Such that:

F(gf)=F(g)F(f)(preserves composition)F(g \circ f) = F(g) \circ F(f) \quad \text{(preserves composition)} F(idA)=idF(A)(preserves identity)F(\text{id}_A) = \text{id}_{F(A)} \quad \text{(preserves identity)}
Category CCategory DAfBF(A)F(f)F(B)F
Functor F: C → D preserves structure

Migrations Are Functors

Consider migrating from PostgreSQL to DynamoDB:

The Source Category (PostgreSQL)

Objects: Tables, views, types Morphisms: Queries, joins, transactions

-- Object: users table
CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR, email VARCHAR);
-- Object: orders table
CREATE TABLE orders (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id));
-- Morphism: join query
SELECT * FROM orders o JOIN users u ON o.user_id = u.id;

The Target Category (DynamoDB)

Objects: Tables, GSIs2 Morphisms: GetItem, Query, Scan, Transactions

The Functor (Migration)

F(users table) = Users table (PK: id)
F(orders table) = Orders table (PK: id, GSI on user_id)
F(join query) = ???

Here’s where migrations fail: DynamoDB doesn’t have joins. The morphism f:Orders×UsersJoinResultf: \text{Orders} \times \text{Users} \to \text{JoinResult} has no image under FF.

Solutions

Option 1: Denormalize (change source structure)

F(denormalized_orders table) = Orders table with embedded user data

Now there’s no join morphism to preserve.

Option 2: Application-level joins (different target category)

F(join query) = Lambda function that queries both tables and merges

You’re changing the target category to include these new morphisms.

Option 3: Accept the loss (partial functor)

Document which operations won’t work and handle them as errors.


Covariant and Contravariant Functors

Functors come in two flavors:

Covariant Functor3

Morphisms map in the same direction:

f:ABF(f):F(A)F(B)f: A \to B \quad \Rightarrow \quad F(f): F(A) \to F(B)

Example: Database migration

PostgreSQL users → DynamoDB Users
PostgreSQL query → DynamoDB Query
Direction preserved

Contravariant Functor4

Morphisms map in the opposite direction:

f:ABF(f):F(B)F(A)f: A \to B \quad \Rightarrow \quad F(f): F(B) \to F(A)

Example: API consumer perspective

Service provides: getUser(id) → User
Consumer needs: User → void (callback/handler)
// The consumer functor reverses arrows
F(getUser) = userHandler: User → void

This explains why producer changes propagate “backwards” to consumers.


The Functor Laws in Practice

Law 1: Preserve Composition

F(gf)=F(g)F(f)F(g \circ f) = F(g) \circ F(f)

Violation example: Inconsistent data migration

// Source: two-step process
const createUser = (data: UserInput): User => { /* ... */ };
const assignRole = (user: User, role: Role): User => { /* ... */ };
const setupNewUser = (data: UserInput, role: Role): User =>
assignRole(createUser(data), role); // g ∘ f
// Target: migration breaks composition
const migratedCreateUser = (data: UserInput): MigratedUser => {
/* ... creates in new system */
};
const migratedAssignRole = (user: MigratedUser, role: Role): MigratedUser => {
/* ... but assumes old format! */
};
// F(g ∘ f) ≠ F(g) ∘ F(f) — composition broken

Fix: Ensure each step produces inputs compatible with the next.

Law 2: Preserve Identity

F(idA)=idF(A)F(\text{id}_A) = \text{id}_{F(A)}

Violation example: Health checks fail after migration

// Source: identity works
GET /users/123User(123) // Returns same user
// Target: migration adds transformation
GET /users/123User(123) + { migrated: true, legacyId: 'old-123' }
// F(id) ≠ id — "do nothing" now does something

Fix: Ensure read-then-write cycles are stable.


Functors Between Architectural Domains

Monolith → Microservices

The decomposition is a functor:

Source Category: Monolith
Objects: Modules, Classes, Functions
Morphisms: Method calls, imports
Target Category: Microservices
Objects: Services, APIs, Queues
Morphisms: HTTP calls, events, messages
Functor F: Decomposition
F(UserModule) = UserService
F(OrderModule) = OrderService
F(userModule.getUser()) = GET /users/{id}
F(orderModule.createOrder()) = POST /orders

Composition Preservation

In the monolith:

const process = (data: Input): Output =>
orderModule.createOrder(userModule.getUser(data.userId), data.items);
// Single transaction, synchronous composition

The functor must map this to microservices:

const process = async (data: Input): Promise<Output> => {
const user = await fetch('/users/' + data.userId);
const order = await fetch('/orders', { body: { user, items: data.items } });
return order;
};
// Distributed, async, no transaction guarantees by default

Endofunctors: Self-Transformations

An endofunctor6 is a functor from a category to itself: F:CCF: \mathcal{C} \to \mathcal{C}

These are incredibly common in programming and architecture.

Option/Maybe as Endofunctor

// F: Type → Type
type Option<A> = Some<A> | None;
// F: (A → B) → (Option<A> → Option<B>)
const map = <A, B>(f: (a: A) => B) => (opt: Option<A>): Option<B> =>
opt.tag === 'Some' ? Some(f(opt.value)) : None;

This is an endofunctor on the category of TypeScript types.

Retry as Endofunctor

// F: ServiceCall → ServiceCall (with retry logic)
const withRetry = <A, B>(
call: (a: A) => Promise<B>,
maxRetries: number
): (a: A) => Promise<B> =>
async (a: A) => {
for (let i = 0; i <= maxRetries; i++) {
try {
return await call(a);
} catch (e) {
if (i === maxRetries) throw e;
await delay(exponentialBackoff(i));
}
}
throw new Error('Unreachable');
};

Circuit Breaker as Endofunctor

// F: ServiceCall → ServiceCall (with circuit breaker)
const withCircuitBreaker = <A, B>(
call: (a: A) => Promise<B>,
config: CircuitBreakerConfig
): (a: A) => Promise<B> => {
const breaker = new CircuitBreaker(config);
return (a: A) => breaker.execute(() => call(a));
};

These endofunctors compose:

const resilientCall = pipe(
originalCall,
withRetry(3),
withCircuitBreaker({ threshold: 5, timeout: 30000 }),
withTimeout(5000),
withLogging('service-x')
);

Each layer is an endofunctor. The composition is still a functor.


Faithful and Full Functors

Not all functors preserve structure equally:

Faithful Functor7

Injective on morphisms: if F(f)=F(g)F(f) = F(g), then f=gf = g.

Meaning: No information loss in morphisms.

Example: Lossless API versioning

// v1 and v2 endpoints map to different implementations
// No two source morphisms collapse to the same target

Full Functor8

Surjective on morphisms: every morphism in the target comes from the source.

Meaning: All target operations exist in source.

Example: Wrapper that exposes everything

// The adapter exposes ALL underlying capabilities
// Nothing is hidden

Fully Faithful Functor9

Both full and faithful: morphisms correspond bijectively.

Meaning: The functor is an embedding—source structure is perfectly preserved.

Example: Perfect migration

// Every PostgreSQL operation has exactly one DynamoDB equivalent
// Every DynamoDB operation came from exactly one PostgreSQL operation
// (Rare in practice!)

AWS Service Migrations as Functors

EC2 → Lambda

Objects:
F(EC2 Instance) = Lambda Function
F(Instance State) = Function Configuration
Morphisms:
F(HTTP request → response) = HTTP event → response
F(Background job) = CloudWatch event → execution
Lost morphisms:
- Long-running processes (15 min limit)
- Local state persistence
- Full OS access

This functor is neither full nor faithful—it’s a significant structural change.

RDS → Aurora Serverless

Objects:
F(RDS Instance) = Aurora Cluster
F(Tables) = Tables (identical)
Morphisms:
F(SQL query) = SQL query (identical)
F(Stored procedures) = Stored procedures (identical)
This is nearly fully faithful—minimal structural change.

ECS → EKS

Objects:
F(ECS Cluster) = EKS Cluster
F(Task Definition) = Pod Spec
F(Service) = Deployment + Service
Morphisms:
F(Task execution) = Pod execution
F(Service discovery) = Kubernetes Service
Added morphisms in target:
- Native Kubernetes operators
- Helm charts
- Kubernetes-native tooling

This functor is faithful but not full—EKS has more morphisms.


Designing Migrations as Functors

Step 1: Map Objects

List all objects in source, define their targets:

| Source Object | Target Object | Notes |
|---------------|---------------|-------|
| users table | Users table | PK changes |
| orders table | Orders table | Denormalized |
| sessions table | ElastiCache | Move to cache |

Step 2: Map Morphisms

For each operation, define the target operation:

| Source Morphism | Target Morphism | Preserved? |
|-----------------|-----------------|------------|
| SELECT * JOIN | Multiple queries + merge | Modified |
| INSERT with FK | Put + separate Put | Different |
| Transaction | ??? | Lost! |

Step 3: Verify Laws

Composition: Does sequential operation order still work?

// Source: guaranteed order
insert(order) → insert(items) → commit
// Target: eventual consistency?
put(order) → put(items) → ???

Identity: Do read-then-write cycles stabilize?

// Source: read-modify-write
const user = select(id);
const updated = { ...user }; // No changes
update(id, updated);
// Result: identical user
// Target: does this still hold?

Step 4: Document Losses

Be explicit about what’s lost:

## Migration Limitations
### Lost Morphisms
- Transactional writes across tables
- Complex JOINs with more than 2 tables
- Full-text search (moving to OpenSearch)
### Modified Morphisms
- Batch inserts: now use BatchWriteItem (25 item limit)
- Updates: must include full item, not partial
### New Morphisms
- Point-in-time recovery
- Global tables for multi-region

The Takeaway

A migration is a functor. To succeed:

  1. Map objects completely: every source object needs a target
  2. Map morphisms completely: every operation needs a translation
  3. Verify composition: sequential operations must still work in sequence
  4. Verify identity: do-nothing operations must still do nothing
  5. Document losses: be explicit about what isn’t preserved

When a migration “fails,” it’s usually because the functor laws were violated—composition broke, identity mutated, or morphisms had no mapping.

Design migrations as functors, and you’ll know exactly what works and what doesn’t.


Next in the series: Natural Transformations: Coherent Change Across Systems — Where we learn how to transform one functor into another while maintaining consistency everywhere.

Footnotes

  1. The word functor comes from the Latin functor (“performer”), derived from the verb fungī meaning “to perform” or “to execute” (unrelated to mushrooms, despite the spelling). In category theory, a functor “performs” a translation between categories. The concept was introduced by Samuel Eilenberg and Saunders Mac Lane in 1945, along with the definition of category itself. Functors formalize the notion of “structure-preserving map” — they’re homomorphisms between categories, just as group homomorphisms preserve group structure and ring homomorphisms preserve ring structure.

  2. GSI stands for Global Secondary Index in DynamoDB. Unlike primary keys which define the main access pattern, GSIs allow you to query the table using alternative key attributes. A GSI has its own partition key and optional sort key, enabling efficient queries on non-primary-key attributes. Each table can have up to 20 GSIs. They’re “global” because queries can span all partitions, unlike Local Secondary Indexes (LSIs) which are constrained to items with the same partition key.

  3. A covariant functor (often just called “functor”) preserves the direction of morphisms: if f:ABf: A \to B, then F(f):F(A)F(B)F(f): F(A) \to F(B). Think of it as “going with the flow.” In programming, Array.map is covariant — if you have a function string → number, you get Array<string> → Array<number> in the same direction. Most functors you encounter are covariant.

  4. A contravariant functor reverses the direction of morphisms: if f:ABf: A \to B, then F(f):F(B)F(A)F(f): F(B) \to F(A). This appears naturally in “consumer” or “input” positions. For example, Predicate<T> (functions T → boolean) is contravariant in T: if you can handle Animal, you can handle Cat (because every Cat is an Animal). In TypeScript, function parameter types are contravariant. Contravariant functors are sometimes called cofunctors or presheaves (when the target is Set).

  5. The Saga pattern is a way to manage distributed transactions without distributed locks. Instead of one atomic transaction spanning multiple services, a saga is a sequence of local transactions where each step publishes an event that triggers the next. If a step fails, the saga executes compensating transactions to undo previous steps (e.g., if payment fails after reserving inventory, release the reservation). There are two coordination approaches: choreography (services react to events independently) and orchestration (a central coordinator directs the sequence). The pattern was first described by Hector Garcia-Molina and Kenneth Salem in 1987.

  6. An endofunctor is a functor from a category to itself (F:CCF: \mathcal{C} \to \mathcal{C}). The prefix “endo-” means “within” (from Greek éndon). Endofunctors are ubiquitous in programming: Array<T>, Promise<T>, Option<T>, and Result<T, E> are all endofunctors on the category of types. The famous (and often misunderstood) phrase “a monad is just a monoid in the category of endofunctors” refers to this: monads are endofunctors with additional structure (unit and join) satisfying monoid laws.

  7. A faithful functor is injective (one-to-one) on hom-sets: for any two objects A,BA, B, the mapping F:Hom(A,B)Hom(F(A),F(B))F: \text{Hom}(A,B) \to \text{Hom}(F(A), F(B)) is injective. This means distinct morphisms in the source map to distinct morphisms in the target — no information about relationships is lost. A faithful functor “remembers” all the structure. In database terms: if two queries do different things, they remain different after migration.

  8. A full functor is surjective (onto) on hom-sets: for any two objects A,BA, B, every morphism g:F(A)F(B)g: F(A) \to F(B) in the target comes from some morphism f:ABf: A \to B in the source. This means the target doesn’t introduce new relationships — everything you can do in the target, you could already do in the source. A full functor “covers” all the structure. In migration terms: the new system doesn’t have capabilities the old one lacked.

  9. A fully faithful functor is both full and faithful — it’s a bijection on hom-sets. This means the functor is an embedding: the source category sits inside the target category with its structure perfectly preserved. No morphisms are lost (faithful) and no new morphisms appear (full). Fully faithful functors represent “perfect” translations where the source and its image are structurally identical. In practice, most real-world migrations are neither full nor faithful — there are always trade-offs.

Share this post

Comments

Favorite Books

Links are Amazon affiliate links.