Skip to main content

en ~ 13 min read ~

Pullbacks and Pushouts: Integration Points That Don't Lie

Share this post

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

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

“A pullback is a product that respects constraints. A pushout is a coproduct that identifies commonalities.”

Products combine everything; coproducts offer choices. But what if you need to combine things subject to constraints? What if your sources share something that must be identified? Pullbacks and pushouts handle these cases—they’re the universal constructions for constrained integration.

The Pullback: Constrained Combination

Section titled The Pullback: Constrained Combination

A pullback of morphisms f:ACf: A \to C and g:BCg: B \to C is an object PP with morphisms p1:PAp_1: P \to A and p2:PBp_2: P \to B such that:

fp1=gp2f \circ p_1 = g \circ p_2

With the universal property:1 for any object QQ with morphisms q1:QAq_1: Q \to A and q2:QBq_2: Q \to B satisfying fq1=gq2f \circ q_1 = g \circ q_2, there exists a unique morphism u:QPu: Q \to P such that p1u=q1p_1 \circ u = q_1 and p2u=q2p_2 \circ u = q_2.

Q ----------q₁---------.
| \ v
| u --> P ---p₁---> A
| | |
q₂ p₂ f
| v v
'-------> B ----g---> C
f ∘ p₁ = g ∘ p₂; u is the unique map with p₁ ∘ u = q₁ and p₂ ∘ u = q₂

The pullback is “the most general object that projects to both A and B consistently with respect to C.”


You have two services that both reference the same data:

// Order Service
interface Order {
orderId: string;
userId: string; // References User
items: Item[];
}
// Shipment Service
interface Shipment {
shipmentId: string;
userId: string; // Also references User
address: Address;
}
// User Service (the common reference)
interface User {
userId: string;
name: string;
address: Address;
}

A pullback ensures consistency:

// Pullback: Orders and Shipments that agree on the User
interface ConsistentOrderShipment {
order: Order;
shipment: Shipment;
// Constraint: order.userId === shipment.userId
// AND both reference the SAME user state
}
const getConsistentView = async (
orderId: string,
shipmentId: string
): Promise<ConsistentOrderShipment | null> => {
const order = await orderService.get(orderId);
const shipment = await shipmentService.get(shipmentId);
// The pullback condition
if (order.userId !== shipment.userId) {
return null; // Doesn't satisfy constraint
}
// Verify both see the same user
const userFromOrder = await userService.get(order.userId);
const userFromShipment = await userService.get(shipment.userId);
if (!deepEqual(userFromOrder, userFromShipment)) {
return null; // Inconsistent!
}
return { order, shipment };
};
Tip:

Pullback = Consistent Join

A pullback is like a database JOIN, but with a guarantee: the joined data is consistent. Both sides agree on the shared reference.

Two API versions must be compatible:

// V1 and V2 both map to internal representation
interface InternalOrder { /* canonical form */ }
interface V1Order {
order_id: string;
line_items: V1Item[];
}
interface V2Order {
orderId: string;
items: V2Item[];
metadata: Metadata;
}
// Pullback: V1 and V2 orders that represent the same internal order
type CompatibleVersions = {
v1: V1Order;
v2: V2Order;
// Constraint: toInternal(v1) === toInternal(v2)
};
const isCompatible = (v1: V1Order, v2: V2Order): boolean => {
return deepEqual(toInternal(v1), toInternal(v2));
};

The Pushout: Merging with Identification

Section titled The Pushout: Merging with Identification

A pushout is dual to a pullback.2 For morphisms f:CAf: C \to A and g:CBg: C \to B, the pushout PP satisfies:

i1f=i2gi_1 \circ f = i_2 \circ g

With the universal property: for any object QQ with morphisms qA:AQq_A: A \to Q and qB:BQq_B: B \to Q agreeing on CC (that is, qAf=qBgq_A \circ f = q_B \circ g), there’s a unique morphism u:PQu: P \to Q with ui1=qAu \circ i_1 = q_A and ui2=qBu \circ i_2 = q_B.

C ----f----> A
| | \
g i₁ qA
| | \
v v v
B ---i₂---> P ---u--> Q
\ ^
\________qB________/
u is the unique map with u ∘ i₁ = qA and u ∘ i₂ = qB

The pushout is “A and B glued together along their common part C.”


Two services produce events with a common structure:

// Common event structure
interface BaseEvent {
eventId: string;
timestamp: Date;
correlationId: string;
}
// Order events extend base
interface OrderEvent extends BaseEvent {
type: 'order.created' | 'order.updated';
orderId: string;
data: OrderData;
}
// Payment events extend base
interface PaymentEvent extends BaseEvent {
type: 'payment.received' | 'payment.failed';
paymentId: string;
data: PaymentData;
}
// The coproduct: a unified stream of disjoint variants
type UnifiedEvent = OrderEvent | PaymentEvent;
// The pushout: quotient the coproduct by shared correlationId —
// correlate is what performs the identification
const correlate = (events: UnifiedEvent[]): CorrelatedEventGroup[] => {
const groups = new Map<string, UnifiedEvent[]>();
for (const event of events) {
const key = event.correlationId; // The shared "C"
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(event);
}
return Array.from(groups.values());
};

Merging two schemas that share common fields:3

// Shared fields
interface CommonFields {
id: string;
createdAt: Date;
updatedAt: Date;
}
// Schema A
interface SchemaA extends CommonFields {
name: string;
category: string;
}
// Schema B
interface SchemaB extends CommonFields {
title: string;
tags: string[];
}
// Merged schema identifying the common fields.
// (A lossy TS encoding: the true schema pushout keeps the union of
// fields *required* — at the value level it's SchemaA | SchemaB
// glued along CommonFields, not an everything-optional record.)
interface MergedSchema {
// Common fields (identified)
id: string;
createdAt: Date;
updatedAt: Date;
// From A
name?: string;
category?: string;
// From B
title?: string;
tags?: string[];
}

Fiber Product (Pullback) in Databases

Section titled Fiber Product (Pullback) in Databases

The fiber product over a common key is a pullback:4

-- Orders and Shipments, fibered over userId
SELECT o.*, s.*
FROM orders o
JOIN shipments s ON o.user_id = s.user_id
-- This IS a pullback: pairs that agree on user_id

Fiber Sum (Pushout) in Event Sourcing

Section titled Fiber Sum (Pushout) in Event Sourcing

Events from multiple sources, identified by correlation:

// Multiple sources produce events with correlationId
declare const orderEvents: Stream<OrderEvent>;
declare const paymentEvents: Stream<PaymentEvent>;
// Pushout: merge identifying by correlationId
const mergedStream = merge(orderEvents, paymentEvents)
.groupBy(e => e.correlationId); // Identification

S3 + DynamoDB Consistency (Pullback)

Section titled S3 + DynamoDB Consistency (Pullback)

When S3 object and DynamoDB record must agree:5

// S3 has object metadata
interface S3Object {
key: string;
ETag: string; // SDK casing
lastModified: Date;
}
// DynamoDB has record
interface DDBRecord {
pk: string; // Same as S3 key
etag: string; // Copy of the S3 ETag written alongside the record
status: string;
metadata: object;
}
// Pullback: S3 and DynamoDB views that agree
const getConsistentState = async (key: string): Promise<ConsistentState | null> => {
const [s3Obj, ddbRecord] = await Promise.all([
s3.headObject({ Bucket: bucket, Key: key }),
ddb.get({ TableName: table, Key: { pk: key } })
]);
// Verify consistency
if (s3Obj.ETag !== ddbRecord.Item?.etag) {
return null; // Inconsistent!
}
return { s3: s3Obj, ddb: ddbRecord.Item };
};

EventBridge Rule Composition (Pushout)

Section titled EventBridge Rule Composition (Pushout)

Multiple rules that share event patterns:

// Base pattern (common structure C)
const basePattern = {
source: ['my-app'],
'detail-type': ['order.*']
};
// Rule A pattern
const ruleAPattern = {
...basePattern,
detail: { status: ['created'] }
};
// Rule B pattern
const ruleBPattern = {
...basePattern,
detail: { priority: ['high'] }
};
// Pushout: combined routing that identifies common pattern
// The event enters the bus once, but each matching rule fires
// independently — EventBridge does not deduplicate across rules

Step Functions Parallel + Join (Pullback)

Section titled Step Functions Parallel + Join (Pullback)
{
"Parallel": {
"Type": "Parallel",
"Branches": [
{ "StartAt": "FetchUser", "States": { /* ... */ } },
{ "StartAt": "FetchOrders", "States": { /* ... */ } }
],
"Next": "JoinResults"
},
"JoinResults": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:join",
"Comment": "Pullback: combine results that agree on userId"
}
}

The Universal Property in Practice

Section titled The Universal Property in Practice

Pullback: “Best Consistent Combination”

Section titled Pullback: “Best Consistent Combination”
// For any Q that consistently maps to A and B via C,
// there's a unique way to factor through the pullback P
interface Pullback<A, B, C> {
p: { a: A; b: B }; // The pullback object
verifyConsistency: (a: A, b: B) => boolean;
// Universal property
factor: <Q>(
qa: (q: Q) => A,
qb: (q: Q) => B,
consistent: (q: Q) => boolean // qa(q) and qb(q) agree on C
) => (q: Q) => { a: A; b: B };
}

Pushout: “Best Unified Merge”

Section titled Pushout: “Best Unified Merge”
// For any Q that accepts both A and B and identifies along C,
// there's a unique way to factor through the pushout P
interface Pushout<A, B, C, P> {
// P is roughly A | B, with the shared C identified
inject: {
fromA: (a: A) => P;
fromB: (b: B) => P;
};
// Universal property
factor: <Q>(
qa: (a: A) => Q,
qb: (b: B) => Q
// requires: qa ∘ f = qb ∘ g — both agree on C
) => (p: P) => Q;
}

Use pullbacks when:

  • Two data sources must agree on a shared reference
  • You need to join with consistency guarantees
  • Constraints must be verified at integration time
// Pattern: consistent read across services
const pullbackPattern = async <A, B, C>(
getA: () => Promise<A>,
getB: () => Promise<B>,
toC: { fromA: (a: A) => C; fromB: (b: B) => C }
): Promise<{ a: A; b: B } | 'inconsistent'> => {
const [a, b] = await Promise.all([getA(), getB()]);
if (!deepEqual(toC.fromA(a), toC.fromB(b))) {
return 'inconsistent';
}
return { a, b };
};

Use pushouts when:

  • Multiple sources feed into a unified view
  • Common structures should be identified
  • You’re merging schemas or events
// Pattern: unified event stream
const pushoutPattern = <Base, A extends Base, B extends Base>(
streamA: AsyncIterable<A>,
streamB: AsyncIterable<B>,
identify: (base: Base) => string
): AsyncIterable<A | B> => {
// merge gives the coproduct; the pushout identification happens
// when downstream groups by identify(event)
return merge(streamA, streamB);
};

Pullbacks make explicit the consistency requirements:6

// Strict pullback: both must be consistent NOW
const strictPullback = async () => {
const a = await getA();
const b = await getB();
if (!consistent(a, b)) throw new Error('Inconsistent');
return { a, b };
};
// Eventual pullback: retry until consistent
const eventualPullback = async () => {
while (true) {
const a = await getA();
const b = await getB();
if (consistent(a, b)) return { a, b };
await delay(100); // Wait for consistency
}
};
// Optimistic pullback: return best effort
const optimisticPullback = async () => {
const a = await getA();
const b = await getB();
return {
a,
b,
consistent: consistent(a, b),
timestamp: Date.now()
};
};
Note:

CAP Through Pullbacks

The CAP theorem manifests in pullbacks: you can have strong consistency (strict pullback) or availability (optimistic pullback), but not both under partition.


Pullbacks and pushouts handle constrained integration:

  1. Pullback = Consistent Join: Combine where sources agree
  2. Pushout = Identified Merge: Merge where sources share structure
  3. Universal property: The best (most general) solution
  4. Consistency trade-offs: Pullbacks make CAP visible

When integrating services that share references → use pullback thinking. When merging sources with common structure → use pushout thinking.

The universal property tells you: your integration is correct if everything factors through it.


Next in the series: Limits and Colimits: The General Theory of “Best Fit” — Where we generalize products, coproducts, pullbacks, and pushouts into a unified framework.


  1. The pullback is also called the fiber product (or fibered product), written A×CBA \times_C B. In the category Set, it has a concrete description: A×CB={(a,b)A×Bf(a)=g(b)}A \times_C B = \{(a, b) \in A \times B \mid f(a) = g(b)\} — the subset of the full product that satisfies the constraint. This makes the slogan precise: a pullback is a product filtered by agreement. Products (Part 8) are actually a special case — when CC is the terminal object, every pair trivially agrees, and the pullback collapses to the plain product A×BA \times B.

  2. The pushout is also called the amalgamated sum or fibered coproduct, written A+CBA +_C B. In Set, it’s the disjoint union ABA \sqcup B quotiented by identifying f(c)g(c)f(c) \sim g(c) for every cCc \in C — “glue A and B along C.” Topology uses exactly this construction to build spaces by gluing along a shared boundary. The duality with pullbacks continues the theme from Part 8: reverse every arrow in the pullback square and you obtain the pushout square, just as reversing the product’s projections yields the coproduct’s injections.

  3. Merging along a shared base is also the shape of version control. A three-way merge — two branches diverging from a common ancestor — is a pushout: the ancestor is CC, the branches are AA and BB, and the merge result glues them along what they share. This is formalized in Mimram & Di Giusto, “A Categorical Theory of Patches” (Electronic Notes in Theoretical Computer Science, 2013) — a post-hoc categorical formalization of Darcs-style patch theory, and the foundation Pijul builds on. One honest caveat from that paper: the category of files and patches does not have all pushouts — merge conflicts are exactly the cases where the pushout fails to exist, which is why the construction lives in a free cocompletion. Schema merges, CRDTs, and event-stream unification all follow the same pattern: identify the common part, then glue — when the glue exists.

  4. The correspondence between relational joins and pullbacks is made rigorous in categorical database theory, most notably in David Spivak’s work on functorial data migration — see Spivak, “Functorial Data Migration” (Information and Computation, 2012) and Category Theory for the Sciences (MIT Press, 2014). An inner equi-join computes precisely the fiber product of the two tables over the shared key column. This line of research grew into the categorical query language CQL, which uses these constructions to guarantee that data migrations preserve integrity constraints.

  5. Amazon S3 has delivered strong read-after-write consistency for all objects since December 2020, and DynamoDB offers strongly consistent reads and condition expressions within a single table. But there are no cross-service transactions between S3 and DynamoDB — each service’s guarantees stop at its own boundary. That’s exactly why the pullback check in the code is an application-level concern: the consistency of the pair is a property neither service can enforce alone. DynamoDB’s TransactWriteItems helps within DynamoDB, but the S3-DynamoDB pullback must be verified (or designed around) by you.

  6. The CAP theorem began as Eric Brewer’s conjecture (PODC keynote, 2000) and was formalized and proved by Seth Gilbert and Nancy Lynch in “Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services” (ACM SIGACT News, 2002). The three pullback variants in the code map directly onto the trade-off: the strict pullback chooses consistency over availability (CP), the optimistic pullback chooses availability (AP), and the eventual pullback trades latency for consistency — the extension Daniel Abadi captures in the PACELC formulation (IEEE Computer, 2012).

Share this post

Comments

Favorite Books

Links are Amazon affiliate links.