跳转到正文
zeno's blog
返回

DDD(一):领域驱动设计概览

专题: DDD

Table of contents

Open Table of contents

DDD 解决什么问题

没有 DDD 的项目:
  代码按技术分层: controllers/ models/ services/ utils/
  业务逻辑散落在 controller 里、service 里、甚至 SQL 里
  新人来了看不懂业务,改一个功能要改 5 个文件
  "这个扣血逻辑在 PlayerService 里还是 CombatService 里?两边都有一点"

DDD 的核心主张:
  按业务领域组织代码,而不是按技术层次
  让代码结构反映业务结构
  业务逻辑集中在领域层,不泄漏到基础设施

核心概念

1. 统一语言(Ubiquitous Language)

开发团队和业务方必须用同一套术语。代码里的命名 = 业务文档里的命名。

业务方说: "玩家从背包中取出物品放到市场上架"
代码应该:
  player.inventory.remove_item(item_id)
  marketplace.list_item(item, price)

而不是:
  db.update("items", {container_id: null})
  db.insert("listings", {item_id, price})

术语统一的好处:和策划讨论需求时,代码里的 PlayerInventoryMarketplace 他们都认识。

2. 实体(Entity)

有唯一标识的对象。即使所有属性都变了,只要 ID 相同就是同一个实体。

struct Player {
    id: PlayerId,        // 唯一标识
    name: String,        // 可以改
    level: u32,          // 可以变
    health: f32,         // 每帧都在变
    // 不管怎么变,id 相同就是同一个玩家
}

// ID 用新类型包装,防止和其他 ID 混淆
struct PlayerId(Uuid);
struct ItemId(Uuid);
// PlayerId 和 ItemId 不能互相传递——编译器阻止你犯错

3. 值对象(Value Object)

没有唯一标识,靠值本身来区分。相等 = 所有字段相等。不可变。

#[derive(Clone, PartialEq)]
struct Money {
    amount: u64,     // 分为单位,避免浮点精度问题
    currency: Currency,
}

#[derive(Clone, PartialEq)]
struct Position {
    x: f32,
    y: f32,
    z: f32,
}

// Money(100, RUB) == Money(100, RUB) → true(值相同)
// 没有 id 字段,100 卢布就是 100 卢布

实体 vs 值对象

4. 聚合(Aggregate)

一组相关对象的边界。外部只能通过聚合根(Aggregate Root)访问内部对象。

// Player 是聚合根
// Inventory 和 Item 是聚合内部对象
struct Player {
    id: PlayerId,
    name: String,
    level: u32,
    inventory: Inventory,  // 内部对象,外部不能直接操作
}

struct Inventory {
    items: Vec<Item>,
    max_slots: u32,
}

impl Player {
    // 外部通过 Player(聚合根)操作 Inventory
    pub fn add_item(&mut self, item: Item) -> Result<(), DomainError> {
        if self.inventory.is_full() {
            return Err(DomainError::InventoryFull);
        }
        if self.level < item.required_level {
            return Err(DomainError::LevelTooLow {
                required: item.required_level,
                current: self.level,
            });
        }
        self.inventory.items.push(item);
        Ok(())
    }

    pub fn remove_item(&mut self, item_id: ItemId) -> Result<Item, DomainError> {
        let pos = self.inventory.items.iter()
            .position(|i| i.id == item_id)
            .ok_or(DomainError::ItemNotFound(item_id))?;
        Ok(self.inventory.items.remove(pos))
    }
}

// 错误做法:绕过聚合根直接操作内部
// player.inventory.items.push(item)  ← 跳过了容量检查和等级检查

为什么要聚合? 保证业务不变量(invariant)。“背包不能超过上限”、“等级不够不能装备”这些规则写在聚合根的方法里,外部绕不过去。

5. 领域事件(Domain Event)

领域中发生的有意义的事情。解耦模块间的依赖。

enum DomainEvent {
    PlayerLeveledUp { player_id: PlayerId, new_level: u32 },
    ItemPickedUp { player_id: PlayerId, item: Item },
    TradeCompleted { buyer_id: PlayerId, seller_id: PlayerId, item: Item, price: Money },
    PlayerKilled { victim_id: PlayerId, killer_id: PlayerId, weapon: String },
}

impl Player {
    pub fn gain_exp(&mut self, amount: u64) -> Vec<DomainEvent> {
        let mut events = vec![];
        self.exp += amount;

        while self.exp >= self.exp_to_next_level() {
            self.exp -= self.exp_to_next_level();
            self.level += 1;
            events.push(DomainEvent::PlayerLeveledUp {
                player_id: self.id.clone(),
                new_level: self.level,
            });
        }

        events
    }
}

// 其他模块监听事件做自己的事,不需要知道升级逻辑
// PlayerLeveledUp → 成就系统检查是否解锁成就
// PlayerLeveledUp → 排行榜更新
// PlayerLeveledUp → 推送通知给好友
// 升级逻辑不需要知道这些模块的存在

6. 仓储(Repository)

对持久化的抽象。领域层定义接口,基础设施层提供实现。

// 领域层:只定义 trait(不知道数据库是什么)
#[async_trait]
trait PlayerRepository {
    async fn find_by_id(&self, id: &PlayerId) -> Result<Option<Player>, DomainError>;
    async fn save(&self, player: &Player) -> Result<(), DomainError>;
    async fn find_by_name(&self, name: &str) -> Result<Option<Player>, DomainError>;
}

// 基础设施层:具体实现(知道用的是 PostgreSQL)
struct PgPlayerRepository {
    pool: PgPool,
}

#[async_trait]
impl PlayerRepository for PgPlayerRepository {
    async fn find_by_id(&self, id: &PlayerId) -> Result<Option<Player>, DomainError> {
        sqlx::query_as("SELECT * FROM players WHERE id = $1")
            .bind(id.0)
            .fetch_optional(&self.pool)
            .await
            .map_err(DomainError::from)
    }
    // ...
}

// 测试时可以换成内存实现
struct InMemoryPlayerRepository {
    players: HashMap<PlayerId, Player>,
}

7. 领域服务(Domain Service)

不属于任何单个实体的业务逻辑。通常涉及多个聚合的协调。

struct TradeService;

impl TradeService {
    pub fn execute_trade(
        buyer: &mut Player,
        seller: &mut Player,
        item_id: ItemId,
        price: Money,
    ) -> Result<Vec<DomainEvent>, DomainError> {
        // 业务规则全在这里
        if buyer.wallet.balance < price {
            return Err(DomainError::InsufficientFunds);
        }

        let item = seller.remove_item(item_id)?;
        buyer.add_item(item.clone())?;
        buyer.wallet.deduct(price.clone())?;
        seller.wallet.add(price.clone())?;

        Ok(vec![DomainEvent::TradeCompleted {
            buyer_id: buyer.id.clone(),
            seller_id: seller.id.clone(),
            item,
            price,
        }])
    }
}

限界上下文(Bounded Context)

大系统里不同模块对同一个词有不同理解:

"玩家" 在不同上下文中的含义:

战斗上下文:    Player = { id, position, health, weapon, velocity }
社交上下文:    Player = { id, name, friends, online_status }
交易上下文:    Player = { id, wallet, inventory, trade_history }
匹配上下文:    Player = { id, skill_rating, queue_time, region }

强行用一个 Player 结构体塞所有字段 → 变成上帝对象。

每个限界上下文有自己的 Player 定义,上下文之间通过 ID 关联,通过事件或 API 通信。

mini_tarkov 的限界上下文:

┌── 战斗上下文 ──┐  ┌── 交易上下文 ──┐  ┌── 社交上下文 ──┐
│ Player(战斗)   │  │ Player(交易)   │  │ Player(社交)   │
│ Weapon         │  │ Inventory     │  │ FriendList    │
│ DamageCalc     │  │ Marketplace   │  │ ChatMessage   │
│ HitDetection   │  │ TradeService  │  │ Notification  │
└───────┬────────┘  └───────┬────────┘  └───────┬────────┘
        │                   │                    │
        └───── 通过 PlayerId 关联 + 领域事件通信 ──┘

DDD 在 Rust 项目中的目录结构

src/
├── domain/                    # 领域层:纯业务逻辑,无外部依赖
│   ├── mod.rs
│   ├── player.rs             # Player 聚合根 + Inventory
│   ├── item.rs               # Item 实体
│   ├── trade.rs              # TradeService 领域服务
│   ├── events.rs             # DomainEvent 枚举
│   ├── errors.rs             # DomainError
│   └── repository.rs         # Repository trait 定义(只有接口)

├── application/               # 应用层:编排用例,调用领域层
│   ├── mod.rs
│   ├── commands.rs           # CreatePlayer, AddItem, ExecuteTrade...
│   └── handlers.rs           # 用例处理:协调 repository + domain service

├── infrastructure/            # 基础设施层:具体实现
│   ├── mod.rs
│   ├── postgres.rs           # PgPlayerRepository 实现 Repository trait
│   ├── redis.rs              # Redis 缓存实现
│   └── config.rs             # 配置加载

├── api/                       # 接口层:HTTP/gRPC handler
│   ├── mod.rs
│   ├── http.rs               # axum handler
│   ├── grpc.rs               # tonic handler
│   └── dto.rs                # 请求/响应的数据传输对象

├── lib.rs
└── main.rs

DDD 不是银弹

适合 DDD 的:
  业务逻辑复杂(交易系统、匹配系统、战斗规则)
  业务规则经常变化
  多团队协作,需要清晰的边界

不适合 DDD 的:
  简单 CRUD(博客、TODO 应用)— 过度设计
  数据密集型管道(ETL、日志处理)— 没什么领域逻辑
  原型/MVP — 先跑起来再说

DDD 的代价:
  更多的抽象层 → 代码量增加
  Repository trait → 需要维护接口 + 实现
  值对象 → 需要大量的 From/Into 转换
  前期设计成本高(需要理解业务领域)

分享这篇文章:

上一篇
DDD(二):领域事件
下一篇
mio(七):陷阱与常见错误