I wrote a previous post about applying these EIPs concretely on Kubernetes for AI orchestration. That post answers “how”; this one is the “why you should care” piece that should have come before it.

Philip Greenspun, writing about Lisp in the mid-1990s, formulated what came to be called his Tenth Rule: any sufficiently complicated C or Fortran program contains an ad-hoc, informally specified, bug-ridden, slow implementation of half of Common Lisp. His point has aged well. Programs grow; the things they need to express grow with them; absent a richer substrate, the missing pieces get reinvented in the language at hand, typically badly.

The same thing happens, with equal regularity, to integration code. Any sufficiently complicated integration pipeline contains an ad-hoc, informally specified, bug-ridden, slow implementation of half of the Enterprise Integration Patterns. It shows up in codebases that have grown past trivial: aggregators that hang, splitters that drop items on the floor, idempotent receivers that aren’t, content-based routers that grow teeth as feature flags accumulate. The engineers who built them reached for what was at hand: a for-loop, a Redis key, an if/elif chain, applied to a problem that looked manageable at the time. The patterns are still there. They just have local names like process_batch, dedup_webhook, and handle_event, and they share their failure modes.

What follows is four patterns I run into often. They are not the most sophisticated patterns in the catalogue. They are the most universally reinvented, and so the ones whose handcrafted versions tend to be quietly broken in the same handful of ways. I want to walk through each of them as the code that probably exists in your repository today, and then sketch what the pattern looks like.

Aggregator#

You have a workflow that fans out: ten parallel API calls, twenty database queries against shards, a hundred image renders dispatched to a GPU pool. Eventually you need to collect the results and continue. Somewhere in your codebase there is a function that knows how to wait for them.

The first version of that function uses a counter. It increments on each callback; when the counter reaches the expected total, it triggers the next step. This works on the happy path. It also fails in three specific ways, each of which I have personally watched happen in production.

The first failure is that one of the ten calls never returns. The counter sits at nine forever. Sometimes there is no timeout because nobody thought about it; sometimes there is a timeout, but it lives in the calling code rather than in the collector, and the collector has no idea that the request was abandoned upstream. The workflow stalls silently, and discovery happens when a customer notices.

The second failure is that two callbacks arrive simultaneously and the counter increments are not atomic. The expected total is ten, the counter goes from eight to nine twice instead of nine and then ten, and the workflow either fires twice or never fires at all. The fix everyone reaches for is a lock, which works until somebody adds a third callback path that doesn’t take the lock.

The third failure is correlation. The collector is shared across multiple in-flight workflows, distinguished by some kind of identifier extracted from the response. The identifier comes from a field that is unique most of the time. The day a duplicate ID slips through, results from one workflow get attached to another and the bug reads as “the system produced the wrong answer once, we couldn’t reproduce it, we moved on.”

The pattern name is Aggregator. The pattern as catalogued has three explicit moving parts: a correlation strategy that tells you which messages belong together, a release strategy that tells you when a group is complete (count reached, timeout elapsed, sequence-number gap closed, predicate satisfied), and a message store that holds in-flight groups durably so the system doesn’t lose them when the process restarts. The handcrafted version usually has the correlation strategy as an implicit hash-map key, the release strategy as a single equality check on a counter, and no message store at all. The bugs are not accidents; they are the consequences of conflating three things into one.

Splitter#

The mirror image. You have a request that arrives as a batch: one webhook containing fifty records, one upload containing a CSV, one orchestration step producing a list of work items. The downstream processing expects a single item at a time. Somewhere in your codebase there is a function that loops over the batch and calls the per-item handler.

The first failure here is partial completion on error. Item seventeen raises an exception. The for-loop has a try/except at the wrong scope: either the whole batch aborts and items eighteen through fifty are silently dropped, or the exception is swallowed at the item level and the calling code thinks the whole batch succeeded. Both modes happen routinely, and both produce the same symptom from the outside, which is “we processed a thousand records and seventeen of them just vanished.”

The second failure is ordering assumptions. The downstream handler is written, intentionally or not, in a way that depends on items being processed in order. Six months later somebody parallelizes the loop to improve throughput, and a race condition that only fires when two items happen to land on adjacent workers becomes the most-paged-on alert of the quarter.

The third failure is loss of identity. Each item travels downstream as if it stood alone, which is fine until something needs to reassemble them. There is no sequence number, no total, no parent correlation ID. When you eventually need to know “did all fifty items finish?”, you cannot tell, because the only place that knew “fifty” was the for-loop, and the for-loop has long since returned.

The pattern name is Splitter, and the rule it follows is precise: take one message, emit N messages, and stamp each output with a sequence number, a sequence size, and a correlation ID inherited from the parent. Those three headers are the contract. They are what lets an Aggregator downstream reassemble the work. They are what lets a partial failure be reported as “items 12, 17, and 34 failed” rather than “something went wrong.” They are what lets the loop be parallelized later without breaking anyone’s order assumptions, because the order is now explicit in the headers rather than implicit in the iteration. The handcrafted for-loop omits all three. They feel unnecessary when the loop has two cases and like overengineering when it has twenty.

Idempotent Receiver#

You receive webhooks. Webhooks retry. Sometimes they retry because your handler returned a 504 after successfully completing the work; sometimes because of a transient network blip on the sender’s side; sometimes because the sender is just aggressive. The same logical event arrives more than once. Your handler must do the work exactly once anyway.

The version of this in your codebase almost certainly involves a shared-memory provider, such as Redis. The handler hashes some field of the payload, checks whether a key with that hash exists, sets the key with a TTL if not, then proceeds. There are at least three things wrong with this, and which ones bite depends on luck.

The TTL is shorter than the processing time. The first call comes in, sets the key, takes nine minutes to finish. The retry comes in at minute ten, finds no key, processes again. The vendor gets paid twice.

The hash is computed from the wrong thing. The natural choice is something stable in the payload, like the webhook ID or a timestamp. But the webhook ID is unique to the delivery attempt, not the business event. The third retry of the same logical payment carries a different webhook ID and therefore a different hash, so the dedup check passes and the work runs again. The vendor gets paid again, for the same business event.

The check and the claim are not atomic. Two retries arrive within milliseconds of each other. Both workers check, both find the key missing, both set the key, both proceed. Redis SETNX would have prevented this; ordinary GET-then-SET did not. Two workers proceed; the work runs twice.

The pattern name is Idempotent Receiver, and it makes the structure explicit: there is a key that names the business action (not the delivery attempt), a durable store that holds claimed keys persistently (not a cache with a guessed TTL), and an atomic check-and-claim primitive that admits exactly one winner per key. None of these are exotic. They are the things the handcrafted version skips because they feel like overkill, until the day they don’t.

Content-Based Router#

The fourth pattern is the one that grows teeth slowest, which makes it the hardest to see coming. You have an event handler. Events come in different shapes; the handler dispatches by type. Day one it is a clean if/elif on two cases. Day three hundred it is fourteen branches, three nested switch statements, a default that silently drops anything unrecognized, and a comment from someone who has since left the team that says // TODO: fix priority ordering before adding more types.

The failures here are less dramatic than the others, which is part of why they accumulate. The unknown-type silent drop is the classic: a new event type is added upstream, the routing code is not updated, the default branch swallows it, and nobody notices for weeks because the symptom is “this category of event seems quieter than expected.” Schema drift hides inside the if/elif: somebody renames a field, the predicates that branched on it now never match, and the events all funnel into the wrong handler. Routing and processing logic share a function, which means testing the routing requires mocking everything the handlers depend on, which means the routing is never tested in isolation, which means the bugs in it are found in production.

The pattern name is Content-Based Router. The discipline it enforces is small but real. The routing logic is a declarative table of predicates to destinations. There is an explicit default channel, and the question of whether the default is “drop silently,” “log and drop,” “route to dead-letter,” or “raise an exception” is one a human had to answer when writing the route, rather than one that fell out of which case the if/elif happened to land on. The handlers live in their own modules, behind named channels, addressable by the channel name. Tests for the router exercise the predicates against representative payloads without touching the handlers. None of that is complicated, and none of it feels necessary when the route has two branches and both seem trivial.

The Point#

None of this is an argument that you need to rewrite your codebase in a JVM ESB. The point Greenspun was making about Lisp was never that everyone should be writing Lisp; it was that the patterns Lisp made first-class were going to show up anyway, and the choice was whether to recognize them or to keep reinventing them with the bugs included.

We already apply this logic elsewhere. No production codebase contains a hand-rolled binary search; that problem is solved, the solution is named, and reimplementing it is a reasonable interview exercise and an unreasonable engineering decision. The patterns in this catalogue occupy the same position. An Aggregator is not a framework convenience; it is the solved form of collecting asynchronous results correctly, in the same way that binary search is the solved form of lookup in a sorted collection. The argument for the pattern is not that it is easier to write. It is that it carries a name, a known structure, and documented failure modes — and the handcrafted version carries none of those things.

AI-assisted development makes this worse, not better. When you ask a model to write a function that collects results from parallel API calls, you get the counter-based version with the race condition and the missing timeout. Not because the model is hallucinating — because that is the median implementation in the training data, which is composed of exactly the kind of ad-hoc, informally specified, bug-ridden code Greenspun was describing. The model has learned from the corpus of handcrafted patterns, bugs included, and it reproduces them faithfully. Naming the pattern changes the prompt and changes the output: “implement an Aggregator with an explicit correlation strategy, a count-based release strategy, and a durable message store” produces something structurally different from “wait for all the results.” The vocabulary is leverage, and it works on models the same way it works on engineers.

The same is true here. The Enterprise Integration Patterns are not a framework. They are a vocabulary for the things you have already built, and a description of the failure modes that show up when you build them by hand. You can express them in XML through Spring Integration, in YAML through Benthos, in code through a thousand other libraries, or in a custom Rust DSL of your own design. You can also keep building them by hand; for small, stable systems that is often a defensible decision. What you cannot do is opt out of the patterns themselves. If your pipeline collects asynchronous results, you have an Aggregator. The only question is whether yours is the version with explicit correlation and release strategies, or the version where the counter sometimes gets to nine and stops.

The pattern names cost effort to learn. The ad-hoc implementation costs effort to debug. The bill comes due either way.