Beyond Request-Response: Architecting Stateful Agentic Chatbots with the Command and State Patterns
Building chatbots with monolithic webhooks leads to messy if/else chains that are hard to maintain and scale. Use the Command Pattern and the State Pattern.
Join the DZone community and get the full member experience.
Join For FreeThe promise of conversational AI is immense. We envision intelligent bots on platforms like WhatsApp, Slack, and Microsoft Teams that can do more than just answer simple questions — they can guide users through complex workflows, conduct interactive training sessions, and provide personalized, multi-step support. However, the reality of building these sophisticated agents often collides with a fundamental constraint of the web: the stateless soul of HTTP.
Every message a user sends arrives at our backend as a discrete, independent request. The server, by default, has no memory of the previous ten messages. This presents a significant architectural challenge. How do we build a bot that can remember it’s in the middle of a quiz, guide a user through a multi-part lesson, or handle any conversation that requires context?
The most common initial approach — a single, monolithic webhook function with a sprawling if/elif/else block — is a well-known path to technical debt. This design quickly becomes a tangled, unmaintainable monolith that violates core software engineering principles.
In this article, we will architect a robust, scalable solution from first principles. We will explore how to combine two classic design patterns — the Command Pattern for managing discrete, stateless actions and the State Pattern for orchestrating complex, stateful conversations. Using a real-world AI learning bot as our case study, we will provide a practical blueprint for building the next generation of truly interactive chatbots.
The Anti-Pattern: The Monolithic Webhook
At its core, a chatbot backend is a webhook: an API endpoint that receives data. A naive implementation in a framework like Django might start simply enough.
# A naive, monolithic approach that quickly becomes unmaintainable
def whatsapp_webhook(request):
body = request.POST.get("Body", "").strip().lower()
user_phone = request.POST.get("From")
if body == "/help":
# Logic to format and send a help message...
send_help_message(user_phone)
elif body == "/get-subjects":
# Logic to query the database and send a list of subjects...
send_subjects(user_phone)
elif body.startswith("/enroll-subject"):
# Logic to parse the subject ID and enroll the user...
enroll_user(user_phone, body)
# ... imagine 20 more elif statements for other commands ...
else:
send_unknown_command_message(user_phone)
return HttpResponse("OK")
While functional for a handful of commands, this design suffers from several critical flaws:
- High Coupling: The webhook's routing logic is inextricably tied to the implementation of every single action. A change in how subjects are fetched could require a change in this central function.
- Poor Maintainability: As new commands are added, the conditional block grows, making the code difficult to read, debug, and manage.
- Violation of the Open/Closed Principle: This function is not closed for modification. Every new feature forces a developer to modify its core logic, increasing the risk of introducing bugs.
- Mixed Concerns: The code mixes concerns that should be separate: request parsing, command routing, and business logic execution are all jumbled together.
To build a professional-grade system, we must first dismantle this monolith.
The First Layer of Sanity: The Command Pattern for Stateless Actions
Our first step is to bring order to the chaos of simple, one-shot actions like /help, /get-subjects, or /create-account. For this, we employ the Command Pattern.
The Command Pattern is a behavioral design pattern that turns a request into a stand-alone object. This object encapsulates everything needed to execute the action, decoupling the invoker of the action (our webhook) from the object that performs the action (our business logic).
The key components in our implementation are:
- Command Interface: An Abstract Base Class (ABC) in Python that defines a common
execute()method. - Concrete Commands: Classes that implement the
Commandinterface for specific actions. - Receivers: Classes that contain the actual business logic (e.g., interacting with the database).
- Invoker: Our webhook, whose only job is to route the request to the correct command object.
First, we define our Command interface and a Receiver to hold our business logic.
# commands.py (The Interface)
from abc import ABC, abstractmethod
class Command(ABC):
@abstractmethod
def execute(self) -> str: # We'll have it return the response message
pass
# receivers.py (The Business Logic)
class SubjectReceiver:
def get_subjects(self):
# Logic to query the database for all available subjects...
return Subject.objects.all()
Next, we create Concrete Command classes. Notice how a simple command like HelpCommand might not even need a Receiver.
# commands.py (Concrete Commands)
class GetSubjectsCommand(Command):
def __init__(self, to_number):
self.to_number = to_number
def execute(self) -> str:
receiver = SubjectReceiver()
subjects = receiver.get_subjects()
# Formatting logic to create a user-friendly message...
return self.format_subjects_message(subjects)
class HelpCommand(Command):
def __init__(self, to_number):
self.to_number = to_number
def execute(self) -> str:
# This command's logic is simple enough to live within the command itself.
return "Welcome to our bot! Available commands are..."
Finally, we create a Command Registry and refactor our webhook to act as a clean Invoker.
# registry.py
COMMAND_REGISTRY = {
"/help": HelpCommand,
"/get-subjects": GetSubjectsCommand,
# ... all other stateless commands
}
# views.py (The Refactored Invoker)
class WhatsAppWebhook(generics.GenericAPIView):
def post(self, request: Request):
# ... parsing logic to get user, phone, and body ...
command_name = body.lower().split(" ")[0]
CommandClass = COMMAND_REGISTRY.get(command_name)
if CommandClass:
# Logic to parse additional arguments if needed...
command_instance = CommandClass(to_number=user_phone)
response_message = command_instance.execute()
send_whatsapp_message(user_phone, response_message)
else:
# Handle unknown command...
return Response("OK")
This architecture is a significant improvement. Our webhook is now decoupled and adheres to the Open/Closed Principle. Adding a new command is a low-risk operation: create a new class and add one line to the registry. The core webhook logic remains untouched.
The Conversational Wall: When Stateless Commands Aren't Enough
The Command Pattern perfectly handles discrete, stateless actions. But what happens when we need a true conversation? Consider the flow for an interactive quiz:
- User:
/practice-subject 123 - Bot: "Great! Here is your first question: What is the capital of France?"
- User: "Paris"
- Bot: "Correct! Next question: What is 2+2?"
When the user replies "Paris," our webhook receives this message as a new, independent request. How does our system know that "Paris" is an answer to a specific question, and not a new command? How does it remember the user's score and which question to send next?
The context is lost between requests. The Command Pattern alone cannot solve this because it is inherently stateless. We have hit the conversational wall.
Giving Your Bot a Memory: The State Pattern
To build a memory, we introduce the State Pattern. This is another behavioral pattern that allows an object to alter its behavior when its internal state changes. From an external perspective, the object appears to change its class.
Our implementation requires these components:
- Context: The object that holds a state. In our case, this will be the user's session, which we will persist in the database.
- State Interface: A conceptual contract for what all state handlers must do (e.g., a
handle_message()method). - Concrete States: Classes that implement the behavior for a particular state (e.g.,
IdleState,InQuizState,InLessonState).
First, we must model the user's state. A simple Django model is perfect for this.
# models.py
class State(models.Model):
class Mode(models.TextChoices):
IDLE = 'IDLE', 'Idle'
IN_QUIZ = 'IN_QUIZ', 'In Quiz'
IN_LESSON = 'IN_LESSON', 'In Lesson'
# One-to-one link to our user model
account = models.OneToOneField(Account, on_delete=models.CASCADE)
# The user's current conversational state
state = models.CharField(max_length=50, choices=Mode.choices, default=Mode.IDLE)
# A flexible JSON field to store context-specific data, like the current
# question index, score, or list of remaining questions.
context = models.JSONField(default=dict)
Next, we create a Concrete State handler. This class encapsulates all the logic for handling messages while the user is in a quiz.
# handlers.py
class QuizHandler:
def __init__(self, state: State, user_answer: str):
self.state = state
self.user_answer = user_answer
def handle(self) -> str:
# 1. Check for "escape commands" like /exit-quiz to transition back to IDLE.
# 2. Validate the user's answer against the current question stored in self.state.context.
# 3. Update the score and current question index in self.state.context.
# 4. If the quiz is over, generate a final score message and transition state to IDLE.
# 5. If there are more questions, format and return the next question.
# 6. Save the updated state to the database.
pass # Implementation details omitted for brevity
The Hybrid Architecture: A Symbiotic Approach
The final step is to combine both patterns in our webhook. The webhook now takes on the role of the Context from the State Pattern. Its primary responsibility is no longer to route commands, but to first check the user's state.
[Diagram: A flowchart showing the webhook's decision logic. A request comes in, the system gets the user's state from the database. A diamond shape asks "Is State IDLE?". If No, the flow goes to a box "Delegate to appropriate State Handler (e.g., QuizHandler)". If Yes, the flow goes to a box "Delegate to Command Registry".]
The webhook's logic becomes a clear, two-stage process:
# views.py (The final hybrid architecture)
class WhatsAppWebhook(generics.GenericAPIView):
def post(self, request: Request):
# ... parsing logic to get user, phone, and body ...
# 1. Get the user's current state from the database.
state = State.objects.get_or_create(account=account)[0]
# 2. STATE PATTERN: First, check if the user is in an active, stateful conversation.
if state.state == State.Mode.IN_QUIZ:
handler = QuizHandler(state, body)
response_message = handler.handle()
send_whatsapp_message(user_phone, response_message)
return Response("OK")
elif state.state == State.Mode.IN_LESSON:
handler = LessonHandler(state, body)
# ... handle lesson logic ...
return Response("OK")
# 3. COMMAND PATTERN: If and only if the state is IDLE, fall back to the command registry.
command_name = body.lower().split(" ")[0]
CommandClass = COMMAND_REGISTRY.get(command_name)
if CommandClass:
# The command's execute() method might change the user's state,
# transitioning them into a stateful conversation.
command_instance = CommandClass(to_number=user_phone, ...)
response_message = command_instance.execute()
send_whatsapp_message(user_phone, response_message)
else:
# Handle unknown command for an IDLE user.
return Response("OK")
Let's trace a full user journey to see this synergy in action:
- User sends
/practice-subject 123. The user's state isIDLE. The webhook passes control to theCOMMAND_REGISTRY. - The
PracticeQuizSubjectCommandis executed. Its job is to fetch the quiz questions, change the user's state in the database toIN_QUIZ, store the questions and score in thecontextfield, and send the first question. - User sends "Paris". The webhook now sees the user's state is
IN_QUIZ. It ignores the command registry and passes control to theQuizHandler. - The
QuizHandlerprocesses the answer, updates the context, and sends the next question. The state remainsIN_QUIZ. - This continues until the last question is answered. The
QuizHandlerthen sends the final score and, crucially, transitions the user's state back toIDLE. - The conversation is now complete, and the system is ready to accept a new command.
Trade-offs and Considerations
This pattern-driven architecture is powerful, but it's not without its costs.
The Benefits:
- Scalability and Maintainability: The system is easy to extend. Adding new commands or new conversational states (e.g., a
FeedbackState) is done by creating new, isolated classes without modifying existing logic. - Testability: Each command and state handler can be unit-tested in isolation, dramatically improving code quality and reliability.
- Clarity: The code is self-documenting. The separation of concerns makes the system's control flow clear and easy for new developers to understand.
The Costs:
- Increased Complexity: For a very simple bot with only a few commands, this architecture is overkill. The number of classes and files can seem daunting at first.
- Boilerplate: There is an initial overhead in setting up the interfaces, registries, and handlers.
This architecture is best suited for applications where conversational complexity is expected to grow over time. For a simple FAQ bot, a less complex state machine might suffice. But for any application involving workflows, learning modules, or transactional conversations, the investment in this robust design pays significant dividends.
Conclusion: Building a Foundation for Intelligent Agents
We have successfully architected a system that tames the stateless nature of the web to create rich, stateful conversations. By using the right tool for the right job, we have built a chatbot backend that is robust, scalable, and a pleasure to maintain.
- The Command Pattern provides a clean, decoupled architecture for managing discrete, stateless actions.
- The State Pattern provides an elegant, encapsulated solution for managing complex, stateful conversations.
This hybrid approach does more than just organize code; it provides a solid, predictable foundation. By creating a deterministic "chassis" to manage the flow of conversation, we create a safe and reliable environment into which we can later inject more advanced, probabilistic AI and LLM features. This disciplined, pattern-driven design is the key to moving beyond simple request-response bots and building the truly intelligent agents of the future.
Opinions expressed by DZone contributors are their own.

Comments