DEV Community

Cover image for Rediscovering Domain-Driven Design, one MCP server at a time
Dennis Traub for AWS

Posted on

Rediscovering Domain-Driven Design, one MCP server at a time

A few days ago, a devops engineer posted on r/devops:

"MCP servers just showed up in our infrastructure and I genuinely have no idea how to secure them, anyone been through this?"

Filesystem access, shell permissions, database connectors - all callable by agents without human approval. At the time I'm writing this, the thread has 76 upvotes and 39 comments from fellow engineers improvising solutions: "separate by blast radius," "don't mix list_files and execute_shell in one server," "three security surfaces, not one."

They're all describing the same thing: the patterns they are rediscovering have been formalized in Domain-Driven Design (DDD), by Eric Evans.

In his book, Eric introduced concepts like Bounded Contexts and Anti-Corruption Layers, which gave us the vocabulary we've been using for system boundaries ever since. They helped us survive the microservices transition, and they apply directly to the architectural problems AI systems are creating right now.

We made the same mistakes a decade ago

In the 2010s, many teams adopted microservices without understanding what made them work. They took monoliths, split them apart, and called the pieces "services." More often than not, the result was distributed monoliths - all the operational complexity of distribution with none of the architectural benefits of real, well-defined boundaries.

The correction took years. We learned (painfully) that a microservice boundary isn't where you split the code. It's where you split the mental model you have of the application. A payment service and a user service don't just live in different containers - they have different vocabularies, different invariants, different reasons to change.

And the same mistake is happening again, this time with MCP servers. We wrap existing REST APIs one-to-one and call it AI integration. David Soria Parra, one of the creators of MCP, said "it's a bit cringe, it just results in horrible things" at AI Engineer World's Fair 2025. The Thoughtworks Technology Radar placed "MCP by default" as a Caution. And if you dive into the argument they make, they're both saying the same thing: we're building distributed monoliths again.

But the correction doesn't have to take years this time. The vocabulary already exists - and has been battle-tested for more than 20 years.

Bounded Contexts: one server, one model, one language

A Bounded Context defines where a particular (data or object) model is valid. Inside the boundary, terms have precise meanings: a "transaction" in the finance context means money changing hands, a "transaction" in the booking context means a reservation. Inside each boundary lives one language and one set of rules. Across boundaries, you expect translation.

And MCP is particularly interesting from this angle: the protocol already enforces bounded contexts at the topology level.

MCP's architecture uses a one-client-per-server model. The host spawns a separate client for each MCP server, and each client talks to exactly one server. An MCP server for your database cannot accidentally leak data to an MCP server for your file system. Unlike microservices, where any service can trivially call any other over the network, an MCP server has no protocol-level way to reach another server's tools. You have to deliberately build that bridge. Cross-boundary coupling becomes visible and intentional rather than accidental.

But only if you design your servers as bounded contexts.

The failure mode is an MCP server that exposes everything, including filesystem access, shell execution, and database connectors in a single server. That's three separate concerns crammed into one boundary - the equivalent of a microservice that owns users, payments, and notifications.

The commenter on Reddit who wrote "don't mix list_files and execute_shell in one server" was actually designing context boundaries, even if he didn't know the term.

Anti-Corruption Layers: separating the tools from domain logic

An Anti-Corruption Layer (ACL) prevents one system's model from contaminating another. It translates between two different worldviews.

In AI systems, two fundamentally different models collide every time an agent calls a tool:

  • For the LLM, everything is strings, parameters are simple, and context is a token window. It reasons in natural language to generate structured calls.
  • The domain consists of rich types, configuration, state, complex error handling, and business invariants that must hold regardless of how they're invoked.

The tools layer sits between these two worlds. In Chris Hughes’s words, it “protects your domain from the LLM’s interface requirements - translating between ‘strings the LLM can reason about’ and ‘rich domain objects your code works with’.”

Here's a tool that ignores this principle:

# Everything in one function - LLM interface mixed with domain logic

@mcp.tool()
async def transfer_funds(from_account: str, to_account: str, amount: str):
    amount_decimal = Decimal(amount)
    from_acc = await db.get_account(from_account)

    if from_acc.balance < amount_decimal:
        return "Insufficient funds"

    if from_acc.is_frozen:
        return "Account frozen"

    await db.execute_transfer(from_acc, to_account, amount_decimal)
    await audit_log.record(from_account, to_account, amount_decimal)

    return f"Transferred {amount} from {from_account} to {to_account}"
Enter fullscreen mode Exit fullscreen mode

And here's the same operation with a proper separation:

# Tool layer: thin adapter (the ACL)
@mcp.tool()
async def transfer_funds(from_account: str, to_account: str, amount: str):
    result = await transfer_service.execute(
        from_account=from_account,
        to_account=to_account,
        amount=Decimal(amount)
    )
    return result.to_agent_summary()

# Service layer: domain logic, testable without the LLM
class TransferService:
    async def execute(self, from_account, to_account, amount) -> TransferResult:
        account = await self.accounts.get(from_account)
        account.validate_transfer(amount)  # raises on invariant violation
        transfer = account.initiate_transfer(to_account, amount)
        await self.transfers.save(transfer)
        await self.audit.record(transfer)
        return TransferResult(transfer)
Enter fullscreen mode Exit fullscreen mode

The second version gives you:

  • Testability: the service works without an LLM. Run it from tests, CLI, scripts.
  • Replaceability: change the LLM interface (tool parameters, response format) without touching business logic. Change business rules without touching the tool layer.
  • Composability: other MCP servers, other agents, or humans can call the same service through their own interface.

The ACL protects both sides. The domain doesn't get contaminated by the LLM's string-based worldview. The LLM doesn't get overwhelmed by domain complexity it can't reason about.

The same vocabulary in a new domain

Back to that Reddit thread.

"Separate MCP servers by blast radius." That's bounded context design. Each server owns one domain. The blast radius is contained because the boundary is real.

"Three security surfaces, not one - tool capability, tool description, and tool call chains." The ACL decomposed into its responsibilities. Tool capability is what the domain allows. Tool description is what the LLM thinks it can do. Tool call chains are cross-boundary interactions that need explicit orchestration.

"The dangerous part is not one tool in isolation. It is the chain." In DDD terms: an aggregate invariant violation. A sequence of operations crossing bounded contexts without coordination. Each operation succeeds locally while the system fails globally.

Same patterns, same structural problem, discovered independently because the problem is real.

The "abstraction tax" is the ACL doing its job

One fair criticism is that MCP adds a layer. The Thoughtworks Tech Radar calls this the "abstraction tax" - every protocol layer between an agent and an API loses fidelity. Simon Willison notes that "almost everything I might achieve with an MCP can be handled by a CLI tool instead."

This is correct. And it's exactly the same argument people made against microservice boundaries, API gateways, and anti-corruption layers in traditional systems. The translation layer comes with costs: you lose directness.

But this loss is intentional. It's the ACL doing its job. The LLM doesn't need to know about your domain's internal types, retry logic, or state management. The domain doesn't need to accommodate the LLM's string-based reasoning model. The "tax" buys you isolation, replaceability, and, ultimately, peace of mind.

It's only a mistake if we're paying this tax without getting the architectural benefit - which is exactly what REST-to-MCP 1:1 wrappers do. They add the layer without adding the boundary: all cost, no benefit.

The vocabulary already exists. Let's keep using it.

We don't have to reinvent these patterns - DDD has 20+ years of battle scars. We've learned the hard way where to draw boundaries, how to enforce them, and what happens when we don't. AI or no AI, Eric Evans's Domain-Driven Design is still the canonical reference for complex software systems.

MCP is already designed to establish bounded contexts; the tools layer is already an anti-corruption layer. Name your MCP servers after the domain they own, not the API they wrap, and when someone on your team says "separate by blast radius" - let them know that there are established patterns for what they're describing.

Top comments (2)

Collapse
 
094459 profile image
Ricardo Sueiras • Edited

Great post Dennis.

Collapse
 
dennistraub profile image
Dennis Traub AWS

Thank you Ricardo!