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 CombinationA pullback of morphisms and is an object with morphisms and such that:
With the universal property:1 for any object with morphisms and satisfying , there exists a unique morphism such that and .
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.”
Pullbacks in Architecture
Section titled Pullbacks in ArchitectureThe Consistent Read Pattern
Section titled The Consistent Read PatternYou have two services that both reference the same data:
// Order Serviceinterface Order { orderId: string; userId: string; // References User items: Item[];}
// Shipment Serviceinterface 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 Userinterface 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 };};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.
The API Versioning Pullback
Section titled The API Versioning PullbackTwo API versions must be compatible:
// V1 and V2 both map to internal representationinterface 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 ordertype 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 IdentificationA pushout is dual to a pullback.2 For morphisms and , the pushout satisfies:
With the universal property: for any object with morphisms and agreeing on (that is, ), there’s a unique morphism with and .
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₂ = qBThe pushout is “A and B glued together along their common part C.”
Pushouts in Architecture
Section titled Pushouts in ArchitectureEvent Aggregation
Section titled Event AggregationTwo services produce events with a common structure:
// Common event structureinterface BaseEvent { eventId: string; timestamp: Date; correlationId: string;}
// Order events extend baseinterface OrderEvent extends BaseEvent { type: 'order.created' | 'order.updated'; orderId: string; data: OrderData;}
// Payment events extend baseinterface PaymentEvent extends BaseEvent { type: 'payment.received' | 'payment.failed'; paymentId: string; data: PaymentData;}
// The coproduct: a unified stream of disjoint variantstype UnifiedEvent = OrderEvent | PaymentEvent;
// The pushout: quotient the coproduct by shared correlationId —// correlate is what performs the identificationconst 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());};Schema Merge
Section titled Schema MergeMerging two schemas that share common fields:3
// Shared fieldsinterface CommonFields { id: string; createdAt: Date; updatedAt: Date;}
// Schema Ainterface SchemaA extends CommonFields { name: string; category: string;}
// Schema Binterface 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 Products and Sums
Section titled Fiber Products and SumsFiber Product (Pullback) in Databases
Section titled Fiber Product (Pullback) in DatabasesThe fiber product over a common key is a pullback:4
-- Orders and Shipments, fibered over userIdSELECT o.*, s.*FROM orders oJOIN shipments s ON o.user_id = s.user_id-- This IS a pullback: pairs that agree on user_idFiber Sum (Pushout) in Event Sourcing
Section titled Fiber Sum (Pushout) in Event SourcingEvents from multiple sources, identified by correlation:
// Multiple sources produce events with correlationIddeclare const orderEvents: Stream<OrderEvent>;declare const paymentEvents: Stream<PaymentEvent>;
// Pushout: merge identifying by correlationIdconst mergedStream = merge(orderEvents, paymentEvents) .groupBy(e => e.correlationId); // IdentificationAWS Pullbacks and Pushouts
Section titled AWS Pullbacks and PushoutsS3 + DynamoDB Consistency (Pullback)
Section titled S3 + DynamoDB Consistency (Pullback)When S3 object and DynamoDB record must agree:5
// S3 has object metadatainterface S3Object { key: string; ETag: string; // SDK casing lastModified: Date;}
// DynamoDB has recordinterface 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 agreeconst 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 patternconst ruleAPattern = { ...basePattern, detail: { status: ['created'] }};
// Rule B patternconst 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 rulesStep 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 PracticePullback: “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;}Designing Integration Points
Section titled Designing Integration PointsWhen to Use Pullbacks
Section titled When to Use PullbacksUse 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 servicesconst 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 };};When to Use Pushouts
Section titled When to Use PushoutsUse pushouts when:
- Multiple sources feed into a unified view
- Common structures should be identified
- You’re merging schemas or events
// Pattern: unified event streamconst 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);};Consistency vs. Availability
Section titled Consistency vs. AvailabilityPullbacks make explicit the consistency requirements:6
// Strict pullback: both must be consistent NOWconst 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 consistentconst 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 effortconst optimisticPullback = async () => { const a = await getA(); const b = await getB(); return { a, b, consistent: consistent(a, b), timestamp: Date.now() };};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.
The Takeaway
Section titled The TakeawayPullbacks and pushouts handle constrained integration:
- Pullback = Consistent Join: Combine where sources agree
- Pushout = Identified Merge: Merge where sources share structure
- Universal property: The best (most general) solution
- 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.
Footnotes
Section titled FootnotesFootnotes
Section titled Footnotes-
The pullback is also called the fiber product (or fibered product), written . In the category Set, it has a concrete description: — 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 is the terminal object, every pair trivially agrees, and the pullback collapses to the plain product . ↩
-
The pushout is also called the amalgamated sum or fibered coproduct, written . In Set, it’s the disjoint union quotiented by identifying for every — “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. ↩
-
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 , the branches are and , 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. ↩
-
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. ↩
-
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
TransactWriteItemshelps within DynamoDB, but the S3-DynamoDB pullback must be verified (or designed around) by you. ↩ -
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). ↩