Part 2 of the IVR design series. Read Part 1: Why Your IVR Is Broken Before It’s Built.
Separating the Journey from the Architecture
Part 1 diagnosed the failure. The design document reads like prose but behaves like a pile of goto statements, and by the time anyone starts building, the damage is already baked in. The fix is not a better document. The fix is a separation.
The core move is to split two concerns that the traditional design document fuses together. On one side you have the business requirements and the customer journey, the overlay that says what the caller experiences and why. On the other side you have the architectural decisions, the structural machinery that determines how calls are routed and how state is resolved. When these two live in the same document, the journey leaks into the structure and the structure distorts the journey. Every business change forces an architectural change, and every architectural shortcut quietly rewrites the journey.
To hold that separation in place I use an architectural pattern I call the dispatch pattern. Before the pattern itself makes sense, it rests on one simple idea: a specificity hierarchy.
The Specificity Hierarchy
A specificity hierarchy is a strict ordering of rules from most specific to most general. When more than one rule could apply to a given call, the most specific rule wins. Nothing else competes for the decision. This is the same principle that makes CSS selectors resolve predictably and that makes routing tables in a network stack deterministic. The more precisely a rule describes the situation in front of it, the higher its claim to decide the outcome.
The value of this ordering is that it removes ambiguity from the routing decision without requiring anyone to write nested conditionals. Instead of a shared block asking a cascade of questions about the caller, each rule declares up front exactly how specific it is. A rule that matches one product line for one customer tier during one promotional window is more specific than a rule that matches the whole product line, which is in turn more specific than a rule that matches every caller as a fallback. The system does not need to reason about how these overlap. It only needs to sort them and pick the narrowest match.
This is what lets the dispatch pattern keep the journey and the architecture apart. The business gets to add, remove, and reorder rules in terms of specificity, describing who should get what and under which conditions. The architecture never changes to accommodate them. A new rule is just a new entry at the correct level of specificity, not a new branch carved into an already overloaded block. The overloaded shared block with its internal conditionals, the thing Part 1 identified as the real source of the spider web, never gets a chance to form, because there is no shared block accumulating special cases. There is only a sorted set of rules and a dispatcher that honors the order.
IVRs Decompose Into a Few Logical Blocks
Think about how almost any IVR actually works, underneath the specific prompts. It falls into a small number of logical blocks. There is a welcome block, where the caller is greeted, told the call may be recorded, and checked against any active disruption such as a hurricane in Texas or a system outage. There is an authentication block, where the caller is identified. There is a main menu, where intent is captured and routed. And there is end of call handling, where the call is closed out. Those four blocks cover the overwhelming majority of the structure.
The normal IVR design ignores this structure. It is organized prompt first, along the customer journey. The designer starts from what the caller hears and chains it forward with goto statements. You hear the welcome prompt, then goto hours of operation. After hours of operation, goto authentication. Authentication needs to check the clock again, so goto back to hours of operation. Each of these gotos is written to satisfy one path through one requirement, and because the requirements share blocks, the arrows start crossing. That crossing is the spider web from Part 1.
The specificity hierarchy attacks the problem at the point where requirements enter the design. Instead of translating each requirement into a prompt and a goto, we sort each requirement into the block it belongs to. The recording disclosure and the disaster handling are both welcome concerns, so they go into the welcome block. Authentication requirements go into the authentication block. Nothing is wired to anything else yet. At this stage the blocks are simply separate containers of requirements, sorted by function rather than by the order a caller would encounter them.
The Entry Flow Is a Loop, Not a Path
Sorting requirements into blocks gives you clean containers, but a caller still has to move through them in some order. That order is exactly where the traditional design reached for goto statements. The dispatch pattern does not. Instead of a path, the entry flow is a loop.
The loop is deliberately empty of business logic. It does one thing. On each pass it calls out to external code and asks what to do next. On Amazon Connect that external code is a Lambda function. The Lambda is not told what the flow is. Nothing about the sequence is hardcoded, not in the loop and not in the Lambda. The sequence lives entirely in a set of rules, and the Lambda’s only job is to consult those rules against the current state of the call and answer one question: what runs next.
Walk a single pass. The loop calls the Lambda. The Lambda looks at the call history and sees that the welcome block has never run. The rules say welcome comes before anything else, so the Lambda answers: run the welcome block. The loop dispatches to the welcome module, and every welcome concern plays, the greeting, the recording disclosure, the disruption check. When the module finishes, an exit block sets state to record that welcome ran and succeeded, then hands control back to the main loop.
The loop asks again. This time the Lambda sees welcome in the history and consults the rules for what follows it. After welcome comes hours of operation, so the Lambda answers with the hours of operation module, and the loop dispatches to it. The same pattern repeats down the entire call. The loop never knows the sequence. It only knows how to ask, dispatch, and record. Each block runs as a separate module with a defined entry and a defined exit, which means each one can be tested in isolation, and the order they run in is governed by the rules rather than wired into the flow.

An Obvious Pattern, Rarely Seen
If you are a software developer, none of this looks clever. Loose coupling, a dispatcher, modules behind a stable interface, these are moves you would reach for without thinking. That is part of the point. The dispatch pattern is less an invention than the application of ordinary software discipline to a corner of the industry that has not received much of it.
Which makes the notable part not the pattern but its absence. I have worked across different domains in the IVR space and with a number of consulting companies, and outside of my own work I have not seen anyone build IVRs this way. That is only an observation and not a survey, but it has held everywhere I have been.
What You Get for Free
Once the pattern is in place, a set of properties that developers normally spend real effort to achieve simply fall out of it. The modules are loosely coupled, because no module names another. They are highly cohesive, because each one owns a single block of the call. And you get DRY almost by accident, because each piece of behavior lives in exactly one module and is dispatched wherever it is needed rather than copied into every path that needs it. These are not things you add on top. They are consequences of the structure.
The property that changes how a team actually works is parallel development. Because each module is independent, different people can build them at the same time. One developer takes hours of operation while another takes end of call handling, and neither waits on the other. In Amazon Connect this shows up cleanly at publish time. End of call handling might start as a module that just hangs up, then get republished later with a warm transfer in it. Nothing else in the flow has to change, and nobody was blocked while it happened. Each module moves forward on its own schedule. The payoff is that IVRs built this way come together faster and cleaner, which is not a phrase the IVR world gets to use very often.
Modules You Can Actually Test
The same separation that lets modules be built in parallel also lets them be tested in isolation, which is something the traditional flow makes almost impossible. In Amazon Connect you build a small test flow and point a phone number at a single module. Dial it and you exercise just the welcome module and nothing else. Repoint the number and you can drive a back office call on its own. You will have to spoof some of the inputs a real call would have carried in, but that is a small price for being able to check one block at a time instead of the whole system at once.
Take an authentication module built on a REST call. You set up a test flow that hardcodes the input values, makes the Lambda call to the web service, gets the response back and writes it to a log. Then you read the Lambda logs and see plainly whether it succeeded or failed and what came back. That cycle is far faster than calling the IVR from the top and navigating all the way down every time you want to see whether one endpoint behaved.
It matters more because IVRs today are mostly data driven. When you need to check how an endpoint handles a range of inputs, a test flow lets you hardcode them one at a time, running each and confirming that the right fields come back and the values look the way they should. You are testing the integration the way the IVR will actually make it, one controlled case after another.
None of this replaces testing the Lambda on its own. You can exercise a Lambda function from the command line or through other tooling, and you should. But those tests live outside the IVR structure. They tell you the function works, not that the module works as the IVR invokes it. The test flow closes that gap. Between the two you can validate the code ad hoc on the command line and validate its behavior inside the IVR, which together give you far more confidence than either gives alone.
Specificity in Action: Rechecking Hours Before a Transfer
Everything so far has shown the rules sequencing a call in a straight line. Welcome is not in the history, so run welcome. Welcome is in the history, so move on. That is ordering, and ordering by itself does not need a hierarchy. Hours of operation is the case that does, because it is the place where the same module has to run twice for two different reasons, and a plain history check cannot express that.
Start with the first check. The rule is simple: if hours of operation is not in the history, run it. A caller dials in at one minute to five, the center closes at five, the check passes because the office is still open, and the call proceeds into authentication and the menu. Hours of operation is now in the history, and the general rule that governs every module says a module already in the history does not run again.
Now the caller reaches a transfer. Time has passed. It is 5:02. The office closed while they were working through the IVR, and the history still says hours of operation passed, because it did, three minutes ago. Transferring now would drop the caller into a queue with nobody in it. The call needs to check the clock a second time, and the general history rule is actively working against that.
This is where specificity does the work the hierarchy promised. You add one more rule, narrower than the general one: when the call is about to transfer and the last hours of operation check is stale, run hours of operation again. At the transfer point two rules now apply. The general rule says the module is in the history and should not rerun. The specific rule says transfer plus a stale check means rerun it. The specific rule is narrower, so it wins, and hours of operation runs a second time. This time the answer is different. It is after hours, and that result flows into the rules that follow: do not transfer and hand the call to the closed handling instead of the live queue.
Notice what did not happen. Nobody duplicated the hours of operation logic and nobody drew an arrow back into a shared block. In a prompt first design the second check is exactly the special case that overloads that block and starts the spider web, because the only way to reach the logic again is to wire another path into it. Here the same module answered both times, the opening check and the pre transfer recheck, and the difference between running once and running twice was a single rule sitting at the right level of specificity.
The Engine Is Reusable, Only the Rules Change
Once you have built the dispatcher and the modules and tested them against a rule set, the code itself stops being client specific. The loop, the Lambda that consults the rules, the individual modules, none of that changes from one engagement to the next. What changes is the rules, and the rules are not code. They are data, typically YAML files sitting in an S3 bucket.
That separation matters most if you are running a consulting shop. A new client does not mean rebuilding the dispatcher or rewriting the modules from scratch. It means writing a new set of rules against the same engine, and reusing whatever modules already fit. The welcome module, the authentication module, hours of operation, these are the same shape everywhere even when the specific business logic inside them differs, and the engine that dispatches them does not care which client it is running for.
It also changes how fast you can respond to a change once a system is live. Say you realize partway through a build that you need a module nobody had planned for. In a prompt first design that means going back into Connect and modifying flows, rewiring gotos, and retesting the paths that touch them. Here it means adding a rule and testing it. The rule set is just a file. You edit it, you validate it against the module you are adding, and you deploy it. The architecture never has to be touched to accommodate a change that, in the old design, would have meant reopening the flow itself.
