DDD 架构为什么适合 MCP Server 开发?

一、先说背景:MCP 到底在解决什么问题

如果你还没接触过 MCP,简单说------它是 Anthropic 提出的一套开放协议,目的是让大模型能以标准化的方式调用外部工具和数据源。你可以把它理解为"AI 时代的 USB-C 接口":大模型是主机,MCP Server 是外设,协议统一了两者之间的通信方式。

一个 MCP Server 对外暴露的核心概念就三个:

  • Tools:可被模型调用的操作(比如"创建工单"、"查询统计数据")
  • Resources:可被模型读取的数据(比如"项目列表"、"用户配置")
  • Prompts:预定义的提示词模板(引导模型更好地使用上述能力)

看起来不难,但实践的时候就有很多问题了。。。

二、裸写 MCP Server,痛点在哪

最开始我的做法很直接------拿到 MCP SDK,直接在 handler 里把业务逻辑写了:

go 复制代码
func handleCreateOrder(params map[string]interface{}) (*mcp.Result, error) {
    title := params["title"].(string)
    workspaceID := params["workspace_id"].(string)

    // 校验工作空间是否存在
    var ws Workspace
    err := db.QueryRowContext(ctx, "SELECT id, status FROM workspaces WHERE id = $1", workspaceID).Scan(&ws.ID, &ws.Status)
    if err != nil {
        return nil, err
    }

    // 校验标题规范
    if !isValidTitle(title) {
        return nil, fmt.Errorf("title invalid")
    }

    // 创建工单
    _, err = db.ExecContext(ctx,
        "INSERT INTO orders (title, workspace_id, status, created_at) VALUES ($1, $2, $3, $4)",
        title, workspaceID, "open", time.Now(),
    )
    if err != nil {
        return nil, err
    }

    // 发消息通知下游
    mq.Produce("order.created", title)

    return &mcp.Result{Content: "Order created"}, nil
}

一个 Tool 的 handler 写成这样,看着还行。但当 Tool 数量膨胀到 20 多个,问题就暴露了:

1. Tool 之间的业务逻辑大量重复。 比如"创建工单"和"批量导入工单"都需要校验空间状态、检查配额、发通知,这些逻辑散落在不同的 handler 里,改一处漏一处。

2. 协议层和业务层耦合严重。 MCP 的参数解析、结果封装和业务规则搅在一起。想把同一套业务逻辑同时暴露给 HTTP API 和 MCP?基本等于重写。

3. 测试极其困难。 想单独测试"工单创建"的业务规则,必须 mock 掉 MCP SDK、数据库、消息队列......一个单测写下来比业务代码还长。

4. 新人接手成本高。 每个 handler 都是一个"大泥球",不深入读完全部代码,根本不知道这个 Tool 到底做了几件事。

以上都是"事务脚本"(Transaction Script)模式的典型症状。

三、DDD 的哪些概念刚好解决这些问题

聊 DDD 之前先声明一下立场:我不觉得所有项目都该上 DDD。但 MCP Server 的特点和 DDD 中几个核心概念比较契合。

3.1 限界上下文(Bounded Context)≈ MCP 的 Tool 分组

一个有一定规模的 MCP Server,对外暴露的 Tool 不可能只属于一个业务域。比如一个工单管理系统,至少涉及:

  • 工单域:创建/查询/流转工单
  • 评审域:提交评审、审批处理
  • 统计域:查看进度、导出报表
  • 组织域:管理团队、权限控制

如果不划分边界,所有 Tool 的 handler 挤在一个包里,互相引用对方的数据结构和数据库查询,最后就是一锅粥。

限界上下文的价值就在于:明确每个域自己管自己的模型和规则,域与域之间通过定义好的接口交互。映射到代码结构:

csharp 复制代码
mcp-server/
├── domain/
│   ├── order/          # 工单域
│   │   ├── entity.go       # Order, OrderStatus 等实体
│   │   ├── repository.go   # 持久化接口(只定义,不实现)
│   │   ├── service.go       # 域服务:分配算法、状态流转规则
│   │   └── event.go         # 域事件:OrderCreated, OrderAssigned
│   ├── review/         # 评审域
│   │   ├── entity.go
│   │   ├── repository.go
│   │   └── service.go
│   └── statistic/      # 统计域
│       ├── entity.go
│       ├── repository.go
│       └── service.go
├── application/        # 应用层:编排域服务,对应每个 Tool 的用例
│   ├── order_usecase.go
│   ├── review_usecase.go
│   └── statistic_usecase.go
├── infrastructure/     # 基础设施:数据库、消息队列、外部 API 的具体实现
│   ├── postgres/
│   ├── mq/
│   └── mcp/            # MCP 协议适配层
│       ├── server.go
│       └── tools.go
└── main.go

3.2 实体和值对象:让业务规则有处安放

MCP Server 开发中一个特别容易犯的错误是:把业务规则写在协议层

比如"工单状态只能从 open 到 in_progress,不能从 closed 跳回 open"------这个规则应该在哪?如果写在 Tool handler 里,那 HTTP API 也要抄一份;如果写在数据库触发器里,那调试起来要命。

DDD 的做法是把这类规则封装在实体内部:

go 复制代码
type OrderStatus string

const (
    OrderOpen       OrderStatus = "open"
    OrderInProgress OrderStatus = "in_progress"
    OrderResolved   OrderStatus = "resolved"
    OrderClosed     OrderStatus = "closed"
)

type Order struct {
    ID          string
    Title       string
    WorkspaceID string
    Status      OrderStatus
    AssigneeID  string
    CreatedAt   time.Time
}

var validTransitions = map[OrderStatus][]OrderStatus{
    OrderOpen:       {OrderInProgress, OrderClosed},
    OrderInProgress: {OrderResolved, OrderOpen},
    OrderResolved:   {OrderClosed, OrderOpen},
    OrderClosed:     {},
}

func (o *Order) TransitTo(target OrderStatus) error {
    allowed := validTransitions[o.Status]
    for _, s := range allowed {
        if s == target {
            o.Status = target
            return nil
        }
    }
    return fmt.Errorf("cannot transit from %s to %s", o.Status, target)
}

这样一来,不管是 MCP Tool 还是 HTTP Handler 还是消息消费者,只要操作 Order 实体,状态流转规则就自动生效。规则跟着模型走,而不是跟着入口走。

3.3 应用服务:一个 Tool 对应一个 Use Case

DDD 中应用层的职责很明确:编排领域对象完成一个完整的业务用例。不多不少,刚好对应 MCP 的一个 Tool。

go 复制代码
type OrderUseCase struct {
    orderRepo     order.Repository
    workspaceRepo workspace.Repository
    eventBus      event.Bus
}

func (uc *OrderUseCase) CreateOrder(ctx context.Context, cmd CreateOrderCommand) (*order.Order, error) {
    ws, err := uc.workspaceRepo.Get(ctx, cmd.WorkspaceID)
    if err != nil {
        return nil, fmt.Errorf("workspace not found: %w", err)
    }

    if !ws.IsActive() {
        return nil, fmt.Errorf("workspace %s is not active", ws.ID)
    }

    o := order.New(cmd.Title, ws.ID)

    if err := uc.orderRepo.Save(ctx, o); err != nil {
        return nil, err
    }

    uc.eventBus.Publish(ctx, order.CreatedEvent{OrderID: o.ID})
    return o, nil
}

而 MCP 的 Tool handler 退化成一个薄薄的适配层:

go 复制代码
func (s *MCPServer) registerCreateOrderTool() {
    s.server.AddTool(mcp.Tool{
        Name:        "create_order",
        Description: "在指定工作空间下创建一个工单",
        InputSchema: createOrderSchema,
    }, func(ctx context.Context, params map[string]interface{}) (*mcp.Result, error) {
        cmd := CreateOrderCommand{
            Title:       params["title"].(string),
            WorkspaceID: params["workspace_id"].(string),
        }
        o, err := s.orderUseCase.CreateOrder(ctx, cmd)
        if err != nil {
            return mcp.ErrorResult(err.Error()), nil
        }
        return mcp.TextResult(fmt.Sprintf("工单 %s 已创建,ID: %s", o.Title, o.ID)), nil
    })
}

这个结构的好处是:Tool handler 只负责参数解析和结果格式化,业务逻辑完全在应用层,应用层又依赖领域层的规则。 三层各司其职,改任何一层都不会牵连其他层。

3.4 Repository 模式:基础设施可替换

MCP Server 有一个很现实的需求:同一套业务逻辑,可能需要跑在不同的基础设施上。开发环境用 SQLite,测试环境用 PostgreSQL,生产环境可能还要加一层缓存。

Repository 接口定义在领域层:

go 复制代码
// domain/order/repository.go
type Repository interface {
    Get(ctx context.Context, id string) (*Order, error)
    Save(ctx context.Context, o *Order) error
    ListByWorkspace(ctx context.Context, wsID string, opts ListOptions) ([]*Order, error)
}

具体实现在基础设施层:

go 复制代码
// infrastructure/postgres/order_repo.go
type OrderRepo struct {
    db *sql.DB
}

func (r *OrderRepo) Get(ctx context.Context, id string) (*order.Order, error) {
    row := r.db.QueryRowContext(ctx, "SELECT id, title, workspace_id, status, assignee_id, created_at FROM orders WHERE id = $1", id)
    var doc orderRow
    if err := row.Scan(&doc.ID, &doc.Title, &doc.WorkspaceID, &doc.Status, &doc.AssigneeID, &doc.CreatedAt); err != nil {
        return nil, err
    }
    return doc.toEntity(), nil
}

对 MCP Server 来说,这意味着你可以非常容易地写集成测试------用一个内存实现的 Repository 替换掉真实数据库,测试跑得飞快,又不用起任何外部依赖。

四、MCP 协议的特性反过来也在"呼唤" DDD

前面说的是 DDD 如何解决 MCP Server 开发的痛点,反过来看,MCP 协议本身的设计也在"暗示"你应该用 DDD 的方式来组织代码。

4.1 Tool 的 Description 就是一份"统一语言"

DDD 强调团队要建立统一语言(Ubiquitous Language)。而 MCP 的 Tool 定义天然要求你用清晰、无歧义的自然语言描述每个操作------因为大模型要靠这些描述来理解该在什么场景下调用这个 Tool。

go 复制代码
mcp.Tool{
    Name:        "assign_order",
    Description: "将一个待分配的工单指派给指定处理人。工单必须处于 open 状态,且处理人必须属于该工作空间的团队成员。",
}

当你认真地写这段描述时,你其实已经在做领域建模了------定义实体、明确前置条件、约束业务规则。如果你的代码结构也按同样的语言来组织,Tool 描述和代码实现之间就能保持一致,维护成本大幅降低。

4.2 Resource 和 Domain Entity 天然映射

MCP 的 Resource 用 URI 标识,比如 order://workspace-123/orders/order-456。这和 DDD 中的聚合根(Aggregate Root)思路非常像:每个 Resource 对应一个有唯一标识的领域实体,通过聚合根来访问。

go 复制代码
s.server.AddResource(mcp.Resource{
    URI:         "order://workspaces/{workspace_id}/orders/{order_id}",
    Name:        "工单详情",
    Description: "获取指定工作空间下某个工单的完整信息",
    MimeType:    "application/json",
}, func(ctx context.Context, uri string) (*mcp.ResourceContent, error) {
    wsID, orderID := parseOrderURI(uri)
    o, err := s.orderUseCase.GetOrder(ctx, wsID, orderID)
    if err != nil {
        return nil, err
    }
    return mcp.JSONContent(o), nil
})

4.3 多 Server 协作 ≈ 上下文映射

真实场景中,一个 AI Agent 可能同时连接多个 MCP Server。比如一个自动化运维流程,可能涉及:

  • 监控系统 MCP Server(查询告警、拉取指标)
  • 工单系统 MCP Server(创建工单、流转处理)
  • 发布系统 MCP Server(触发回滚、灰度发布)

每个 Server 有自己的领域模型,Server 之间需要协调。这和 DDD 中的上下文映射(Context Map)类似,你需要定义清楚各个 Server 之间的关系------是共享内核、是防腐层、还是发布/订阅。

五、实践中的取舍

说了这么多好处,也聊聊实际的。

也不是所有 MCP Server 都需要 DDD。 如果你的 Server 就暴露三五个 Tool,业务逻辑很薄,那直接写 handler 反而更高效。引入 DDD 的分层会增加代码量和认知成本,收益不一定覆盖成本。

DDD 的分层不必一步到位。 我的建议是先按"协议层 - 应用层 - 基础设施层"三层来拆,领域层的建模可以随着业务复杂度的增长逐步引入。很多时候你一开始不知道边界在哪,做着做着才能看清楚。

警惕过度设计。 不要为了"符合 DDD"而硬拆。如果一个概念在你的业务里就是个简单的 CRUD,没有复杂的状态流转和业务规则,那就老老实实用贫血模型。把精力留给真正复杂的核心域。

六、思考和总结

回到标题的问题:DDD 架构为什么适合 MCP Server 开发?

总结下来三点:

  1. MCP 的 Tool/Resource/Prompt 三件套,天然对应 DDD 的应用服务、领域实体和统一语言。 两者的思维模型高度一致。
  2. MCP Server 通常作为已有业务系统的"AI 门面"存在,需要复用而非重写业务逻辑。 DDD 的分层架构让业务规则独立于协议层,可以被多个入口(HTTP、gRPC、MCP)共享。
  3. MCP 生态鼓励多 Server 协作,这要求每个 Server 有清晰的领域边界和明确的对外契约。 这正是 DDD 限界上下文和上下文映射要解决的问题。

当然,架构永远是手段不是目的。选择 DDD 不是因为它看着"高级",而是因为在特定场景下,它确实能帮你写出更容易维护、更容易测试、更容易演进的代码。

相关推荐
Lee川2 小时前
从零构建智能对话系统:AI Agent 实战指南
人工智能
苏三说技术2 小时前
Prompt、Agent、Function Call、Skill、MCP,傻傻分不清楚?
后端
小码哥_常3 小时前
Spring Boot接口幂等保护:一个注解开启数据一致性守护
后端
我叫黑大帅3 小时前
Go中的interface的两大用法
后端·面试·go
冬奇Lab3 小时前
一天一个开源项目(第43篇):Star-Office-UI - 像素风格的 AI 办公室看板,让 AI 助手的工作状态可视化
人工智能·开源·资讯
神秘的猪头3 小时前
🚀 React 开发者进阶:RAG 核心——手把手带你玩转 Milvus 向量数据库
数据库·后端·llm
navms3 小时前
NameServer 极简设计的哲学
后端
小码哥_常3 小时前
Spring Boot 4.0 牵手RabbitMQ死信队列,解锁消息处理新姿势
后端
於時光清歌3 小时前
Git基础使用
后端