The Yoneda Perspective: Systems Defined by Their Interfaces — Ibrahim Cesar

10 min read Original article ↗

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:

Nat(Hom(,A),F)F(A)\text{Nat}(\text{Hom}(-, A), F) \cong F(A)

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 C\mathcal{C} is a functor CopSet\mathcal{C}^{op} \to \text{Set}. 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:

  1. Objects are determined by morphisms into them
  2. Implementation is categorically invisible
  3. Abstraction means hiding morphisms
  4. Consumer-driven contracts are natural
  5. 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.

  1. 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.

  2. A representable functor is a functor F:CSetF: \mathcal{C} \to \text{Set} that is naturally isomorphic to Hom(A,)\text{Hom}(A, -) for some object AA. That object AA 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.

  3. 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.

  4. A presheaf is a contravariant functor from a category to Set—meaning it reverses the direction of morphisms. If f:ABf: A \to B is a morphism in your category, the presheaf gives you a function F(B)F(A)F(B) \to F(A) 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).

  5. 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.