📝 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 between categories and consists of:
- Object mapping: For every object in , an object in
- Morphism mapping: For every morphism in , a morphism in
Such that:
Migrations Are Functors
Consider migrating from PostgreSQL to DynamoDB:
The Source Category (PostgreSQL)
Objects: Tables, views, types Morphisms: Queries, joins, transactions
-- Object: users tableCREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR, email VARCHAR);
-- Object: orders tableCREATE TABLE orders (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id));
-- Morphism: join querySELECT * 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 has no image under .
Solutions
Option 1: Denormalize (change source structure)
F(denormalized_orders table) = Orders table with embedded user dataNow 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 mergesYou’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:
Example: Database migration
PostgreSQL users → DynamoDB UsersPostgreSQL query → DynamoDB QueryDirection preservedContravariant Functor4
Morphisms map in the opposite direction:
Example: API consumer perspective
Service provides: getUser(id) → UserConsumer needs: User → void (callback/handler)
// The consumer functor reverses arrowsF(getUser) = userHandler: User → voidThis explains why producer changes propagate “backwards” to consumers.
The Functor Laws in Practice
Law 1: Preserve Composition
Violation example: Inconsistent data migration
// Source: two-step processconst 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 compositionconst 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 brokenFix: Ensure each step produces inputs compatible with the next.
Law 2: Preserve Identity
Violation example: Health checks fail after migration
// Source: identity worksGET /users/123 → User(123) // Returns same user
// Target: migration adds transformationGET /users/123 → User(123) + { migrated: true, legacyId: 'old-123' }
// F(id) ≠ id — "do nothing" now does somethingFix: 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 /ordersComposition Preservation
In the monolith:
const process = (data: Input): Output => orderModule.createOrder(userModule.getUser(data.userId), data.items);// Single transaction, synchronous compositionThe 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 defaultEndofunctors: Self-Transformations
An endofunctor6 is a functor from a category to itself:
These are incredibly common in programming and architecture.
Option/Maybe as Endofunctor
// F: Type → Typetype 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 , then .
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 targetFull 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 hiddenFully 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 accessThis 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 toolingThis 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 orderinsert(order) → insert(items) → commit
// Target: eventual consistency?put(order) → put(items) → ???Identity: Do read-then-write cycles stabilize?
// Source: read-modify-writeconst user = select(id);const updated = { ...user }; // No changesupdate(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-regionThe Takeaway
A migration is a functor. To succeed:
- Map objects completely: every source object needs a target
- Map morphisms completely: every operation needs a translation
- Verify composition: sequential operations must still work in sequence
- Verify identity: do-nothing operations must still do nothing
- 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
-
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. ↩
-
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. ↩
-
A covariant functor (often just called “functor”) preserves the direction of morphisms: if , then . Think of it as “going with the flow.” In programming,
Array.mapis covariant — if you have a functionstring → number, you getArray<string> → Array<number>in the same direction. Most functors you encounter are covariant. ↩ -
A contravariant functor reverses the direction of morphisms: if , then . This appears naturally in “consumer” or “input” positions. For example,
Predicate<T>(functionsT → boolean) is contravariant inT: if you can handleAnimal, you can handleCat(because everyCatis anAnimal). In TypeScript, function parameter types are contravariant. Contravariant functors are sometimes called cofunctors or presheaves (when the target is Set). ↩ -
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. ↩
-
An endofunctor is a functor from a category to itself (). The prefix “endo-” means “within” (from Greek éndon). Endofunctors are ubiquitous in programming:
Array<T>,Promise<T>,Option<T>, andResult<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. ↩ -
A faithful functor is injective (one-to-one) on hom-sets: for any two objects , the mapping 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. ↩
-
A full functor is surjective (onto) on hom-sets: for any two objects , every morphism in the target comes from some morphism 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. ↩
-
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. ↩