2、库存领域模型设计:DDD领域驱动设计入门、库存核心聚合、领域事件与限界上下文

好,咱们进入正题。

库存系统,说白了就是管「货」的。但货怎么管?管哪些东西?这就涉及到领域模型设计了。我见过不少团队,一上来就建表、写CRUD,结果业务一复杂,代码就成了一团乱麻。嗯,今天我们就用DDD的思路,把库存的根儿捋清楚。

2.1 DDD领域驱动设计入门

DDD,全称是Domain-Driven Design。很多人一听就觉得高大上,其实没那么玄乎。它的核心思想就一句话:让代码模型跟业务模型对齐

你想想看,业务上说的是「入库」、「出库」、「盘点」,代码里却只有「insert」、「update」、「select」,这中间就有鸿沟。DDD就是填这个沟的。

核心概念速览:

  • 实体(Entity):有唯一标识,会变化。比如一个商品,它的库存数量会变,但商品ID不变。
  • 值对象(Value Object):没有ID,只描述属性。比如「地址」、「金额」。
  • 聚合(Aggregate):一组相关对象的集合,有根。比如「订单」聚合了「订单项」。
  • 领域事件(Domain Event):业务中发生的重要事情。比如「库存不足」就是一个事件。
  • 限界上下文(Bounded Context):划清边界,每个上下文有自己的语言。

我个人习惯,在开始写代码之前,先跟业务方聊透。聊什么?聊他们口中的「库存」到底指什么。是仓库里的实物?还是系统里的记录?还是可售库存?这三个东西,在DDD里可能就是三个不同的聚合。

2.2 库存核心聚合

库存系统里,最核心的聚合有三个:商品仓库库存记录。我一个个说。

2.2.1 商品聚合

商品,是库存的「标的物」。它本身不存数量,但它是所有库存操作的依据。

// 商品聚合根
public class Product : AggregateRoot
{
    public string ProductId { get; private set; }
    public string Sku { get; private set; }
    public string Name { get; private set; }
    public string CategoryId { get; private set; }
    
    // 商品规格(值对象)
    public ProductSpecification Spec { get; private set; }
    
    // 领域方法
    public void ChangeCategory(string newCategoryId)
    {
        // 这里可以加业务规则
        CategoryId = newCategoryId;
        AddDomainEvent(new ProductCategoryChangedEvent(this));
    }
}

注意,商品聚合里不包含库存数量。为什么?因为数量是跟仓库绑定的。同一个商品,在不同仓库里数量不同。你把它放商品里,就乱套了。

我的经验: 曾经有个项目,把库存数量直接挂在商品表上。结果一做多仓,代码改得想哭。后来拆成独立的库存记录聚合,世界清净了。

2.2.2 仓库聚合

仓库,是库存的「容器」。它定义了库存的物理位置和属性。

// 仓库聚合根
public class Warehouse : AggregateRoot
{
    public string WarehouseId { get; private set; }
    public string Code { get; private set; }
    public string Name { get; private set; }
    public Address Location { get; private set; } // 值对象
    public WarehouseType Type { get; private set; } // 枚举:实体仓、虚拟仓
    
    public bool IsActive { get; private set; }
    
    public void Deactivate()
    {
        if (!IsActive) return;
        IsActive = false;
        AddDomainEvent(new WarehouseDeactivatedEvent(this));
    }
}

仓库本身不存商品数量,它只描述「我在哪儿、我是什么类型」。真正的数量,在库存记录里。

2.2.3 库存记录聚合

这才是库存系统的「心脏」。库存记录,是商品+仓库+数量的组合。

// 库存记录聚合根
public class InventoryRecord : AggregateRoot
{
    public string RecordId { get; private set; }
    public string ProductId { get; private set; }
    public string WarehouseId { get; private set; }
    
    public int QuantityOnHand { get; private set; }  // 实物库存
    public int QuantityAvailable { get; private set; } // 可售库存
    public int QuantityReserved { get; private set; } // 预占库存
    
    // 核心领域方法
    public void Reserve(int quantity)
    {
        if (quantity <= 0) 
            throw new ArgumentException("数量必须大于0");
            
        if (QuantityAvailable < quantity)
        {
            AddDomainEvent(new InventoryShortageEvent(this, quantity));
            throw new InsufficientInventoryException("库存不足");
        }
        
        QuantityAvailable -= quantity;
        QuantityReserved += quantity;
        
        AddDomainEvent(new InventoryReservedEvent(this, quantity));
    }
    
    public void ReleaseReservation(int quantity)
    {
        // 释放预占的逻辑
    }
}

注意: 库存操作一定要用领域方法,不要直接set属性。为什么?因为每次操作都可能触发业务规则和领域事件。直接set,这些逻辑就丢了。

2.3 领域事件设计

领域事件,是DDD里很妙的一个东西。它让聚合之间可以「松耦合」地通信。

举个例子:当库存不足时,系统需要通知采购部门。如果直接在库存记录里调用采购接口,那就耦合了。正确做法是:抛出领域事件,让其他服务去订阅。

// 领域事件基类
public abstract class DomainEvent
{
    public DateTime OccurredOn { get; } = DateTime.UtcNow;
    public string EventId { get; } = Guid.NewGuid().ToString();
}

// 具体事件
public class InventoryShortageEvent : DomainEvent
{
    public string ProductId { get; }
    public string WarehouseId { get; }
    public int ShortageQuantity { get; }
    
    public InventoryShortageEvent(InventoryRecord record, int shortageQty)
    {
        ProductId = record.ProductId;
        WarehouseId = record.WarehouseId;
        ShortageQuantity = shortageQty;
    }
}

public class InventoryReservedEvent : DomainEvent
{
    public string ProductId { get; }
    public string WarehouseId { get; }
    public int ReservedQuantity { get; }
    
    public InventoryReservedEvent(InventoryRecord record, int qty)
    {
        ProductId = record.ProductId;
        WarehouseId = record.WarehouseId;
        ReservedQuantity = qty;
    }
}

我建议,领域事件命名用「过去时」。比如「InventoryReservedEvent」,表示「库存已经被预占了」。这样语义更清晰。

领域事件的作用:

  • 解耦聚合之间的直接调用
  • 记录业务变更的审计日志
  • 触发跨限界上下文的流程(比如通知采购、更新缓存)

2.4 限界上下文划分

限界上下文,是DDD里最容易被忽略、但最重要的概念。说白了,就是划地盘。

在库存系统里,我一般会划分出这几个上下文:

限界上下文 核心职责 典型聚合
库存核心 管理实物库存、可售库存 InventoryRecord, Product, Warehouse
库存预留 处理订单预占、释放 Reservation, Allocation
库存盘点 实物盘点、差异调整 Stocktake, Adjustment
库存预警 监控库存水位、触发补货 AlertRule, ReplenishmentRequest

每个上下文都有自己的「通用语言」。比如在「库存核心」里,「可用库存」指的是实物减去预占。但在「库存预警」里,「可用库存」可能还要考虑在途库存。同一个词,不同上下文含义不同,这很正常。

避坑指南: 我曾经在一个项目里,试图用一个「库存服务」搞定所有事情。结果服务越来越臃肿,改一个盘点逻辑,影响到了订单预占。后来拆成四个限界上下文,每个独立部署,问题迎刃而解。

2.5 知识体系总览

下面这张图,是我梳理的库存领域模型核心结构。你可以看到,聚合、事件、上下文之间的关系。

库存领域模型核心结构 库存核心上下文 商品聚合 Product 仓库聚合 Warehouse 库存记录聚合 InventoryRecord 库存预留上下文 预占聚合 Reservation 分配聚合 Allocation 领域事件总线 InventoryReservedEvent | InventoryShortageEvent 库存盘点上下文 Stocktake聚合 | Adjustment聚合 库存预警上下文 AlertRule聚合 | ReplenishmentRequest聚合

你看,每个限界上下文都有自己的聚合。它们通过领域事件通信,互不干扰。这就是DDD的魅力所在。

总结一下:

  • DDD的核心是让代码模型对齐业务模型
  • 库存核心聚合:商品、仓库、库存记录
  • 领域事件用过去时命名,解耦聚合间通信
  • 限界上下文划清边界,每个上下文有自己的语言

嗯,这一章的内容就到这儿。记住,模型设计不是一蹴而就的。我每次做新项目,都会跟业务方反复对几轮,直到大家说的「库存」是同一个东西。这个功夫,值得花。


公众号:蓝海资料掘金营,微信deep3321