All articles
AI News

Domain-Driven Agents: Bounded Contexts for Reliable AI Workflows

FDE Coach EditorialAugust 31, 202611 min read

What Domain-Driven Agents Are (The Cold Take)

The core idea surfaced by Cold Take is brutally simple: most AI agent failures aren't model problems—they're architecture problems. We keep building monolithic agents that hold a giant system prompt, access twenty tools, and then we're shocked when they call the wrong API or hallucinate a parameter from a different context.

Domain-Driven Agents (DDA) borrows directly from Eric Evans' Domain-Driven Design. The insight: if bounded contexts prevent a Customer object in the billing domain from leaking into the shipping domain in software, they can do the same for LLM agents. You don't build one agent. You build a network of small, context-scoped agents, each owning a single domain, each with a prompt and toolset that never bleeds outside its boundary.

This isn't another prompt engineering trick. It's a structural constraint. When an agent only knows about OrderFulfillment and can only touch check_inventory and create_shipment, it literally cannot hallucinate a billing dispute function. The architecture removes the failure mode.

Why This Architecture Clicks for Engineers

If you've deployed LLM workflows in production, you've felt the pain this solves. The pattern addresses three failure modes that keep engineers awake:

Contextual interference. A single agent with a 3000-token system prompt covering CRM, billing, and support will inevitably confuse field names across domains. It'll pass a customer_id where it should pass an invoice_id because both exist in its attention window. Bounded contexts eliminate this by making the cross-domain mapping an explicit, testable layer—not something the LLM holds in its head.

Tool selection ambiguity. Give an agent fifteen tools, and it will sometimes pick the wrong one. Not because the model is bad, but because the semantic distance between cancel_subscription and issue_refund is small enough that a high-temperature sample crosses it. Domain-scoped agents typically have 2-4 tools each. The selection problem collapses.

Observability debt. When a monolithic agent produces a wrong output, debugging means tracing through a single massive trace where every domain's state is interleaved. With DDA, if a shipment goes wrong, you look at the ShippingAgent trace. The blast radius is contained. For Forward Deployed Engineers managing enterprise AI rollouts, this is the difference between a 10-minute root cause analysis and a multi-hour war room. If you're reading customer health signals during a deployment, you'll spot domain-specific degradation instantly rather than seeing a vague "agent quality is down" metric—a skill we cover in our breakdown of FDE customer health signals during AI rollouts.

The Core Pattern: Bounded Contexts for LLMs

The architecture has three layers. Understanding them as an engineer means thinking about data contracts, not just prompts.

Layer 1: Domain Agents (The Workers)

Each domain agent is a self-contained unit with:

  • A system prompt that defines one bounded context. It knows the entities, rules, and vocabulary of exactly one domain. Nothing else.
  • A tool set limited to that domain's operations. A BillingAgent has lookup_invoice and process_refund. It does not have check_inventory.
  • An output schema that returns structured data, not free text. The agent's job is to produce a domain event or a domain object, not to chat.

Layer 2: The Context Map (The Translator)

In DDD, a context map defines how bounded contexts relate. Here, it's a deterministic translation layer—code, not an LLM. When the OrderAgent produces an OrderConfirmed event containing a customer_id and order_total, the context map translates that into the shape the BillingAgent expects: an InvoiceRequest with account_id and amount_due.

This is where the reliability comes from. The LLM never performs cross-domain mapping. A pure function does it. You can unit test it. You can version it. You can deploy it independently.

Layer 3: The Orchestrator (The Conductor)

The orchestrator is the only agent that sees the full workflow. But critically, it doesn't see domain details. It operates on an abstract workflow graph. Its prompt says: "When you receive an OrderConfirmed event, route it to the BillingAgent. When BillingAgent returns PaymentProcessed, route to ShippingAgent." It knows the sequence, not the substance.

The flow is unidirectional and auditable. Each domain agent's input and output is a typed event. The context map transformations are pure functions. If something breaks, you replay the event log through the offending domain agent in isolation.

Building It: A Practical Reference Implementation

Let's make this concrete. Here's a minimal Domain-Driven Agent system for an e-commerce support workflow, built with what you likely already have in your stack.

Step 1: Define Domain Schemas as Contracts

Before writing a single prompt, define the events. Use Pydantic, Zod, or JSON Schema—whatever enforces types at runtime.

from pydantic import BaseModel
from typing import Optional

class OrderInquiry(BaseModel):
    order_id: str
    customer_id: str
    inquiry_type: str  # "status", "cancel", "modify"

class OrderStatus(BaseModel):
    order_id: str
    status: str
    tracking_number: Optional[str]
    items: list[dict]

class RefundRequest(BaseModel):
    account_id: str
    amount_cents: int
    reason: str
    order_reference: str

These schemas are your API contracts between domains. The OrderInquiry is what the orchestrator hands to the Order domain agent. The OrderStatus is what comes back. The context map transforms OrderStatus into RefundRequest when a cancellation triggers a refund. No LLM touches this transformation.

Step 2: Build Domain Agents with Scoped Prompts

Each domain agent gets a prompt that knows only its world. Here's the Order domain agent:

order_agent_prompt = """
You are an order management system. Your ONLY responsibility is to retrieve and report order information.

You have access to these tools:
- lookup_order(order_id: str) -> dict
- cancel_order(order_id: str) -> dict

Rules:
- You do NOT handle payments, refunds, or shipping logistics.
- You do NOT know customer account details beyond customer_id.
- Always return a valid OrderStatus object.
- If you cannot fulfill the request, return an error with a specific reason.

Current request type: {inquiry_type}
"""

Notice what's absent: no mention of billing, no refund logic, no shipping carriers. The prompt's brevity is a feature. The model's attention isn't diluted by irrelevant context.

The Billing domain agent is similarly scoped:

billing_agent_prompt = """
You are a billing processor. Your ONLY responsibility is to process refunds and payment lookups.

Tools:
- process_refund(account_id: str, amount_cents: int, reason: str) -> dict
- lookup_transactions(account_id: str) -> list[dict]

Rules:
- You do NOT know about orders, inventory, or shipping.
- You do NOT initiate refunds without an explicit RefundRequest.
- Always return a structured result with transaction_id on success.
"""

Step 3: Implement the Context Map as Pure Functions

This is the deterministic glue. When the Order agent returns a cancelled order, the context map decides whether and how to create a refund request:

def map_order_status_to_refund(order_status: OrderStatus) -> Optional[RefundRequest]:
    if order_status.status != "cancelled":
        return None
    
    # Business logic: only refund if items were never shipped
    if any(item.get("shipped") for item in order_status.items):
        return None
    
    return RefundRequest(
        account_id=order_status.order_id,  # Mapping logic lives here
        amount_cents=sum(item["price_cents"] for item in order_status.items),
        reason="Order cancelled before shipment",
        order_reference=order_status.order_id
    )

This function is testable, versionable, and auditable. You can write a dozen unit tests for edge cases—partial shipments, zero-value orders, already-refunded orders—without touching an LLM. For an FDE deploying AI workflows at an enterprise, this is where you encode the customer's specific business rules that no foundation model can know. This pattern echoes the decomposition skills we explore in our breakdown of the Cohere and Anthropic FDE interview process, where separating business logic from model invocation is a key signal.

Step 4: Wire the Orchestrator

The orchestrator is the only agent with a "big picture" prompt, but it's thin:

orchestrator_prompt = """
You are a workflow router. Based on the user's intent, route to the appropriate domain agent.

Available domains:
- order: For order status, cancellation, modification
- billing: For refund requests, transaction history
- shipping: For tracking, delivery issues

You do NOT handle domain logic. Your job is to classify intent and route.
Return a JSON object: {"domain": "order|billing|shipping", "parameters": {...}}
"""

When the orchestrator routes to order and the order agent returns a cancellation, the orchestrator doesn't decide to issue a refund. It passes the OrderStatus through the context map. The context map either produces a RefundRequest (which the orchestrator routes to billing) or it doesn't. The orchestrator follows the workflow graph; it doesn't improvise.

Step 5: Test Each Domain in Isolation

Here's where the architecture pays off. You can test the Order agent without standing up billing infrastructure:

def test_order_agent_cancellation():
    mock_order = {"order_id": "123", "status": "active", "items": [...]}
    result = invoke_order_agent(OrderInquiry(order_id="123", customer_id="c1", inquiry_type="cancel"))
    assert result.status == "cancelled"
    # No billing calls were made. The agent's toolset doesn't include them.

And test the context map independently:

def test_context_map_no_refund_for_shipped_items():
    status = OrderStatus(order_id="123", status="cancelled", 
                         items=[{"name": "Widget", "price_cents": 1000, "shipped": True}])
    refund = map_order_status_to_refund(status)
    assert refund is None

This testability is what makes the pattern production-grade. You're not hoping the LLM "does the right thing" across domains. You're verifying deterministic logic and testing LLM behavior within tightly scoped boundaries.

When to Use It (and When Not To)

Domain-Driven Agents aren't a universal answer. They're a specific tool for a specific class of problem.

Use DDA when:

  • Your workflow spans 3+ distinct business domains (orders, billing, shipping, notifications).
  • Domain logic has compliance or correctness requirements that can't be left to LLM judgment.
  • You need independent deployability—shipping a billing change shouldn't risk breaking order lookups.
  • You're building for an enterprise customer who needs auditable, explainable AI decisions. This is bread-and-butter FDE work; if you're navigating enterprise deployments, understanding how AI-native startups use FDEs to win enterprise deals will show you why architecture patterns like DDA close deals.

Skip DDA when:

  • Your workflow is a single domain. A customer support bot that only does FAQ lookup doesn't need bounded contexts. One agent, one toolset, done.
  • Latency is paramount and you can't afford multiple LLM calls. Each domain hop adds a round trip. If you need sub-500ms responses, consider a single fine-tuned agent.
  • The domains are tightly coupled and the translation layer would be trivial. If your "context map" is just renaming one field, you're adding complexity for no gain.
  • You're prototyping. DDA adds upfront schema and contract design. For a hackathon or internal tool, move fast with a monolith and refactor when the boundaries become clear.

FAQ

Doesn't this increase latency with multiple LLM calls? Yes. Each domain agent invocation is a separate API call. In practice, you can parallelize independent domains (billing and shipping can run concurrently after order confirmation). For latency-sensitive paths, consider caching domain agent outputs or using smaller, faster models for well-scoped domains.

How is this different from just using function calling with strict schemas? Function calling with schemas constrains outputs, not context. A single agent with function calling still has all tools and all domain knowledge in its prompt. DDA constrains the input context itself. The agent can't hallucinate a billing function call because it doesn't know billing exists.

Can I use this with any LLM framework? Yes. The pattern is framework-agnostic. You can implement it with LangChain, directly with the OpenAI SDK, or in a no-code platform. If you're building agentic workflows in n8n, you can model each domain agent as a separate workflow node with scoped tool access. For a practical example of scoped agent design in a different context, check out our guide on building a WhatsApp customer-support agent with n8n and Supabase—the RAG retrieval agent there is effectively a domain agent scoped to document lookup.

What about shared state between domains? Shared state lives in the context map and the event payloads, not in the agents. If the Order agent and Shipping agent both need a customer_address, it's passed in the event that the orchestrator routes. No agent retrieves it independently from a shared database. This keeps the agents stateless and replayable.

Doesn't the orchestrator become the new monolith? Only if you let it. The orchestrator should be a thin router with a workflow graph. If it starts containing domain logic—"if the order is over $500, flag for fraud review"—that logic belongs in a domain agent or the context map. The orchestrator's job is sequence, not substance. Keep its prompt under 500 tokens as a smell test.

#ai-agents#software-architecture#ddd#workflow-design

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More ai news

August 15 · 0d left
Enroll Now
Domain-Driven Agents: Bounded Contexts for Reliable AI Workflows | FDE Coach