en ~ 15 min read ~
Adjunctions: The Formal Structure of Trade-offs
Share this post
This article is part 11 of a 11-part series: Categorical Solutions Architecture
See the full series navigation at the end of this article.
“An adjunction is a pair of functors that are ‘almost inverses’ of each other, in a precise sense.”
You’ve felt this tension: more flexibility means less governance; more governance means less flexibility. More consistency means less availability. These feel like problems to solve, but they’re not—they’re mathematical dualities. Adjunctions formalize this structure, showing why certain trade-offs are necessary and how to navigate them optimally.
What Is an Adjunction?
Section titled What Is an Adjunction?An adjunction between categories and consists of:1
- A left adjoint functor
- A right adjoint functor
- A natural bijection:
We write (“F is left adjoint to G”).
The Intuition
Section titled The Intuition- is “free”—it constructs with minimal constraints
- is “forgetful”—it observes while losing structure
- The bijection says: ways to use F’s output = ways to map into G’s output
The Canonical Example: Free and Forgetful
Section titled The Canonical Example: Free and ForgetfulSets ↔ Monoids
Section titled Sets ↔ MonoidsSets ⟵(U)---(F)⟶ Monoids G F- : Free monoid (generates strings from a set)
- : Forgetful functor (just sees the underlying set)
The adjunction says:
In words: Giving a monoid homomorphism from the free monoid is the same as choosing where generators go.2
Code Translation
Section titled Code Translation// The set: a typetype Generators = 'a' | 'b' | 'c';
// Free monoid: words over an arbitrary set Stype FreeMonoid<S> = S[];
// A target monoidinterface Monoid<M> { empty: M; combine: (a: M, b: M) => M;}
// Right adjoint: forgetful, just sees the carriertype Forget<M> = M;
// The adjunction: homomorphisms from Free ≅ functions to carrierconst adjunction = <M>( monoid: Monoid<M>, assignment: (g: Generators) => M // Function S → U(M)): ((free: FreeMonoid<Generators>) => M) => { // Homomorphism F(S) → M return (free: FreeMonoid<Generators>) => free.reduce((acc, g) => monoid.combine(acc, assignment(g)), monoid.empty);};Adjunctions in Architecture
Section titled Adjunctions in ArchitectureFree ↔ Constrained
Section titled Free ↔ ConstrainedMany architectural patterns have adjoint structure:3
Flexible System ⟵(Make Flexible)---(Observe)⟶ Constrained SystemOne warning before the table, because it’s easy to get wrong: the systems below are not adjoints. Functors are adjoints; systems are just objects. Read the columns as the two sides of the correspondence—shorthand for where each functor lands. And watch which side is which: in Sets ↔ Monoids, the free functor lands on the more-structured side (monoids), while the forgetful functor lands on the less-structured side (sets). Architectural “flexibility” usually names the less-structured side—the forgetful functor’s home turf, not the free functor’s.
| Less-structured side (forgetful functor lands here) | More-structured side (free functor lands here) |
|---|---|
| Sets | Monoids |
| Schema-less (NoSQL) | Schema-enforced (RDBMS) |
| Dynamic typing | Static typing |
| Event-driven | Request-response |
| Microservices | Monolith |
The Trade-off Bijection
Section titled The Trade-off BijectionRead the central formula again, carefully, because it’s easy to get backwards:
This is a statement of equivalence, not scarcity. The bijection says the two descriptions are interchangeable: every way of using the free construction corresponds to exactly one way of mapping into the forgetful view, and the correspondence is natural—you can translate back and forth for free, as often as you like.
So where does the trade-off live? In the unit and counit, which are not invertible. Going from into , the free construction adds structure you never specified. Going from back to , the forgetful view has already discarded structure you had. The adjunction organizes free translation between two perspectives; what each direction must add or forget is where the cost lives.
Consistency ↔ Availability
Section titled Consistency ↔ AvailabilityIt’s tempting to say the CAP theorem is an adjunction in disguise. Resist the temptation—it isn’t one, and seeing why sharpens both ideas.
CAP is an impossibility theorem: under a network partition, no system can offer both strong consistency and full availability.4 An adjunction is the opposite kind of statement—a correspondence. If a hom-set bijection related consistent-side specifications to available-side specifications, it would say the two specs carry equivalent information. That’s the opposite of impossibility. CAP itself is not the bijection, and no amount of squinting makes it one.
What can be adjunction-flavored is the pair of translations between the two worlds. Take categories of systems annotated with their consistency guarantees, and a pair of functors between them:
Available Systems ⟵(Weaken)---(Strengthen)⟶ Consistent Systems G F- : Strengthen—add the coordination required to meet a consistency contract
- : Weaken—forget guarantees, keep the behavior
If these form an adjunction, the correspondence reads:
That’s a claim about translating specifications—not a way around CAP. The trade-off lives where it always does: strengthening adds coordination (and its latency) you never asked for; weakening discards guarantees you were relying on. And CAP remains standing outside the whole construction, as the hard limit on what any strengthening can deliver during a partition.
// Consistent systeminterface ConsistentDB<T> { read(): Promise<{ value: T; version: number }>; write(value: T, expectedVersion: number): Promise<boolean>; // CAS}
// Available system (weakened view)interface AvailableDB<T> { read(): Promise<T>; // May be stale write(value: T): Promise<void>; // Always succeeds locally}
// The adjunction-flavored claim: programming against ConsistentDB<T>// corresponds to programming against AvailableDB<T> plus explicit// conflict resolution—the part that Strengthen has to addCAP Is Not an Adjunction
The CAP theorem says: under partition, you must choose C or A. That’s an impossibility result about systems. The adjunction-shaped structure here is the strengthen ⊣ weaken pair of translations—and naturality means you always have both descriptions available, freely interchangeable. Keep the two ideas separate.
Performance ↔ Cost
Section titled Performance ↔ CostAnother fundamental adjunction:
High Performance ⟵(Scale Up)---(Reduce)⟶ Low Cost F GThe correspondence:
Same shape as before: this is translation, not scarcity. A design phrased against the scaled-up system can be re-read, exactly, as a design phrased against its reduced view. The trade-off sits in the unit and counit—scaling up provisions capacity you didn’t specify; reducing forgets headroom you had.
AWS Example
Section titled AWS Example// High performance (provisioned)const provisionedDynamoDB = { readCapacityUnits: 10000, writeCapacityUnits: 5000, // Predictable latency, higher cost};
// Low cost (on-demand)const onDemandDynamoDB = { billingMode: 'PAY_PER_REQUEST', // Variable latency, pay per use};
// Adjunction: provisioned capacity guarantees are equivalent to// on-demand costs under specific load patternsThe Unit and Counit
Section titled The Unit and CounitEvery adjunction has two special natural transformations:
“Embedding into the free construction”
// Unit: embed a set into its free monoidconst unit = <S>(s: S): FreeMonoid<S> => [s];
// Architecturally: embed simple request into flexible systemconst embedRequest = (simpleReq: SimpleRequest): FlexibleRequest => ({ ...simpleReq, metadata: {}, options: defaults,});Counit:
Section titled Counit: ϵ:F∘G⇒IdD\epsilon: F \circ G \Rightarrow \text{Id}_{\mathcal{D}}ϵ:F∘G⇒IdD“Evaluating the free construction”
// Counit: evaluate free monoid in target monoidconst counit = <M>( monoid: Monoid<M>, free: FreeMonoid<M>): M => free.reduce((acc, m) => monoid.combine(acc, m), monoid.empty);
// Architecturally: evaluate flexible request in constrained systemconst evaluateInConstrained = ( system: ConstrainedSystem, flexReq: FlexibleRequest): ConstrainedResponse => system.handle(restrictToSchema(flexReq));The Triangle Identities
Section titled The Triangle IdentitiesThe unit and counit satisfy:
Meaning: Not that round-tripping through both functors gets you back where you started—it doesn’t. The free monoid on is nothing like ; if the round trips were identities, we’d have an equivalence of categories, not an adjunction. The triangle identities say something narrower: inserting the unit–counit zig-zag on the image of or is a no-op. is the identity, and so is .
Adjunctions Generate Monads
Section titled Adjunctions Generate MonadsEvery adjunction generates a monad on .5
// The monad from Free ⊣ Forgetfultype FreeMonad<S> = FreeMonoid<S>; // G(F(S))
// unit: S → G(F(S))const pure = <S>(s: S): FreeMonad<S> => [s];
// join: G(F(G(F(S)))) → G(F(S))const flatten = <S>(nested: FreeMonad<FreeMonad<S>>): FreeMonad<S> => nested.flat();This is why monads appear everywhere in programming—they arise from adjunctions, and adjunctions model trade-offs.
Navigating Adjunctions
Section titled Navigating AdjunctionsStrategy 1: Choose a Side
Section titled Strategy 1: Choose a SidePick left or right and commit:
// Commit to the flexible sideconst flexibleChoice = 'microservices';// Accept: more operational complexity, coordination costs
// Commit to the constrained sideconst constrainedChoice = 'monolith';// Accept: less flexibility, tighter couplingStrategy 2: Use the Bijection
Section titled Strategy 2: Use the BijectionWork on the easier side, transport results:6
// Problem: design complex consistent transactions// Easier: design available operations + conflict resolution
// Work in available spaceconst operations = designAvailableOps();
// Transport to consistent space via adjunctionconst transactions = composeWithConflictResolution(operations);Strategy 3: Move Along the Adjunction
Section titled Strategy 3: Move Along the AdjunctionUse unit/counit to transition:
// Start in simple categoryconst simpleDesign: SimpleSystem = { /* ... */ };
// Apply unit: embed into flexibleconst flexibleDesign = unit(simpleDesign);
// Modify in flexible spaceconst modified = enhance(flexibleDesign);
// Apply counit: evaluate back in constrainedconst constrainedResult = counit(targetSystem, modified);AWS Adjunctions
Section titled AWS AdjunctionsLambda ↔ ECS
Section titled Lambda ↔ ECSLambda (Flexible) ⟵(Extract)---(Containerize)⟶ ECS (Controlled)- Left: Lambda—flexible scaling, pay-per-invocation
- Right: ECS—controlled resources, predictable behavior
The correspondence, honestly stated: if Containerize ⊣ Extract holds, ways of using a containerized workload correspond to ways of mapping into its extracted, Lambda-shaped view—a bijection of hom-sets, natural in both arguments. It is not an object-level bijection between Lambda configurations and ECS task definitions; that would be an equivalence, and none exists (an ECS task exceeding Lambda’s limits has no Lambda counterpart).
DynamoDB ↔ RDS
Section titled DynamoDB ↔ RDSDynamoDB (Flexible Schema) ⟵(Relax)---(Normalize)⟶ RDS (Rigid Schema)- Left: DynamoDB—schema flexibility, denormalized
- Right: RDS—schema enforcement, normalized
The correspondence, honestly stated: if Normalize ⊣ Relax holds, ways of using the normalized schema correspond to ways of mapping access patterns into its relaxed view—hom-sets again, not a one-to-one pairing of schemas or queries.
SNS ↔ SQS
Section titled SNS ↔ SQSSNS (Pub/Sub) ⟵(Broadcast)---(Buffer)⟶ SQS (Queue)- Left: SNS—immediate fanout, fire-and-forget
- Right: SQS—buffered, guaranteed delivery
The correspondence, honestly stated: if Buffer ⊣ Broadcast holds, ways of consuming the buffered topic correspond to ways of mapping into the broadcast view of the queue—a hom-set correspondence, not a claim that every consumer setup has a subscription twin.
Detecting Adjunctions
Section titled Detecting AdjunctionsSigns you have an adjunction:
1. A Correspondence Between Ways of Mapping
Section titled 1. A Correspondence Between Ways of Mapping“Every way of using one construction matches a way of mapping into the other”—a correspondence of hom-sets, not a one-to-one pairing of the systems themselves
GraphQL queries ↔ REST endpoint compositionsEvent handlers ↔ Request handlersDeclarative configs ↔ Imperative scripts2. Trade-off Tension
Section titled 2. Trade-off Tension“We can’t have both fully”
Flexibility ↔ GovernancePerformance ↔ CostSimplicity ↔ PowerLatency ↔ Throughput3. Canonical Translations
Section titled 3. Canonical Translations“There’s a natural way to go from A to B”—but be precise about what those translations are. A REST-to-GraphQL translation is the action of the functor itself, carrying objects across categories. It is not the unit: lives entirely inside , comparing each object with its round-trip image , and lives entirely inside .
// The action of F: translate across categoriesdeclare const restToGraphQL: (endpoint: RestEndpoint) => GraphQLResolver;
// The action of G: translate backdeclare const graphqlToRest: (resolver: GraphQLResolver) => RestEndpoint;
// The unit stays INSIDE the REST category:// η: endpoint → graphqlToRest(restToGraphQL(endpoint))The Adjunction Test
Careful with the tempting phrasing “natural embeddings both ways”—that describes a section–retraction pair or an equivalence, not an adjunction. The adjunction signature is asymmetric: two translations, a natural correspondence between maps out of one construction and maps into the other, and round trips (unit and counit) that are systematic but not invertible.
The Takeaway
Section titled The TakeawayAdjunctions organize architectural trade-offs:
- Left adjoint (free): constructive—lands on the more-structured side, adding structure you didn’t specify
- Right adjoint (forgetful): observational—lands on the less-structured side, discarding structure you had
- Hom-set correspondence: maps out of the free construction match maps into the forgetful view—two interchangeable descriptions of the same information
- Unit/Counit: the non-invertible round trips—where the actual trade-off lives
When you feel tension between flexibility and constraint:
- Ask whether a pair of translations gives it adjoint structure
- Use the correspondence to work on the easier side
- Remember the cost sits in the unit and counit, not in the bijection
Trade-offs aren’t problems—they’re structure. Navigate them, don’t fight them.
Next in the series: Monads: Cross-Cutting Concerns That Actually Compose — Where we learn how error handling, logging, and effects can be structured categorically.
Footnotes
Section titled FootnotesFootnotes
Section titled Footnotes-
Adjoint functors were introduced by Daniel Kan in “Adjoint Functors” (Transactions of the American Mathematical Society, 1958), motivated by his work in homotopy theory, where the pattern kept recurring between constructions like product and function-space—what programmers know as currying. The definition can be given two equivalent ways: by the natural hom-set bijection used in this post, or by a unit and counit satisfying the triangle identities. The name is borrowed from linear algebra, where adjoint operators satisfy —the hom-set bijection is the categorical echo of that inner-product identity, with Hom playing the role of the pairing. ↩
-
The free ⊣ forgetful pair is the paradigm adjunction. The free monoid on a set is the set of finite words over under concatenation, and its universal property is exactly the bijection in the text: any function extends to a unique homomorphism . The same pattern recurs across algebra—free groups, free rings, free vector spaces on a basis, free categories on a graph—each a left adjoint to the evident forgetful functor. This is why “free” is a technical term rather than a vibe: freely generated means no equations hold beyond those the structure itself forces. ↩
-
Saunders Mac Lane’s Categories for the Working Mathematician (Springer, 1971; second edition 1998) declares in its preface that “adjoint functors arise everywhere,” and the book is organized to prove the slogan—limits, colimits, monads, and Kan extensions are all developed as facets of adjunction. Mac Lane, who founded category theory with Samuel Eilenberg in the 1940s, treated the adjunction as the concept the subject had been converging toward all along. If one idea justifies category theory to a practitioner, this is the usual nominee—which is exactly why it deserves to be quoted accurately rather than stretched. ↩
-
The CAP theorem began as Eric Brewer’s conjecture in his PODC 2000 keynote 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). Gilbert and Lynch’s result is an impossibility theorem: in an asynchronous network subject to partitions, no read/write object can guarantee both linearizable consistency and a response to every request. That logical shape—“nothing with both properties exists”—is precisely what an adjunction never asserts; an adjunction asserts that two hom-sets are in natural bijection. Part 9 of this series used the same theorem to classify pullback strategies; here it marks the boundary that no strengthen ⊣ weaken translation can cross. ↩
-
That every adjunction induces a monad was known early; the converse—that every monad arises from some adjunction—was proved independently in 1965 by Heinrich Kleisli (“Every Standard Construction Is Induced by a Pair of Adjoint Functors,” Proceedings of the American Mathematical Society) and by Samuel Eilenberg and John C. Moore (“Adjoint Functors and Triples,” Illinois Journal of Mathematics). The two constructions bracket the possibilities: the Kleisli category is the smallest adjunction generating a given monad, the Eilenberg–Moore category of algebras the largest. At the time monads were called “standard constructions” or “triples”; the modern name stuck through Mac Lane. When Eugenio Moggi later connected monads to computational effects in “Notions of Computation and Monads” (Information and Computation, 1991), this 1965 machinery became the mathematical backbone of effectful functional programming. ↩
-
“Work on the easier side and transport the results” is backed by a real theorem: right adjoints preserve limits (RAPL), and dually, left adjoints preserve colimits—see Mac Lane, Categories for the Working Mathematician, Chapter V. Concretely: the underlying set of a product of monoids is the product of the underlying sets (the forgetful functor, a right adjoint, preserves products), and the free monoid on a disjoint union is the coproduct of the free monoids (the free functor, a left adjoint, preserves coproducts). Freyd’s adjoint functor theorem gives a partial converse: a limit-preserving functor satisfying a solution-set condition is a right adjoint. For an architect, this is the payoff of identifying adjoint structure—preservation guarantees you get for free instead of verifying case by case. ↩