Designing a Telephony IVR System (and Why It’s Harder Than It Looks)
Building an IVR system often sounds deceptively simple: play a few prompts, collect user input, route the call. In reality, IVR systems tend to grow into some of the most complex and fragile parts of a telephony platform. This article walks through how we learned that lesson the hard way, using Azure Communication Services (ACS) as an example, and how introducing the right design patterns helped us regain control.
When I first started working on this cool new thing of an AI-driven telephony customer service, the energy was high, iterations were fast and ideas were flowing, and our goal was ambitious: replace rigid, old-school menu-driven IVR’s with something flexible, conversational and intelligent.
Then, our client gave us a reality check.
At a time where our AI solution was already built — changed our direction temporarily: start with a strict, traditional IVR, and then move to AI.
As painful as it was for the team to admit, pushing AI straight into production for a first rollout wasn’t the safest bet. So we reset expectations, swallowed our pride, and committed to implementing a full IVR layer inside our existing codebase.
Initially, it looked almost disappointing. IVR felt basic. Too basic. The kind of thing you expect to knock out quickly without breaking a sweat.
That confidence didn’t last.
As soon as real requirements started coming in, the call flow began to sprawl. Menu options led to sub-menus. Edge cases multiplied. What looked like a simple decision tree grew deeper and wider than expected.
Before we knew it, the IVR logic had become one of the most complex parts of the system. Visual representation below:
This made us realize how tightly coupled and rigid our codebase really was (ironically!).
The implementation relied on the Azure Communication Services (ACS) Python SDK to handle inbound calls to ACS‑assigned phone numbers. Everything was driven by call events and their associated operation contexts. For example, a FirstNameInputContext indicating that we were waiting for a caller’s first name.
This worked, but it came at a cost.
Every new modification to the call flow required revisiting significant portions of the code. It was necessary to update several event handlers, rework call contexts, and threading changes through the system to avoid regressions. Adding a single option often meant revisiting large portions of the codebase. Over time, the system became harder to reason about, harder to test, and riskier to change.
That was our cue to pause and rethink the approach.
The system needed to be much more flexible and decoupled if it was to evolve. This required us to revisit the fundamentals, including the overall design, the division of labour, and the codebase’s change management. From there, we identified a set of design patterns we decided to introduce:
State Design Pattern
Each IVR menu or step acts as a state, with well‑defined transitions based on caller input or system events. This maps naturally to IVR systems, which are inherently state‑driven.
Adapter Pattern
We decoupled our core call‑flow logic from the telephony provider itself. By introducing an adapter layer, the system could support providers like Twilio or Vonage without rewriting application logic.
A State‑Based IVR Workflow Example
Below is a simplified example of how we modeled an IVR workflow using a state‑based configuration:
This approach makes several things explicit:
- Menu‑driven state transitionsz
- Clear handling of DTMF input
- Separation between actions (tasks) and flow control (states)
- Easy extensibility without touching existing logic
Adding a new option or flow becomes a configuration change rather than a risky code rewrite.
Architectural Shift
Previously, IVR logic was deeply intertwined with call event handlers and provider‑specific details. Over time, this coupling made the system brittle and resistant to change.
With the new design, responsibilities are clearly separated:
- Call‑flow logic lives in a provider‑agnostic workflow layer
- Telephony providers are abstracted behind adapters
- State transitions are explicit and testable
The result is a system that is easier to reason about, safer to extend, and far more tolerant of changing requirements.
Key Takeaways
- IVR systems are state machines, whether you model them that way or not
- “Simple” call flows tend to grow quickly and unpredictably
- Decoupling providers early prevents painful rewrites later
- AI works best on top of a stable, well‑designed IVR foundation, not instead of one
Designing IVR systems may not be glamorous, but getting the fundamentals right pays off every time requirements change. And they always do