DEV Community

Ashraf
Ashraf

Posted on

Your Codebase Isn't Ready for AI Agents. Here's How DDD Fixes That

You've noticed the pattern. Give an LLM a greenfield repo and it produces clean, sensible code. Point it at the five-year-old monolith with three spellings of "customer" and it invents a fourth. It writes an adapter where a direct call was fine, then calls straight through where the whole point was the adapter. It doesn't fail loudly — it produces code that looks reasonable and is wrong in ways that take three code reviews to catch.

The model isn't the problem. The codebase isn't ready.

This isn't about prompt engineering or context window size. It's about whether your codebase answers the questions an agent needs to ask: what is this thing, who owns it, and what am I allowed to do with it? When the answer is "it depends" or "check the git blame," your agent guesses — and guesses wrong.

Domain-driven design (DDD) is the fix. Not the whole Evans book — just the parts that make your codebase legible to an LLM: bounded contexts, ubiquitous language, and a context map.

Why LLMs Thrive in Greenfield and Die in Brownfield

LLMs are pattern matchers. Give them a clean domain model with consistent naming and explicit boundaries, and they produce code that fits. Give them a codebase where the same concept is called User, Account, and Party across three modules, and they produce code that adds a fourth variant — because the codebase itself never decided which one was real.

Ask an LLM to add a "subscription status" field. In a well-structured codebase, it finds the Subscription aggregate, reads its invariants, and adds the field correctly. In a legacy codebase, it finds user.subscription_type, account.plan_id, billing.tier, and a customer_subscription table that contradicts all three. It picks one at random. It might even create a new table.

The failure mode isn't hallucination. It's ambiguity. The codebase doesn't answer the question, so the model guesses.

Strategic vs. Tactical: The Split That Makes Agents Valuable

The economics of software work change when you separate strategic work from tactical work.

Strategic work is deciding what needs to change and why: reading the system, understanding the domain, tracing the impact of a modification. It requires the whole system in your head. This is still expensive.

Tactical work is carrying that decision into the files: writing the code, adding tests, extracting the module. This got cheap. An LLM does the mechanical half of a refactor at a cost that no longer resembles 2020 — as long as it can understand what needs to happen.

The mistake most teams make is expecting the agent to do both halves. It can't. The strategic half — the "what" — needs to be baked into the codebase itself. That's where DDD comes in.

Bounded Contexts Are Your Agent's Operating Manual

A bounded context is a boundary inside which one word means exactly one thing. Offer in billing and Offer in recruiting are not the same concept. When your agent operates in a bounded context, it knows the vocabulary, the rules, and the relationships without guessing.

Before (no bounded context):

# models.py - 3000 lines, no context boundary
class User(models.Model):
    status = models.CharField(max_length=20)  # Is this for subscription? Moderation? Verification?
    plan = models.CharField(max_length=50, null=True)  # Sometimes "free", sometimes "pro", sometimes null
    role = models.CharField(max_length=20)  # admin, user, moderator, or customer tier?
Enter fullscreen mode Exit fullscreen mode

Ask an LLM to "add a premium subscription check" to this and it will:

  • Check user.plan (50% chance)
  • Check user.status (30% chance)
  • Create a new Subscription model with its own state (20% chance)

All three are valid guesses because the codebase never declared which one is real.

After (bounded context):

# billing/context.py - explicit boundary
class Subscription:
    """Bounded context: billing. Owns all payment and plan state."""
    plan: PlanTier  # enum: FREE, PRO, ENTERPRISE — single source of truth
    status: SubscriptionStatus  # enum: ACTIVE, PAST_DUE, CANCELED, EXPIRED
    period: BillingPeriod

class PlanTier(Enum):
    FREE = "free"
    PRO = "pro"
    ENTERPRISE = "enterprise"

# user/context.py - separate bounded context
class User:
    """Bounded context: identity. Owns authentication and profile state."""
    role: UserRole  # admin, moderator, member — completely unrelated to billing
    profile: Profile
Enter fullscreen mode Exit fullscreen mode

An LLM operating in the billing context has exactly one place to look for subscription state. No guessing. The model doesn't need to be smarter — it needs the codebase to be unambiguous.

Ubiquitous Language: Stop Making Your LLM Guess

Ubiquitous language means the vocabulary of one context, used identically in conversation, in docs, and in code. If the business says "Profile Variant" and the code says candidateConfig, the translation tax is paid on every change — by the LLM and the developer.

The fix is a glossary file per context:

# CONTEXT.md — billing context

## Terms
- **Subscription**: A recurring billing agreement. Owned by this context.
- **PlanTier**: The product tier (FREE, PRO, ENTERPRISE). Enum, single source of truth.
- **BillingPeriod**: Monthly or annual. Determines price calculation.
- **Invoice**: Generated at the end of each period. Immutable after generation.

## Rejected synonyms (do not use)
- user.plan — owned by identity context, refers to onboarding state only
- account.tier — legacy, being migrated to billing.Subscription.plan
- customer_subscription — deprecated table, removed in Q3

## Rules
- A Subscription must have exactly one active PlanTier
- Downgrades take effect at the end of the current BillingPeriod
- Invoices are generated within 24 hours of period end
Enter fullscreen mode Exit fullscreen mode

Put that file in your repo at billing/CONTEXT.md. When your agent loads the billing context, it reads this file first. It knows the vocabulary, the invariants, and the things not to touch.

The improvement in code quality isn't incremental. It's the difference between a model that invents and a model that follows instructions.

The Context Map: Your Agent's Navigation System

In a multi-repo or multi-service system, agents need to know which context owns what and how contexts relate. A context map is the navigation system.

Declare edges between contexts explicitly:

{
  "domain": {
    "project": "job-offer-box",
    "contexts": [
      {
        "name": "billing",
        "docs": "billing/CONTEXT.md",
        "subdomain": "core",
        "edges": [
          {
            "to": "identity/user",
            "direction": "inbound",
            "pattern": "published-language",
            "owner": "supplier"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

This tells the agent:

  • The billing context owns the customer's payment relationship.
  • It receives identity data from the identity service via a published API.
  • It does not own user authentication or profile data.

When an agent needs to change an invoice field, it reads this map and knows to check the billing context first, not to touch identity tables, and to respect the published language contract with identity.

Generate the portfolio-wide context map from individual declarations. A script walks every repo, unions the domain blocks, and emits a single CONTEXT-MAP.md. The map is disposable and regenerable — it never drifts from the code because it's derived from it.

The generator should also cross-check edges. If billing declares an outbound edge to identity but identity has no matching inbound declaration, that's a finding — file it as an issue and let an agent fix it.

Getting Started Without Rewriting Everything

You don't need to rewrite your legacy codebase to benefit from this. Start small:

  1. Pick one context. Find the most painful part of the codebase — the one where LLMs consistently produce wrong code. Declare its boundary.

  2. Write a CONTEXT.md. List the terms, the rejected synonyms, and the invariants. Keep it to one page.

  3. Add a domain block to your repo manifest. Declare the context name, its docs file, and one edge to the most important neighbor.

  4. Point your agent at the context. Before the agent starts a task in that area, feed it the CONTEXT.md and the context map. Watch the error rate drop.

  5. Repeat. The next context takes half the time.

The goal isn't a perfect DDD model. It's a codebase that answers the questions an agent needs to ask. Every glossary entry you add, every boundary you declare, makes your agents more reliable.

What DDD Doesn't Fix

DDD makes your codebase legible. It doesn't fix:

  • Bad agent prompts. If your instructions are vague, even a perfect context map won't save you. The strategic/tactical split applies here too: you still need to specify the what.
  • Bad tests. An agent can follow your domain model perfectly and still produce code with no tests. Your review process still matters.
  • Bad architecture. Bounded contexts don't fix a distributed monolith or a god class. They surface the boundaries — you still have to decide what goes where.
  • Runtime correctness. A model that understands your domain model still produces code you need to run through CI. The error rate drops, but it doesn't hit zero.

The Bottom Line

LLMs are pattern matchers. Give them a codebase with consistent patterns — explicit boundaries, unambiguous vocabulary, declared dependencies — and they produce better code. That's not a claim about the model. It's a claim about the codebase.

The teams investing in domain modeling today aren't just writing better software. They're building the substrate their AI agents will operate on. The codebase that reads like a domain model is a codebase an LLM can work with.

Start with one context. Write the glossary. Declare the edge. Watch the agent stop guessing.

Top comments (0)