📝 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 and is an object together with projection morphisms:
With the universal property:1 for any object with morphisms and , there exists a unique morphism such that:
C / | \ f ⟨f,g⟩ g / | \ v v v A <--π₁-- A × B --π₂--> B
π₁ ∘ ⟨f,g⟩ = f and π₂ ∘ ⟨f,g⟩ = gProducts in Architecture
The API Gateway Pattern
An API Gateway is a product construction:2
Client -----> Gateway -----> Service A | +---------> Service B | +---------> Service CThe gateway () has projections (routes) to each backend. Any client request factors uniquely through the gateway.
// The projectionsconst 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 gatewayconst 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>;}
// Projectionsdeclare 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 gatewayconst 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 and , the coproduct has injection morphisms:
With the universal property: for any object with morphisms and , there exists a unique morphism such that:
A --ι₁--> A + B <--ι₂-- B \ | / f [f,g] g \ | / v v v C
[f,g] ∘ ι₁ = f and [f,g] ∘ ι₂ = gCoproducts in Architecture
The Event Router Pattern
An event router is a coproduct construction:
Service A -----> Event Router -----> ConsumerService B -----> | |Service C -----> +------------> AnalyticsMultiple sources inject events into a common bus, which then distributes to consumers.
// The injectionsconst 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 routerconst 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 typetype OrderEvent = | { type: 'created'; order: Order } | { type: 'updated'; orderId: string; changes: Partial<Order> } | { type: 'cancelled'; orderId: string; reason: string };
// Injectionsconst created = (order: Order): OrderEvent => ({ type: 'created', order });
const updated = (orderId: string, changes: Partial<Order>): OrderEvent => ({ type: 'updated', orderId, changes });
// Universal property: pattern matchingconst 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 ResponseEvent-Driven (Coproduct-like)
[Service A, Service B, Service C] ↓ Event Bus (Coproduct) ↓ ConsumerThe 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 backendspaths: /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 EventBridgeawait 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.* → AnalyticsLambdaStep 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 componentinterface OrderGateway { getUserData(req: GatewayRequest): UserRequest; // π₁ getOrderData(req: GatewayRequest): OrderRequest; // π₂ getPaymentData(req: GatewayRequest): PaymentRequest; // π₃}
// Coproduct: define how to inject each variantinterface EventBus { fromOrderService(event: OrderEvent): BusEvent; // ι₁ fromUserService(event: UserEvent): BusEvent; // ι₂ fromPaymentService(event: PaymentEvent): BusEvent; // ι₃}Step 3: Verify the Universal Property
// Product: any combination factors uniquelyconst anyClientRequest = ( needsUser: ClientNeedsUser, needsOrder: ClientNeedsOrder): GatewayRequest => { // This factorization must be UNIQUE return combineIntoGatewayRequest(needsUser, needsOrder);};
// Coproduct: any handler factors uniquelyconst 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 resultsconst serviceA = async (): Promise<DataA> => { /* ... */ };const serviceB = async (): Promise<DataB> => { /* ... */ };
// If services can't be called together (mutual exclusion, rate limits),// the product doesn't existMissing Coproducts
If your architecture can’t choose, coproducts don’t exist:
// Can't create coproduct: no unified event typeinterface 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 existArchitectural 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:
- Product = Combination: Gateway pattern, aggregation, “give me all of these”
- Coproduct = Choice: Event router, unions, “give me one of these”
- Universal property = Best solution: All other approaches factor through these
- 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
-
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. ↩
-
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). ↩
-
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. ↩
-
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. ↩
-
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. ↩
-
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. ↩