Skip to main content

📝 en ~ 11 min read ~ ☕

Products and Coproducts: The Algebra of Service Composition

Share this post

This article is part 8 of a 8-part series: Categorical Solutions Architecture

See the full series navigation at the end of this article.

“The product is the canonical way to combine two things. The coproduct is the canonical way to offer a choice between two things.”

Every time you design an API gateway that routes to multiple backends, you’re building a product. Every time you create an event router that accepts from multiple sources, you’re building a coproduct. These aren’t just patterns—they’re universal constructions with precise mathematical properties. Understanding them tells you exactly what your architecture must satisfy.

The Product: Canonical Combination

A product of objects AA and BB is an object A×BA \times B together with projection morphisms:

π1:A×BAandπ2:A×BB\pi_1: A \times B \to A \quad \text{and} \quad \pi_2: A \times B \to B

With the universal property:1 for any object CC with morphisms f:CAf: C \to A and g:CBg: C \to B, there exists a unique morphism f,g:CA×B\langle f, g \rangle: C \to A \times B such that:

π1f,g=fandπ2f,g=g\pi_1 \circ \langle f, g \rangle = f \quad \text{and} \quad \pi_2 \circ \langle f, g \rangle = g
C
/ | \
f ⟨f,g⟩ g
/ | \
v v v
A <--π₁-- A × B --π₂--> B
π₁ ∘ ⟨f,g⟩ = f and π₂ ∘ ⟨f,g⟩ = g

Products in Architecture

The API Gateway Pattern

An API Gateway is a product construction:2

Client -----> Gateway -----> Service A
|
+---------> Service B
|
+---------> Service C

The gateway (A×B×CA \times B \times C) has projections (routes) to each backend. Any client request factors uniquely through the gateway.

// The projections
const routeToServiceA = (req: GatewayRequest): ServiceARequest => {
return { endpoint: '/service-a' + req.path, ...transform(req) };
};
const routeToServiceB = (req: GatewayRequest): ServiceBRequest => {
return { endpoint: '/service-b' + req.path, ...transform(req) };
};
// The universal property: any client factors through gateway
const clientRequest = async (target: 'A' | 'B', data: any) => {
// Unique factorization through gateway
const gatewayReq = buildGatewayRequest(target, data);
return await gateway.handle(gatewayReq);
};

Universal Property in Action

The universal property says: there’s exactly one way to route.

// Given: client wants to call Service A and Service B
// Universal property guarantees: unique gateway request exists
interface Gateway {
// The product object
handle(req: GatewayRequest): Promise<GatewayResponse>;
}
// Projections
declare const toServiceA: (req: GatewayRequest) => ServiceARequest;
declare const toServiceB: (req: GatewayRequest) => ServiceBRequest;
// Universal property: for any client with specific service needs,
// there's exactly ONE way to express this through the gateway
const uniqueFactorization = <T>(
needsA: (t: T) => ServiceARequest,
needsB: (t: T) => ServiceBRequest
): ((t: T) => GatewayRequest) => {
return (t: T) => combineRequests(needsA(t), needsB(t));
};

The Coproduct: Canonical Choice

The coproduct (or sum) is the dual of the product.3 For objects AA and BB, the coproduct A+BA + B has injection morphisms:

ι1:AA+Bandι2:BA+B\iota_1: A \to A + B \quad \text{and} \quad \iota_2: B \to A + B

With the universal property: for any object CC with morphisms f:ACf: A \to C and g:BCg: B \to C, there exists a unique morphism [f,g]:A+BC[f, g]: A + B \to C such that:

[f,g]ι1=fand[f,g]ι2=g[f, g] \circ \iota_1 = f \quad \text{and} \quad [f, g] \circ \iota_2 = g
A --ι₁--> A + B <--ι₂-- B
\ | /
f [f,g] g
\ | /
v v v
C
[f,g] ∘ ι₁ = f and [f,g] ∘ ι₂ = g

Coproducts in Architecture

The Event Router Pattern

An event router is a coproduct construction:

Service A ----->
Event Router -----> Consumer
Service B -----> |
|
Service C -----> +------------> Analytics

Multiple sources inject events into a common bus, which then distributes to consumers.

// The injections
const fromServiceA = (event: ServiceAEvent): RouterEvent => ({
source: 'service-a',
type: event.type,
payload: event.data
});
const fromServiceB = (event: ServiceBEvent): RouterEvent => ({
source: 'service-b',
type: event.type,
payload: event.data
});
// The universal property: any consumer factors through router
const handleEvent = (
handleA: (e: ServiceAEvent) => void,
handleB: (e: ServiceBEvent) => void
): ((e: RouterEvent) => void) => {
return (event: RouterEvent) => {
switch (event.source) {
case 'service-a': return handleA(extractA(event));
case 'service-b': return handleB(extractB(event));
}
};
};

Discriminated Unions Are Coproducts

TypeScript discriminated unions are coproducts4—untagged unions that overlap (like 1 | number) collapse their injections and lose the universal property:

// Coproduct type
type OrderEvent =
| { type: 'created'; order: Order }
| { type: 'updated'; orderId: string; changes: Partial<Order> }
| { type: 'cancelled'; orderId: string; reason: string };
// Injections
const created = (order: Order): OrderEvent =>
({ type: 'created', order });
const updated = (orderId: string, changes: Partial<Order>): OrderEvent =>
({ type: 'updated', orderId, changes });
// Universal property: pattern matching
const handleOrderEvent = (event: OrderEvent): void => {
switch (event.type) {
case 'created': handleCreated(event.order); break;
case 'updated': handleUpdated(event.orderId, event.changes); break;
case 'cancelled': handleCancelled(event.orderId, event.reason); break;
}
};

Products and Coproducts Together

Real architectures use both:

Request-Response (Product-like)

Client Request → Gateway (Product)
[Service A, Service B, Service C]
Aggregated Response

Event-Driven (Coproduct-like)

[Service A, Service B, Service C]
Event Bus (Coproduct)
Consumer

The Full Pattern

Products (combine)
Producers → Event Bus (Coproduct) → Gateway (Product) → Consumers
[Service A, B, C]

AWS Products and Coproducts

API Gateway as Product

# API Gateway defines projections to multiple backends
paths:
/users/{id}:
get:
x-amazon-apigateway-integration:
uri: arn:aws:lambda:...:user-service # π₁
/orders/{id}:
get:
x-amazon-apigateway-integration:
uri: arn:aws:lambda:...:order-service # π₂
/payments/{id}:
get:
x-amazon-apigateway-integration:
uri: arn:aws:lambda:...:payment-service # π₃

The gateway is the product; each route is a projection.

(A single request taking one path is actually the dual reading—selecting one backend is copairing on the request side. The product view is about the gateway as a whole: it holds a projection to every backend, and an aggregating client uses several at once.)

EventBridge as Coproduct

Multiple sources inject into a single bus:5

// Multiple sources inject into EventBridge
await eventBridge.putEvents({
Entries: [
{ Source: 'orders', DetailType: 'OrderCreated', Detail: '...' }, // ι₁
]
});
await eventBridge.putEvents({
Entries: [
{ Source: 'payments', DetailType: 'PaymentReceived', Detail: '...' }, // ι₂
]
});
// Rules define the copairing [f, g]
// Rule 1: orders.* → ProcessingLambda
// Rule 2: payments.* → AnalyticsLambda

Step Functions: Both

Step Functions combine products and coproducts:6

{
"StartAt": "Parallel",
"States": {
"Parallel": {
"Type": "Parallel", // Product: run all branches
"Branches": [
{ "StartAt": "BranchA", "States": {...} },
{ "StartAt": "BranchB", "States": {...} }
],
"ResultPath": "$.results", // keep the input's $.type; branch outputs land here
"Next": "Choice"
},
"Choice": {
"Type": "Choice", // Coproduct: choose one path
"Choices": [
{ "Variable": "$.type", "StringEquals": "A", "Next": "HandleA" },
{ "Variable": "$.type", "StringEquals": "B", "Next": "HandleB" }
],
"Default": "HandleOther"
}
}
}

Universal Properties as Design Principles

The Product Principle

Any way to access multiple services should factor through a single gateway.

Violations:

  • Clients directly calling multiple backends
  • Multiple gateways with overlapping responsibilities
  • Inconsistent authentication across services

The Coproduct Principle

Any way to produce events should inject through a single bus.

Violations:

  • Services publishing to multiple different buses
  • Point-to-point event connections
  • Inconsistent event schemas across sources

Designing with Universal Properties

Step 1: Identify What You’re Combining

Products when you need:

  • All of multiple things together
  • Aggregation of data from multiple sources
  • Single entry point to multiple backends

Coproducts when you need:

  • Choice between alternatives
  • Multiple sources feeding one destination
  • Tagged unions of different event types

Step 2: Define the Projections/Injections

// Product: define how to extract each component
interface OrderGateway {
getUserData(req: GatewayRequest): UserRequest; // π₁
getOrderData(req: GatewayRequest): OrderRequest; // π₂
getPaymentData(req: GatewayRequest): PaymentRequest; // π₃
}
// Coproduct: define how to inject each variant
interface EventBus {
fromOrderService(event: OrderEvent): BusEvent; // ι₁
fromUserService(event: UserEvent): BusEvent; // ι₂
fromPaymentService(event: PaymentEvent): BusEvent; // ι₃
}

Step 3: Verify the Universal Property

// Product: any combination factors uniquely
const anyClientRequest = (
needsUser: ClientNeedsUser,
needsOrder: ClientNeedsOrder
): GatewayRequest => {
// This factorization must be UNIQUE
return combineIntoGatewayRequest(needsUser, needsOrder);
};
// Coproduct: any handler factors uniquely
const anyEventHandler = (
handleOrder: (e: OrderEvent) => void,
handleUser: (e: UserEvent) => void
): ((e: BusEvent) => void) => {
// This copairing must be UNIQUE
return (event) => {
if (isOrderEvent(event)) handleOrder(event);
else if (isUserEvent(event)) handleUser(event);
};
};

When Products and Coproducts Don’t Exist

Not every category has all products and coproducts.

Missing Products

If your architecture can’t aggregate, products don’t exist:

// Can't create product: no way to combine results
const serviceA = async (): Promise<DataA> => { /* ... */ };
const serviceB = async (): Promise<DataB> => { /* ... */ };
// If services can't be called together (mutual exclusion, rate limits),
// the product doesn't exist

Missing Coproducts

If your architecture can’t choose, coproducts don’t exist:

// Can't create coproduct: no unified event type
interface OrderEvent { orderId: string; /* order fields */ }
interface UserEvent { userId: string; /* user fields */ }
// If there's no common event bus that can carry both,
// the coproduct doesn't exist

Architectural Implications

  • Missing product: Can’t build aggregation gateway → need to change service contracts
  • Missing coproduct: Can’t build event bus → need unified event schema

The Takeaway

Products and coproducts are the fundamental building blocks:

  1. Product = Combination: Gateway pattern, aggregation, “give me all of these”
  2. Coproduct = Choice: Event router, unions, “give me one of these”
  3. Universal property = Best solution: All other approaches factor through these
  4. Design check: Can you factor through your gateway/router uniquely?

When designing aggregation → think products. When designing routing → think coproducts.

The universal properties tell you exactly what your design must satisfy.


Next in the series: Pullbacks and Pushouts: Integration Points That Don’t Lie — Where we learn how to construct integration points that preserve consistency.


Footnotes

Footnotes

  1. Universal properties are fundamental to category theory and define constructions uniquely “up to isomorphism.” A construction with a universal property is the “best” or “most efficient” solution—all other solutions must factor through it. This concept appears throughout mathematics: products, limits, free objects, and adjunctions all have universal properties. In architecture, recognizing universal properties helps identify canonical patterns that all other designs must ultimately reference.

  2. The API Gateway pattern has become a cornerstone of microservices architecture. Amazon API Gateway, Kong, Nginx, and similar tools all implement the product construction: they provide a single entry point (the product object) with routes/projections to various backend services. The universal property manifests as the requirement that any client access must factor through the gateway’s routing logic—you can’t bypass it while maintaining the same access patterns and guarantees (authentication, rate limiting, monitoring).

  3. Duality is a pervasive concept in category theory: for any construction in a category, you can obtain its dual by reversing all arrows. The product and coproduct are perfect duals—the product has arrows pointing out (projections), while the coproduct has arrows pointing in (injections). Other dual pairs include: monomorphisms/epimorphisms, limits/colimits, and pullbacks/pushouts (covered in Part 9). In architecture, understanding duality helps you recognize that aggregation patterns (products) and routing patterns (coproducts) are fundamentally related.

  4. This connection between union types and coproducts is formalized in the Curry-Howard-Lambek correspondence, which relates type theory, logic, and category theory. In typed functional programming languages (Haskell, OCaml, TypeScript with discriminated unions), sum types directly implement categorical coproducts. Pattern matching implements the universal property of the coproduct—it’s the unique way to handle all cases. Languages without proper sum types (classic Java, C) lack this direct categorical structure.

  5. Amazon EventBridge is AWS’s serverless event bus service that implements the coproduct pattern at scale. Multiple event sources (AWS services, custom applications, SaaS providers) inject events into the bus, and EventBridge rules route events to targets. The service handles the copairing function [f, g] through its rule engine. EventBridge supports events from over 200 AWS services plus SaaS and custom sources; throughput is governed by per-Region quotas (the PutEvents default is in the thousands of events per second, raisable on request). See: AWS EventBridge documentation.

  6. AWS Step Functions’ state machine language (Amazon States Language) explicitly supports both product and coproduct constructions. The “Parallel” state type runs multiple branches concurrently and combines results (product), while the “Choice” state type selects one execution path based on conditions (coproduct). This makes Step Functions particularly powerful for orchestrating complex workflows that require both aggregation and conditional routing. The Map state also implements a product over dynamic collections.

Share this post

Comments

Favorite Books

Links are Amazon affiliate links.