📝 en ~ 11 min read ~ ☕
Equivalence of Categories: When Different Architectures Are 'The Same'
Share this post
This article is part 7 of a 7-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 Hamming[^1]
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
An 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
Two 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
Monolith 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 user = await fetch(`${USER_SERVICE}/users/${userId}`); 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.
The Equivalence Functor Pair
For architectures and to be equivalent, you need:
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)
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
: Decomposing then recomposing gives something isomorphic to the original.[^9]
// 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
Equivalence fails when structural properties don’t transfer:
Transactions Don’t Transfer
Monolith:
@Transactionalasync 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}```[^4]
**Microservices**:```typescriptasync 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 pattern[^5]
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).
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.[^6]
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!Skeleton and Coskeleton
Every category has a skeleton: the smallest equivalent category with no isomorphic objects.[^7]
Architecture insight: The skeleton is your “essential” architecture—with all redundancy removed.
Finding the Skeleton
Original 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
- 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
Two databases are Morita equivalent if their categories of “modules” (queries, views) are equivalent.[^8]
Example: Relational vs. Document
Relational 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
Behavioral Equivalence Testing
describe('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
Using 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
An adjoint equivalence is an equivalence where the functors form an adjunction (which we’ll cover in Part 11). This is the “best” kind of equivalence.[^11]
Architectural meaning: Not only are the architectures equivalent, but there’s a canonical way to translate between them.
// Left adjoint: free constructionconst decompose = (monolith: Monolith): Microservices => { // Canonical decomposition};
// Right adjoint: forgetful/aggregationconst compose = (microservices: Microservices): Monolith => { // Canonical composition};
// Adjunction means these translations are "optimal"AWS Equivalences
Lambda vs. ECS
For 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
Single Table Design ≅ Multi-Table Design
If: Proper GSI design mirrors the joins you needIf: Access patterns are known and stable```[^12]
**Equivalent for**: Known access patterns**Not equivalent for**: Ad-hoc querying, complex reporting
### SQS + Lambda vs. Kinesis + LambdaSQS: Pull-based, message-level processing Kinesis: Push-based, batch processing
Equivalent when: Order doesn’t matter, batch size = 1 Not equivalent when: Ordering matters, need replay
---
## The Equivalence Decision Framework
When choosing between architecturally different options:
### 1. Identify the Categories
What are the objects and morphisms in each approach?
### 2. Check Morphism Correspondence
Does every operation in A have an equivalent in B?
### 3. Verify Composition
Do sequential operations compose the same way?
### 4. Identify Breaks
What properties exist in one but not the other?- Transactions?- Latency bounds?- Consistency guarantees?
### 5. Decide Based on Breaks
If the breaks don't matter for your use case, the architectures are equivalent *for you*.
<InfoBox title="The Key Question" type="note">
"Are these architectures equivalent *for my requirements*?"
Not abstractly equivalent—equivalent for what you actually need.
</InfoBox>
---
## The Takeaway
Equivalence is about preserving what matters:
1. **Different implementations can be equivalent** if they support the same operations2. **Equivalence is weaker than isomorphism** but more practical3. **Test for equivalence** by verifying morphism correspondence and composition4. **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
[^1]: 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.
[^2]: In category theory notation: $\circ$ represents composition of functors, and $\text{Id}_{\mathcal{C}}$ is the identity functor on category $\mathcal{C}$ (which maps every object to itself and every morphism to itself). The equation states that composing the functors $F$ and $G$ in either order gives exactly the identity functor.
[^3]: The symbol $\cong$ 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.
[^4]: The `@Transactional` annotation 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.
[^5]: 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](https://aws.amazon.com/step-functions/).
[^6]: 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.
[^7]: 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.
[^8]: 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.
[^9]: 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.
[^10]: Consumer-driven contracts are a pattern where service consumers define the contracts they expect, rather than providers dictating them. Tools like [Pact](https://pact.io/) enable testing these contracts across different implementations. This is particularly useful for verifying architectural equivalence between systems that must satisfy the same client requirements.
[^11]: Adjunctions are pairs of functors with optimal translation properties—they preserve the most structure possible. When an equivalence is also an adjunction (adjoint equivalence), the round-trip translations are not just isomorphic but universal (no other translation is "better"). We'll explore adjunctions in depth in Part 11: "Adjunctions: The Universal Translation Pattern."
[^12]: Rick Houlihan's talks at AWS re:Invent popularized DynamoDB single-table design patterns. Key resources: [AWS DynamoDB single-table design](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/) and his advanced design pattern talks. Single-table design trades schema flexibility for performance and cost optimization when access patterns are well-understood.