DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Cost Is a Distributed Systems Bug
  • The Art of Idempotency: Preventing Double Charges and Duplicate Actions
  • A General Overview of TCPCopy Architecture
  • Implementing Persistence With Clean Architecture

Trending

  • Swift Concurrency Part 4: Actors, Executors, and Reentrancy
  • Lease Coordination Under Serializable Isolation in CockroachDB
  • The Prompt Isn't Hiding Inside the Image
  • Stop Guessing, Start Seeing: A Five -Layer Framework for Monitoring Distributed Systems
  1. DZone
  2. Data Engineering
  3. AI/ML
  4. Beyond Request-Response: Architecting Stateful Agentic Chatbots with the Command and State Patterns

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.

By 
Iyanuoluwa Ajao user avatar
Iyanuoluwa Ajao
·
Apr. 13, 26 · Tutorial
Likes (3)
Comment
Save
Tweet
Share
2.6K Views

Join the DZone community and get the full member experience.

Join For Free

Chatbot Backend Architecture


The 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.

Python
 
# 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:

  1. Command Interface: An Abstract Base Class (ABC) in Python that defines a common execute() method.
  2. Concrete Commands: Classes that implement the Command interface for specific actions.
  3. Receivers: Classes that contain the actual business logic (e.g., interacting with the database).
  4. 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.

Python
 
# 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.

Python
 
# 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.

Python
 
# 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:

  1. User:/practice-subject 123
  2. Bot: "Great! Here is your first question: What is the capital of France?"
  3. User: "Paris"
  4. 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:

  1. Context: The object that holds a state. In our case, this will be the user's session, which we will persist in the database.
  2. State Interface: A conceptual contract for what all state handlers must do (e.g., a handle_message() method).
  3. 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.

Python
 
# 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.

Python
 
# 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:

Python
 
# 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 is IDLE. The webhook passes control to the COMMAND_REGISTRY.
  • The PracticeQuizSubjectCommand is executed. Its job is to fetch the quiz questions, change the user's state in the database to IN_QUIZ, store the questions and score in the context field, 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 the QuizHandler.
  • The QuizHandler processes the answer, updates the context, and sends the next question. The state remains IN_QUIZ.
  • This continues until the last question is answered. The QuizHandler then 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.

Architecture Business logic Chatbot Command pattern State pattern Command (computing) Requests

Opinions expressed by DZone contributors are their own.

Related

  • Cost Is a Distributed Systems Bug
  • The Art of Idempotency: Preventing Double Charges and Duplicate Actions
  • A General Overview of TCPCopy Architecture
  • Implementing Persistence With Clean Architecture

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook