en ~ 12 min read ~
Equivalence of Categories: When Different Architectures Are 'The Same'
Share this post
This article is part 7 of a 11-part series: Categorical Solutions Architecture
See the full series navigation at the end of this article.
“The purpose of computing is insight, not numbers.”
— Richard Hamming1
Two architectures look completely different—one is a monolith, the other is microservices. Yet from the perspective of their clients, they’re indistinguishable. This isn’t a coincidence; it’s categorical equivalence. Understanding equivalence tells you when architectural choices don’t matter and when they absolutely do.
Equivalence vs. Isomorphism
Section titled Equivalence vs. IsomorphismAn isomorphism between categories is a pair of functors and such that:
This is strict equality—round-tripping gives you exactly what you started with.2
An equivalence is weaker but more useful:
Round-tripping gives you something isomorphic to what you started with—not identical, but indistinguishable.3
Architectural Equivalence
Section titled Architectural EquivalenceTwo architectures are equivalent if:
- Every capability in Architecture A has a corresponding capability in Architecture B
- Every capability in Architecture B has a corresponding capability in Architecture A
- Composing capabilities works the same way in both
Example: Monolith ↔ Microservices
Section titled Example: Monolith ↔ MicroservicesMonolith Category:
- Objects: Modules, classes, functions
- Morphisms: Method calls, function invocations
Microservices Category:
- Objects: Services, APIs, message queues
- Morphisms: HTTP calls, events, gRPC
Can these be equivalent? Yes, if:
// Monolith morphismclass OrderModule { constructor(private userModule: UserModule) {}
createOrder(userId: string, items: Item[]): Order { const user = this.userModule.getUser(userId); return this.processOrder(user, items); }}
// Microservices equivalentclass OrderService { async createOrder(userId: string, items: Item[]): Promise<Order> { const res = await fetch(`${USER_SERVICE}/users/${userId}`); const user = await res.json(); return this.processOrder(user, items); }}From the client’s perspective, both provide:
createOrder(userId, items) → Order
The internal structure differs, but the external interface is equivalent.
Equivalence Criteria
Two architectures are equivalent if:
- Same operations are available
- Same composition patterns work
- Same results are produced (up to isomorphism)
The Equivalence Functor Pair
Section titled The Equivalence Functor PairFor architectures and to be equivalent, you need:
Functor F: A → B (Decomposition)
Section titled Functor F: A → B (Decomposition)Maps monolith structure to microservices:
// F(UserModule) = UserService// F(OrderModule) = OrderService// F(userModule.getUser()) = GET /users/{id}// F(module.method()) = service.endpoint()Functor G: B → A (Composition)
Section titled Functor G: B → A (Composition)Maps microservices back to monolith-like structure:
// G(UserService) = UserClient wrapper// G(OrderService) = OrderClient wrapper// G(HTTP call) = method call on client
class UserClient { async getUser(id: string): Promise<User> { return fetch(`${USER_SERVICE}/users/${id}`).then(r => r.json()); }}Natural Isomorphisms
Section titled Natural Isomorphisms: Decomposing then recomposing gives something isomorphic to the original.4
// Original monolith callconst result1 = orderModule.createOrder(userId, items);
// Decomposed to microservices, then wrapped in clientsconst result2 = await orderClient.createOrder(userId, items);
// Results are isomorphic (same data, different wrapper): Composing then decomposing gives something isomorphic.
When Equivalence Breaks
Section titled When Equivalence BreaksEquivalence fails when structural properties don’t transfer:
Transactions Don’t Transfer
Section titled Transactions Don’t TransferMonolith5:
class CheckoutModule { constructor(private orderRepo: OrderRepo, private paymentRepo: PaymentRepo) {}
@Transactional async createOrderWithPayment(userId: string, items: Item[]) { const order = await this.orderRepo.create(items); const payment = await this.paymentRepo.charge(order.total); // Both succeed or both fail }}Microservices:
class CheckoutService { async createOrderWithPayment(userId: string, items: Item[]) { const order = await orderService.create(items); const payment = await paymentService.charge(order.total); // What if payment fails? Order already exists! }}The morphism “atomic create-and-pay” exists in the monolith category but not in the naive microservices category.
To restore equivalence: Add saga/compensation pattern6
class CheckoutService { async createOrderWithPayment(userId: string, items: Item[]) { const order = await orderService.create(items); try { const payment = await paymentService.charge(order.total); } catch (e) { await orderService.cancel(order.id); // Compensating action throw e; } }}Now there’s an equivalent morphism (though implemented differently)—but only in a coarsened observational category. Sagas lack isolation: the transient order is visible before compensation runs, compensation itself can fail, and the failure end-state is a cancelled order, not no order. The equivalence holds only if your requirements ignore intermediate states, concurrency, and compensation failure.
Latency Changes Semantics
Section titled Latency Changes Semantics// Monolith: ~1msconst user = userModule.getUser(id);const orders = orderModule.getOrdersForUser(user);
// Microservices: ~100ms totalconst user = await userService.getUser(id);const orders = await orderService.getOrdersForUser(user);If latency matters to correctness (real-time systems, trading), these aren’t equivalent—the microservices version can’t meet the same temporal constraints.7
Data Locality Matters
Section titled Data Locality Matters-- Monolith: single database, joins are cheapSELECT o.*, u.*FROM orders oJOIN users u ON o.user_id = u.idWHERE o.total > 1000;// Microservices: data is distributed// This query doesn't have a direct equivalent!Skeletons
Section titled SkeletonsEvery category has a skeleton: the smallest equivalent category with no isomorphic objects.8
Architecture insight: The skeleton is your “essential” architecture—with all redundancy removed.
Finding the Skeleton
Section titled Finding the SkeletonOriginal Architecture:- UserService-v1, UserService-v2, UserService-v3 (all equivalent)- OrderService-Primary, OrderService-Replica (equivalent)- PaymentGateway-Stripe, PaymentGateway-Square (same interface)
Skeleton:- UserService (one representative)- OrderService (one representative)- PaymentGateway (one representative)The skeleton tells you: “This is what you really have, ignoring deployment details.”
Why Skeletons Matter
Section titled Why Skeletons Matter- Simplifies reasoning: Analyze the skeleton, conclusions apply to full system
- Identifies redundancy: Multiple isomorphic services are one logical service
- Clarifies dependencies: See the essential dependency graph
Morita Equivalence for Databases
Section titled Morita Equivalence for DatabasesTwo databases are Morita equivalent if their categories of “modules” (queries, views) are equivalent.9
Example: Relational vs. Document
Section titled Example: Relational vs. DocumentRelational Schema:
CREATE TABLE users (id INT, name VARCHAR, email VARCHAR);CREATE TABLE orders (id INT, user_id INT, items JSONB);Document Schema:
// users collection{ _id: ObjectId, name: String, email: String }
// orders collection{ _id: ObjectId, userId: ObjectId, items: [...] }These are Morita equivalent if:
- Every relational query has a document equivalent
- Every document query has a relational equivalent
- Compositions correspond
They’re NOT equivalent when:
- You need multi-document transactions (relational wins)
- You need deep nested documents (document wins)
- You need arbitrary joins (relational wins)
- You need schema flexibility (document wins)
Testing for Equivalence
Section titled Testing for EquivalenceBehavioral Equivalence Testing
Section titled Behavioral Equivalence Testingdescribe('Architecture Equivalence', () => { const monolith = new MonolithOrderSystem(); const microservices = new MicroservicesOrderSystem();
test.each(orderScenarios)('scenario %s produces equivalent results', async (scenario) => { const monolithResult = await monolith.execute(scenario); const microservicesResult = await microservices.execute(scenario);
// Results should be isomorphic expect(normalize(monolithResult)).toEqual(normalize(microservicesResult)); } );
test('composition is preserved', async () => { // f then g in monolith const m1 = await monolith.f(input); const m2 = await monolith.g(m1);
// f then g in microservices const s1 = await microservices.f(input); const s2 = await microservices.g(s1);
expect(normalize(m2)).toEqual(normalize(s2)); });});Contract Equivalence
Section titled Contract EquivalenceUsing consumer-driven contracts:10
// Both systems must satisfy the same contractsconst userContract = { getUser: { input: { id: 'string' }, output: { id: 'string', name: 'string', email: 'string' } }};
// Test monolith against contracttestContract(monolith.userModule, userContract);
// Test microservices against contracttestContract(microservices.userService, userContract);Adjoint Equivalences
Section titled Adjoint EquivalencesAn adjoint equivalence is an equivalence where the functors form an adjunction (which we’ll cover in Part 11). It’s not a stronger relationship between the categories—every equivalence can be promoted to an adjoint equivalence using the same two functors—but a convenient normalization of one.11
Architectural meaning: The architectures are no more equivalent; adjointness just tidies the translation data so the round-trip witnesses fit together coherently.
// Quasi-inverse pair—each undoes the other up to isomorphismdeclare const decompose: (monolith: Monolith) => Microservices;declare const compose: (microservices: Microservices) => Monolith;
// In an equivalence, each functor is both left and right adjoint// to the other—no free ⊣ forgetful asymmetry hereAWS Equivalences
Section titled AWS EquivalencesLambda vs. ECS
Section titled Lambda vs. ECSFor many workloads, these are equivalent:
Lambda Function ≅ ECS Task (single container)
Morphisms:- invoke(event) → response- (internal processing)Equivalent when: Stateless request/response pattern Not equivalent when: Long-running processes, local state needed
DynamoDB Single-Table vs. Multi-Table
Section titled DynamoDB Single-Table vs. Multi-TableSingle Table Design ≅ Multi-Table Design
If: Proper GSI design mirrors the joins you needIf: Access patterns are known and stableEquivalent for: Known access patterns12 Not equivalent for: Ad-hoc querying, complex reporting
SQS + Lambda vs. Kinesis + Lambda
Section titled SQS + Lambda vs. Kinesis + LambdaSQS: Pull-based, message-level processingKinesis: Push-based, batch processing
Equivalent when: Order doesn't matter, batch size = 1Not equivalent when: Ordering matters, need replayThe Equivalence Decision Framework
Section titled The Equivalence Decision FrameworkWhen choosing between architecturally different options:
1. Identify the Categories
Section titled 1. Identify the CategoriesWhat are the objects and morphisms in each approach?
2. Check Morphism Correspondence
Section titled 2. Check Morphism CorrespondenceDoes every operation in A have an equivalent in B?
3. Verify Composition
Section titled 3. Verify CompositionDo sequential operations compose the same way?
4. Identify Breaks
Section titled 4. Identify BreaksWhat properties exist in one but not the other?
- Transactions?
- Latency bounds?
- Consistency guarantees?
5. Decide Based on Breaks
Section titled 5. Decide Based on BreaksIf the breaks don’t matter for your use case, the architectures are equivalent for you.
The Key Question
“Are these architectures equivalent for my requirements?”
Not abstractly equivalent—equivalent for what you actually need.
The Takeaway
Section titled The TakeawayEquivalence is about preserving what matters:
- Different implementations can be equivalent if they support the same operations
- Equivalence is weaker than isomorphism but more practical
- Test for equivalence by verifying morphism correspondence and composition
- Breaks in equivalence identify when architectural choice matters
When someone says “monolith vs. microservices”—ask “equivalent for what operations?”
The answer tells you whether the choice matters.
Next in the series: Products and Coproducts: The Algebra of Service Composition — Where we learn the universal patterns for combining and decomposing services.
Footnotes
Section titled FootnotesFootnotes
Section titled Footnotes-
Richard Hamming (1915-1998) was an American mathematician and computer scientist, known for his work on error-correcting codes (Hamming codes) and information theory. This quote reflects his philosophy that computing should serve as a tool for understanding and insight, not mere calculation—directly relevant to architectural equivalence, where we focus on structural insights rather than implementation details. ↩
-
In category theory notation: represents composition of functors, and is the identity functor on category (which maps every object to itself and every morphism to itself). The equation states that composing the functors and in either order gives exactly the identity functor. ↩
-
The symbol means “is isomorphic to” (structurally the same but not necessarily identical), as opposed to which means strict equality. This weaker condition makes equivalence more practical than isomorphism for real-world architectures, where exact identity is too strict a requirement. ↩
-
A natural isomorphism is a collection of isomorphisms that “vary naturally” with the objects—meaning the transformation respects the category structure. This is crucial because it means the equivalence isn’t arbitrary but follows from the fundamental structure of the architectures. Natural transformations will be covered in detail in Part 8 of this series. ↩
-
The
@Transactionalannotation works in monoliths because both operations target the same database. ACID (Atomicity, Consistency, Isolation, Durability) guarantees ensure both operations either succeed together or fail together. This property doesn’t automatically transfer to distributed systems where data lives in different databases or services. ↩ -
The saga pattern was introduced by Hector Garcia-Molina and Kenneth Salem in their 1987 paper “Sagas” (Princeton University). It provides long-lived transactions through compensating actions. AWS Step Functions implements this pattern natively, allowing you to define compensating workflows for distributed transactions. See: AWS Step Functions. ↩
-
Examples of systems where latency differences are critical: High-Frequency Trading (HFT) systems where microseconds matter, industrial control systems with real-time constraints, gaming servers requiring low latency for player experience, and robotics systems with hard real-time deadlines. In these domains, a 100x latency increase breaks functional equivalence. ↩
-
The skeleton of a category is formally defined in Saunders Mac Lane’s “Categories for the Working Mathematician” (1971). It’s constructed by choosing one representative from each isomorphism class of objects. This construction is unique up to isomorphism, making it a canonical way to simplify category structure while preserving equivalence. ↩
-
Morita equivalence is named after Japanese mathematician Kiiti Morita, who introduced the concept in ring theory in 1958. Two rings are Morita equivalent if their categories of modules are equivalent. This generalizes naturally to databases: two database schemas are Morita equivalent if their categories of queries/views are equivalent, regardless of internal representation. ↩
-
Consumer-driven contracts are a pattern where service consumers define the contracts they expect, rather than providers dictating them. Tools like Pact enable testing these contracts across different implementations. This is particularly useful for verifying architectural equivalence between systems that must satisfy the same client requirements. ↩
-
Any equivalence can be promoted to an adjoint equivalence with the same two functors by adjusting one of the natural isomorphisms—see Mac Lane, Categories for the Working Mathematician, Theorem IV.4.1. So adjointness is a normalization every equivalence admits, not an extra property distinguishing “better” equivalences. We’ll explore adjunctions in depth in Part 11: “Adjunctions: The Universal Translation Pattern.” ↩
-
Rick Houlihan’s talks at AWS re:Invent popularized DynamoDB single-table design patterns. Key resources: AWS DynamoDB single-table design and his advanced design pattern talks. Single-table design trades schema flexibility for performance and cost optimization when access patterns are well-understood. ↩