This article is part 3 of a 7-part series: Categorical Solutions Architecture
See the full series navigation at the end of this article.
“The Yoneda lemma is arguably the most important result in category theory.”
— Emily Riehl, mathematician
The Yoneda lemma says something profound: an object is completely determined by its relationships to all other objects. For Solutions Architects, this means a system is its interfaces. You don’t need to know how a database stores data—you need to know what queries it accepts and what it returns. This perspective transforms how we think about abstraction, contracts, and system boundaries.
The Lemma, Informally
Here’s the Yoneda lemma in plain English:
To know an object, know all the ways other objects map into it.
Or equivalently:
An object is completely characterized by its collection of morphisms from all other objects.
This is written mathematically as:
Don’t worry about the formula. The intuition is what matters.
What This Means for Architecture
A Database Is Its Query Interface
Consider PostgreSQL. You could describe it as:
- “A relational database management system”
- “Software that stores data in tables with ACID guarantees”1
- “An object-relational database with extensibility features”
These descriptions focus on what it is. Yoneda says: describe what you can do with it.
-- PostgreSQL IS these morphisms:
SELECT ... FROM ... WHERE ... -- Query morphism
INSERT INTO ... VALUES ... -- Insert morphism
UPDATE ... SET ... WHERE ... -- Update morphism
DELETE FROM ... WHERE ... -- Delete morphism
BEGIN ... COMMIT / ROLLBACK -- Transaction morphisms
CREATE INDEX ... -- Schema morphisms
If another database accepts all the same morphisms and produces the same results, it is—for your purposes—the same thing.
A Service Is Its API
What is your “User Service”? Not its implementation—its interface:
interface UserService {
// These morphisms ARE the service
getUser(id: UserId): Promise<User | null>;
createUser(data: CreateUserInput): Promise<User>;
updateUser(id: UserId, data: UpdateUserInput): Promise<User>;
deleteUser(id: UserId): Promise<void>;
authenticate(credentials: Credentials): Promise<AuthToken>;
authorize(token: AuthToken, resource: Resource): Promise<boolean>;
}
Change the interface, change the service. Change the implementation? Architecturally invisible.
Representable Functors: When One Interface Rules Them All
Some interfaces are special: they’re so well-designed that any interaction with the system factors through them.
The Universal Property
A representable functor2 is one where there exists a “universal element” that generates all other elements. In architecture:
Universal Interface: If you can do X, you can do anything.
Example: REST Resource Identifier
For a RESTful service, the resource path is often the universal interface:
If you know the id, you can:
- Retrieve the resource
- Construct update/delete URLs
- Navigate to related resources via links
The id represents the resource. This is the Yoneda perspective in action.
Example: Event Sourcing
In event-sourced systems, the event stream is the universal interface:
interface EventStore {
// This IS the system
append(streamId: string, events: Event[]): Promise<void>;
read(streamId: string, fromVersion?: number): AsyncIterable<Event>;
}
Everything else—projections, snapshots, queries—derives from this. The event stream represents the entire system state.
Consumer-Driven Contracts: Yoneda in Practice
Consumer-driven contracts3 (CDC) embody the Yoneda perspective perfectly.
Traditional (Provider-Driven)
Provider says: “Here’s what I offer”
# Provider defines
paths:
/users/{id}:
get:
responses:
200:
schema:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
created_at: { type: string }
updated_at: { type: string }
# ... 20 more fields
Consumer-Driven (Yoneda Perspective)
Consumers say: “Here’s what we need from you”
// Consumer A only needs:
interface UserForConsumerA {
id: string;
name: string;
}
// Consumer B needs:
interface UserForConsumerB {
id: string;
email: string;
created_at: Date;
}
// Consumer C needs:
interface UserForConsumerC {
id: string;
name: string;
email: string;
}
The service is characterized by the union of consumer expectations. If all consumers are satisfied, the service is correct—regardless of how it’s implemented.
Abstraction Through Interfaces
The Yoneda perspective gives us a precise notion of abstraction:
Abstraction is hiding morphisms.
When you expose a high-level interface, you’re showing fewer morphisms than exist internally.
Example: Storage Abstraction
Internal reality (DynamoDB):
// Many morphisms available
interface DynamoDBDocumentClient {
put(params: PutCommandInput): Promise<PutCommandOutput>;
get(params: GetCommandInput): Promise<GetCommandOutput>;
update(params: UpdateCommandInput): Promise<UpdateCommandOutput>;
delete(params: DeleteCommandInput): Promise<DeleteCommandOutput>;
query(params: QueryCommandInput): Promise<QueryCommandOutput>;
scan(params: ScanCommandInput): Promise<ScanCommandOutput>;
transactWrite(params: TransactWriteCommandInput): Promise<...>;
batchWrite(params: BatchWriteCommandInput): Promise<...>;
// ... many more
}
Abstracted interface (your Repository):
// Fewer morphisms exposed
interface UserRepository {
save(user: User): Promise<void>;
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
delete(id: UserId): Promise<void>;
}
You’ve hidden morphisms. The consumers of UserRepository can’t tell it’s DynamoDB underneath. They see only the morphisms you’ve exposed.
The Abstraction Test
Good abstraction: Consumers can’t distinguish between implementations
// These are Yoneda-equivalent for UserRepository consumers:
const dynamoRepo = new DynamoDBUserRepository();
const postgresRepo = new PostgresUserRepository();
const memoryRepo = new InMemoryUserRepository();
// If all three satisfy the interface, they're "the same" to consumers
Bad abstraction: Implementation details leak
interface LeakyUserRepository {
save(user: User): Promise<void>;
findById(id: UserId): Promise<User | null>;
// Leaking DynamoDB morphisms:
query(keyCondition: string, indexName?: string): Promise<User[]>;
scan(filterExpression?: string): Promise<User[]>;
}
The Presheaf Perspective
In category theory, a presheaf4 on a category is a functor . This assigns to each object a set and to each morphism a function going the “opposite” direction.
For architecture, this means:
A system is defined by what can be observed from each viewpoint.
Multiple Stakeholder Views
Developer View:
- Source code
- Build artifacts
- Test results
Operations View:
- Metrics
- Logs
- Alerts
Business View:
- User counts
- Revenue impact
- SLA compliance
Security View:
- Access logs
- Vulnerability scans
- Compliance status
Each stakeholder observes the system through different morphisms. The system is all these views combined—the presheaf.
Consistency Across Views
The presheaf structure demands naturality: views must be consistent.
If: Developer deploys version 2.0
Then: Operations should see version 2.0 in metrics
Business should see version 2.0 features in analytics
Security should scan version 2.0
If views disagree, the presheaf is broken.
Interface Segregation Is Yoneda
The Interface Segregation Principle5 (ISP) from SOLID says:
“Clients should not be forced to depend on methods they do not use.”
This is precisely Yoneda reasoning:
// Bad: One fat interface
interface UserService {
createUser(data: CreateUserInput): Promise<User>;
getUser(id: UserId): Promise<User>;
updateUser(id: UserId, data: UpdateUserInput): Promise<User>;
deleteUser(id: UserId): Promise<void>;
authenticate(creds: Credentials): Promise<Token>;
authorize(token: Token, resource: Resource): Promise<boolean>;
exportUserData(id: UserId): Promise<DataExport>;
deleteAllUserData(id: UserId): Promise<void>;
}
// Good: Segregated by consumer need
interface UserReader {
getUser(id: UserId): Promise<User>;
}
interface UserWriter {
createUser(data: CreateUserInput): Promise<User>;
updateUser(id: UserId, data: UpdateUserInput): Promise<User>;
deleteUser(id: UserId): Promise<void>;
}
interface Authenticator {
authenticate(creds: Credentials): Promise<Token>;
}
interface Authorizer {
authorize(token: Token, resource: Resource): Promise<boolean>;
}
interface GDPRCompliance {
exportUserData(id: UserId): Promise<DataExport>;
deleteAllUserData(id: UserId): Promise<void>;
}
Each consumer interacts only with the morphisms it needs. From UserReader’s perspective, the other morphisms don’t exist.
Yoneda and Testing
Mock Objects Are Yoneda Incarnate
When you mock a service, you’re saying: “I don’t care about the implementation—I care about the interface.”
// The real service
const realUserService = new PostgresUserService(db);
// The mock - Yoneda equivalent for testing purposes
const mockUserService: UserService = {
getUser: jest.fn().mockResolvedValue({ id: '1', name: 'Test' }),
createUser: jest.fn().mockResolvedValue({ id: '2', name: 'New' }),
// ...
};
// If the mock satisfies the same interface, it's "the same" for tests
Contract Testing
Contract tests verify Yoneda equivalence:
// Consumer contract
describe('UserService contract', () => {
it('getUser returns User with id and name', async () => {
const user = await service.getUser('123');
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('name');
});
});
// Run against real implementation
// Run against mock
// Run against staging environment
// If all pass, they're Yoneda-equivalent for these morphisms
AWS Through the Yoneda Lens
Lambda Function Identity
What is a Lambda function? Its interface:
// A Lambda IS this signature
type LambdaHandler = (event: Event, context: Context) => Promise<Response>;
Everything else—runtime, memory, timeout, VPC configuration—is implementation detail. Two Lambdas with the same interface are interchangeable.
S3 as Universal Storage
S3’s interface is remarkably simple:
PUT /{bucket}/{key} → store bytes
GET /{bucket}/{key} → retrieve bytes
DELETE /{bucket}/{key} → remove object
LIST /{bucket}?prefix= → enumerate objects
This interface represents an enormous amount of functionality. From cold storage to real-time analytics, the same morphisms work.
IAM Policies as Morphism Filters
IAM policies restrict which morphisms a principal can use:
{
"Effect": "Allow",
"Action": [
"s3:GetObject", // Allow this morphism
"s3:PutObject" // Allow this morphism
],
"Resource": "arn:aws:s3:::my-bucket/*"
}
You’re literally defining which arrows exist in that principal’s category.
The Takeaway
A system is its interfaces.
The Yoneda perspective tells us:
- Objects are determined by morphisms into them
- Implementation is categorically invisible
- Abstraction means hiding morphisms
- Consumer-driven contracts are natural
- Testing verifies interface equivalence
When you understand a system’s interfaces, you understand the system. Everything else is implementation detail.
Next in the series: Composition as Architectural Law: Diagnosing Integration Failures — Where we learn that composition failures aren’t bugs—they’re category-theoretic impossibilities.
-
ACID stands for Atomicity (transactions are all-or-nothing), Consistency (transactions move the database from one valid state to another), Isolation (concurrent transactions don’t interfere), and Durability (committed transactions survive crashes). These properties define what it means for a database to be “reliable” in the transactional sense. The term was coined by Andreas Reuter and Theo Härder in 1983. ↩
-
A representable functor is a functor that is naturally isomorphic to for some object . That object is called the “representing object.” In programming terms: if you have a generic interface, and there’s one concrete type that “represents” all possible uses of that interface, the functor is representable. The representing object is like a universal remote control—it can do everything the interface allows. ↩
-
Consumer-driven contracts is a testing pattern where API consumers define their expectations, and the provider verifies it meets all of them. Popularized by tools like Pact, it inverts traditional API testing: instead of the provider dictating the contract, consumers specify what they need. This aligns with the Yoneda perspective—the service is characterized by how its consumers use it, not by its internal implementation. ↩
-
A presheaf is a contravariant functor from a category to Set—meaning it reverses the direction of morphisms. If is a morphism in your category, the presheaf gives you a function going backwards. Intuitively: as you “zoom in” (follow a morphism), information “flows back.” In architecture, this captures how observations at a fine-grained level (a specific service) can be pulled back to observations at a coarse-grained level (the whole system). ↩
-
The Interface Segregation Principle is the “I” in SOLID, articulated by Robert C. Martin. It states that no client should be forced to depend on methods it doesn’t use. The solution is to split fat interfaces into smaller, role-specific ones. This reduces coupling and makes systems easier to understand, test, and evolve. From a categorical view, you’re restricting which morphisms each consumer can see. ↩