en ~ 11 min read ~
The Yoneda Perspective: Systems Defined by Their Interfaces
Share this post
This article is part 3 of a 9-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
Section titled The Lemma, InformallyHere’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
Section titled What This Means for ArchitectureA Database Is Its Query Interface
Section titled A Database Is Its Query InterfaceConsider 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 morphismINSERT INTO ... VALUES ... -- Insert morphismUPDATE ... SET ... WHERE ... -- Update morphismDELETE FROM ... WHERE ... -- Delete morphismBEGIN ... COMMIT / ROLLBACK -- Transaction morphismsCREATE INDEX ... -- Schema morphismsIf another database accepts all the same morphisms and produces the same results, it is—for your purposes—the same thing.
Yoneda Embedding
This is called the “Yoneda embedding”: you can embed any object into the category of its interfaces. Two objects with identical interfaces are identical as far as the rest of the category can tell.
A Service Is Its API
Section titled A Service Is Its APIWhat 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
Section titled Representable Functors: When One Interface Rules Them AllSome interfaces are special: they’re so well-designed that any interaction with the system factors through them.
The Universal Property
Section titled The Universal PropertyA 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
Section titled Example: REST Resource IdentifierFor a RESTful service, the resource path is often the universal interface:
GET /resources/{id}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
Section titled Example: Event SourcingIn 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
Section titled Consumer-Driven Contracts: Yoneda in PracticeConsumer-driven contracts3 (CDC) embody the Yoneda perspective perfectly.
Traditional (Provider-Driven)
Section titled Traditional (Provider-Driven)Provider says: “Here’s what I offer”
# Provider definespaths: /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 fieldsConsumer-Driven (Yoneda Perspective)
Section titled 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—for the interactions those consumers exercise—regardless of how it’s implemented.
Yoneda Says
The service is the collection of all consumer views. Any implementation that satisfies all consumer contracts is, categorically speaking, the same service—relative to those contracts. One honest caveat: Yoneda requires agreement on all morphisms, naturally, while a contract suite only samples . Two implementations that pass every existing contract can still differ on interactions no current consumer exercises.
Abstraction Through Interfaces
Section titled Abstraction Through InterfacesThe 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
Section titled Example: Storage AbstractionInternal reality (DynamoDB):
// Many morphisms availableinterface 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<TransactWriteCommandOutput>; batchWrite(params: BatchWriteCommandInput): Promise<BatchWriteCommandOutput>; // ... many more}Abstracted interface (your Repository):
// Fewer morphisms exposedinterface 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
Section titled The Abstraction TestGood 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 consumersBad 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
Section titled The Presheaf PerspectiveIn 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
Section titled Multiple Stakeholder ViewsDeveloper 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 statusEach stakeholder observes the system through different morphisms. The system is all these views combined—the presheaf.
Consistency Across Views
Section titled Consistency Across ViewsWithin any single view, the presheaf structure demands functoriality: observing along a composed morphism must equal composing the observations—. That keeps each view internally coherent, but it does not force different stakeholder views to agree with each other.
If: Developer deploys version 2.0Then: Operations should see version 2.0 in metrics Business should see version 2.0 features in analytics Security should scan version 2.0That stronger property—independent views agreeing on their overlaps and gluing into one global picture—is the sheaf condition. When it holds, you don’t just have a presheaf, you have a sheaf. Presheaves famously need not satisfy it, which is exactly why stakeholder views drift apart unless you actively make them glue.
Interface Segregation Is Yoneda
Section titled Interface Segregation Is YonedaThe 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 interfaceinterface 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 needinterface 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
Section titled Yoneda and TestingMock Objects Are Yoneda Incarnate
Section titled Mock Objects Are Yoneda IncarnateWhen you mock a service, you’re saying: “I don’t care about the implementation—I care about the interface.”
// The real serviceconst realUserService = new PostgresUserService(db);
// The mock - Yoneda equivalent for testing purposesconst 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 testsContract Testing
Section titled Contract TestingContract tests verify Yoneda equivalence:
// Consumer contractdescribe('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 morphismsAWS Through the Yoneda Lens
Section titled AWS Through the Yoneda LensLambda Function Identity
Section titled Lambda Function IdentityWhat is a Lambda function? Its interface:
// A Lambda IS this signaturetype LambdaHandler = (event: Event, context: Context) => Promise<Response>;Everything else—runtime, memory, timeout, VPC configuration—is implementation detail. Two Lambdas with the same interface and the same observable behavior on every event are interchangeable.
S3 as Universal Storage
Section titled S3 as Universal StorageS3’s interface is remarkably simple:
PUT /{bucket}/{key} → store bytesGET /{bucket}/{key} → retrieve bytesDELETE /{bucket}/{key} → remove objectLIST /{bucket}?prefix= → enumerate objectsThis interface represents an enormous amount of functionality. From cold storage to real-time analytics, the same morphisms work.
IAM Policies as Morphism Filters
Section titled IAM Policies as Morphism FiltersIAM 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
Section titled The TakeawayA 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.
Footnotes
Section titled Footnotes-
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. ↩