0.前言

DDD(Domain Driven Design),领域驱动设计,不少同学可能会觉得它偏向理论,在实际业务场景中是难以发挥作用的。这里有一引自Martin Fowler在《企业应用架构模式》之言:

  1. Then there's the matter of what comes under the term “business logic”.I find this a curious term because there are few things that are less logical than business logic.

大意是:很难找到比业务逻辑更没有逻辑的事情。这句话过于真实地反映了业务开发的心声,同时也揭示出DDD落地的困境:
1.业务本身很简单,加上DDD又显得大材小用。
2.业务本身已经很复杂,使用DDD容易使得技术复杂度也增加,从而使得开发成本过高。
以上困境其实就是:是否应该使用DDD和如何降低DDD的落地成本
首先可以明确的是,业务简单的项目确实没必要“硬上”DDD,可以从面向对象的编程思想、SOLID原则或者更直接地从推动代码规范下手解决问题。DDD的诞生就是用来解决复杂业务的维护问题的,正是因为业务复杂且多变,我们才需要把技术细节和业务逻辑的耦合度降低,从而达到业务和技术哪一方发生变动都不会影响对方的目的,DDD尝试通过其自有的原则与套路来解决软件的复杂性问题,它将开发者的目光首先聚焦在业务本身上,使技术架构和代码实现成为软件建模过程中的“副产品”;
关于降低成本,我认为主要有两点可以:1.按照项目实际情况定制合适的DDD之道,以DDD和SOLID原则为指导思想,定制份能够在团队可执行的代码规范即可;2.使用一份详细的代码规范,具体从命名、分层、甚至异常处理,让同学们可以先知道代码该怎么写,后面再潜移默化地互相学习一些编程思想,可以定期组织code review会议保证代码质量,由于DDD需要设计领域和实体,所以也需要加入技术设计的规范。
在我看来,DDD要落地也没多复杂,具体要做什么,我们可以先简单分为三层:API、Domain、Infrastructure

  • API层:应用服务层对业务步骤进行分解
  • Domain层:领域层使用面向对象的思维编程,以业务语言为标准建立领域模型
  • Infrastructure:隔离底层实现细节

image.png
接下来,本文将分为两部分阐述,第一部分会讲一些DDD的基础理论,第二部分则是实战结论。
关于DDD的理论知识大家完全可以自己找书来看,所以我在这里主要是结合自己的理解与实战经验,以一种简单、清晰的方式尽量把DDD中比较重要的术语给解释清楚。如果你是对DDD理论十分了解的同学则完全可以跳过第一部分,直接奔向第二部分的实战结论。

1.基础篇

领域与限界上下文

领域是业务领域的抽象,用于区分不同业务的边界。我们会按照一定的规则将业务逻辑进行分门别类,于是便产生了领域层(Domain)。
根据Eric Evan(下文简称Eric)的理论,业务层应该分为两个层次:应用层和领域层。它们的定义分别如下:

  • 应用层:定义软件可以完成的工作,并且指挥具有丰富含义的领域对象来解决问题,保持精练;不包括业务规则或知识,无业务情况的状态;
  • 领域层:负责表示业务概念、业务状态的信息和业务规则,是业务软件核心。

image.png
所以把AppService(应用服务)定位为领域层的对外接口人,只负责编排领域的工作,不包含具体业务逻辑和状态,DomainService(领域服务)负责具体业务逻辑。
限界上下文是用来确定领域的边界的,同样的一个领域,在不同的限界上下文里是有不同含义的。我们知道语言都有它的语义环境,同样地,为了避免同样的概念或语义在不同的上下文环境中产生歧义,DDD 在战略设计上提出了“限界上下文”这个概念,用来确定语义所在的领域边界。
因此当在一个项目内出现了相似概念时,我们需要使用限界上下文区分其边界,目的是在以后业务蓬勃发展时还可以干净快速地切分出一个独立的微服务。
比如保险值对象在商品领域中,是虚拟商品的一种类型。商品只会关注其售卖相关的属性,比如价格、卖家、标题,但是在保险领域中则更多地关注跟保险规则相关的逻辑,比如保费的计算、保险供应商的选择、保单的创建、顾客的理赔。
通过一个独立的软件包作为限界上下文的边界,跟对API层一样,不同领域之间只能通过AppSerivice进行互相调用,这样即使未来对方变为独立的服务,我们也只需要把AppService替换成二方包,再通过RPC框架进行远程调用。
image.png

实体与值对象

如果说领域是业务的核心,那么领域模型则是领域的核心资产,通常我们会将业务规则、状态、逻辑都放在领域模型之中,而实体与值对象则是两种不同的领域模型,它们都拥有属性、行为。
不同点在于,实体对象有唯一的ID,我们可以对一个实体对象进行多次修改,修改后的数据和原来的数据可能会大不相同,但由于它们拥有相同的 ID,它们依然是同一个实体。
值对象则是通过属性值来识别的对象,它将多个相关属性组合为一个概念整体,用来描述领域的特定方面并且没有标识符。
从业务需求中找出实体和值对象是非常重要的,这关乎领域建模的合理性。我们可以通过事件风暴根据命令、操作或者事件,找出产生这些行为的主体,进而归纳成一个个实体和值对象。
image.png

聚合和聚合根

实体和值对象都只是个体化的对象,它们的行为表现出来的是个体的能力,所以我们需要将依存度高和业务关联紧密的多个实体对象和值对象进行聚类。形成聚合能让实体和值对象协同工作,它用来确保这些领域对象在实现共同的业务逻辑时,能保证数据的一致性。
聚合都会有一个聚合根,聚合根是为了避免由于复杂数据模型缺少统一的业务规则控制而导致聚合和实体之间数据不一致性的问题。传统数据模型中的每一个实体都是对等的,如果任由实体进行无控制地调用和数据修改,很可能会导致实体之间数据逻辑的不一致。
如果把聚合比作组织,那聚合根就是这个组织的负责人。聚合根也称为根实体,它不仅是实体,还是聚合的管理者。首先它作为实体本身,拥有实体的属性和业务行为,实现自身的业务逻辑。其次它作为聚合的管理者,在聚合内部负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑。最后在聚合之间,它还是聚合对外的接口人,以聚合根ID关联的方式接受外部任务和请求,在上下文内实现聚合之间的业务协同。也就是说,聚合之间通过聚合根ID关联引用,如果需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体。
通过聚合进行操作能够体现聚合内实体之间的协同作用,如图所示,ItemDetailEntity作为聚合根,对其所聚合的实体方法进行组合,从而形成一个完整的方法。
image.png

你可能已经注意到了,上面列出的方法一看就只是在内存中对业务状态进行判断,对实体进行转换、计算、组装等,而要将其持久化还需要通过repo接口调用infrastructure层的实现。以一言概之,领域层与实现层的接口交互的代码应该写在哪里才合理呢?

服务

根据Eric的理论,除了实体和值对象外,还有一种称之为服务的领域模型,即DomainService。
服务是用于描述领域概念的方式,主要用于以下三种用途:

  1. 与领域概念相关的操作行为、但不是实体和值对象中固有的部分。
  2. 接口根据领域模型中其他对元素定义。
  3. 操作是无状态的。

由于持久化、缓存、消息的接口都不属实体和值对象固有的部分,所以不应该放在Entity或ValueObject,而应该放在DomainService;DomainService同时应该承接对领域模型对外部元素的操作,这里的“其他元素”可以理解为“通用组件”,比如专门定义了一个Money对象,里面包含了价格、汇率等转换方法。
简而言之,对于DomainService可以有以下几个结论:
1.颗粒度是聚合级别的,即聚合:DomainService=1:1。
2.领域模型所有涉及实现层接口的操作均统一收拢在DomainService。
image.png

2.实战篇

DDD分层规范

分层定义

分层定义跟《DDD落地实战总结》大体一致,这里直接援引一张宏观设计图
image.png
分层细节则根据平台侧的实践总结会有所差异,差异用红色字体标注
首先为了减少复杂度,这里对实体的定义进行了泛化,我们将每个根部(最外层的)Entity默认为是聚合根

  • API层
    • 应用入口与触发入口
    • 主要包括HSF接口, 定时任务接口实现, 事件监听等
  • Domain层
    • DDD核心逻辑
      • AppService负责对DomainService进行编排
      • DomainService负责:1.封装跨多个聚合之间的事务;2.封装通用型方法,使用场景为:当有些方法想在多个AppService上复用,所以不想绑定在实体上。3.涉及到外部聚合或实体的方法,比如一个策略模式的封装,通常需要涉及到策略执行实体和策略对象。
      • Entity提供原子能力
    • 定义技术细节接口
      • 如持久层/ 缓存/ 消息通知以供infrastructure实现
  • Infrasturcture层

    • 提供技术细节所有实现

      项目结构详解

      image.png

    • ecbp-xxx.open (对外二方包)

      • hsf接口定义/ 领域事件定义
      • 不能有逻辑代码, 不能引入二方或三方包
    • ecbp-xxx-common (项目内部通用模块)
      • 项目内部的公共定义:常量、异常、工具类
      • 不能有逻辑代码, 不能引入二方或三方包
    • ecbp-xxx-api
      • 入口触发
      • hsf/ job/ 消息等实现
    • ecbp-xxx-domain
      • 领域核心资产
        • applicationservice/ entity/ domainService
      • 定义infrastructure接口
      • 按子域分包
    • ecbp-xxx-infrastructure
      • 技术细节实现
        • 数据库/ 缓存/ 跨域调用封装/ switch配置/ 加解密/ 事件
    • ecbp-xxx-adapter
      • 旧模块防腐层
      • 隔离旧代码接口调用封装, 如名服/ maga之类的
    • ecbp-xxx-start
      • pandoraboot启动包
      • 不应该有逻辑代码

我们把common模块定位为项目内部的通用代码,不能暴露出去,所以open模块没有依赖commono,open用到的常量、枚举等都在其包下多写一份。

对象分层约定

对象的类型大致分为:Entity、Data Object(DO)、Business Object(BO)、Entity、Data Transfer Object (DTO)
对象分层模型 (1).png

  • API层和Client调用外部系统时使用DTO
    • DTO是对外暴露接口入参, 只使用在API内
  • Domain层使BO和Entity

由于Domain层不能依赖外部和底层实现,所以使用BO作为数据传输载体,BO可以和DTO保持一致但不能实现序列化接口。
将方法入参的BO按照用途分别把后缀对应为:

  • 查询——QueryBO
  • 增删改——CommandBO
  • 事件——EventBO

AppService返回BO到API层,API层自行转为DTO,不可直接在AppService返回DTO
DomainService查询场景建议返回Entity,有些场景也可返回BO

  • Infrastructure

    • Factory入参沿用RequestObject, 返回Entity
    • Repo入参使用Entity
    • Domain出入参都使用ValueObject
    • EventProducer使用Event

      编码约定

  • ecbp-xxx-open模块:

    • 【强制】作为二方包提供出去的模块,要尽量减轻依赖
    • 【强制】接口定义要遵循SRP单一职责原则,不要定义一个大而全的接口
    • 【建议】接口入参和返回均定义为对象,有两个原因:1.对象里增加字段是向下兼容的。2.字段意图和作用更加清晰,调用者在引入二方包时在不选择Downlpad Sources时也能清晰地看见字段命名。

反例:

public interface InsurancePolicyHSFService {
    ResultDTO<Long> createPolicy(InsurancePolicyReq var1);

    ResultDTO<InsurancePolicyDTO> getInsurancePolicyByOrderNo(String var1);

    ResultDTO<Integer> calculateInsuranceFee(Long var1, Integer var2, Long var3);
}

正例:

public interface FundMerchantRegisterService {
    FundRespSupport<MerchantRegisterApplyRespDTO> apply(MerchantRegisterApplyReqDTO var1);

    FundRespSupport<MerchantRegisterHandleResultDTO> queryResult(MerchantRegisterQueryReqDTO var1);

    FundRespSupport<MerchantModifyRespDTO> modify(MerchantModifyReqDTO var1);
}
  • 看第一种方式,入参、返回值均不清晰,接口升级向下兼容难。假如不能Downlpad Sources,真的只能去问对方“这方法返回的是啥,那方法第一个参数是啥,第二个参数…”;后续要扩展参数和返回值大概率也只能增加方法了吧……基于以上的原因还是建议同学们采用后者,请善用DTO作为入参和返回。
  • 【建议】接口入参数如果是有枚举意味的字段,那么最好在open包的constant包下加上对应的枚举,且在字段注释上通过{@link xxEnum}或者@see xxEum导航到对应的枚举类。
    • ecbp-xxx-api模块:
  • 【强制】依赖domain模块,通过调用其AppService组装服务能力。
  • 【强制】尽量把判断参数校验、业务状态、组装参数对象、接口兜底等逻辑放在底层去做。否则这些琐碎的判断逻辑、兜底逻辑会过于零散。

反例:

    public ResultDTO<CreateOrderRespDTO> createOrder(@NotNull String bizCode, @NotNull Long buyerId,
        @NotNull GoodsDTO mainGoodsDTO,
        List<GoodsDTO> associatedGoodsDTOList, List<String> couponIds, Integer tag, String extInfo) {

        if (StringUtils.isBlank(bizCode) || ParamUtil.isNullOrEmptyOrZero(buyerId) || mainGoodsDTO == null
            || StringUtils.isBlank(mainGoodsDTO.getGoodsId()) || "null".equals(mainGoodsDTO.getGoodsId())
            || !GoodsType.GOODS.getCode().equals(mainGoodsDTO.getGoodsType()) || ParamUtil.nullAndLeZero(
            mainGoodsDTO.getBuyAmount())) {
            logger.error("createOrder() params error, bizCode:{}, buyerId:{}, mainGoodsDTO:{}, associatedGoodsDTOList:{}, couponId:{}, extInfo:{}",
                bizCode, buyerId, mainGoodsDTO, associatedGoodsDTOList, couponIds, extInfo);
            return ResultUtil.illegalParam();
        }
        if (CollectionUtils.isNotEmpty(associatedGoodsDTOList)) {
            Map<Integer, String> itemTypeMap = new HashMap<>();
            for (GoodsDTO goodsDTO : associatedGoodsDTOList) {
                if (goodsDTO == null || StringUtils.isBlank(goodsDTO.getGoodsId()) || "null".equals(goodsDTO.getGoodsId())
                    || ParamUtil.nullAndLeZero(goodsDTO.getBuyAmount()) || GoodsType.getEnum(goodsDTO.getGoodsType()) == null) {
                    logger.error("createOrder() params error, bizCode:{}, buyerId:{}, mainGoodsDTO:{}, associatedGoodsDTOList:{}, couponId:{}, extInfo:{}",
                        bizCode, buyerId, mainGoodsDTO, associatedGoodsDTOList, couponIds, extInfo);
                    return ResultUtil.illegalParam();
                }
                if (itemTypeMap.containsKey(goodsDTO.getGoodsType())) {
                    return ResultUtil.illegalParam("【" + GoodsType.getEnum(goodsDTO.getGoodsType()).getDesc() + "】虚拟商品重复");
                } else {
                    itemTypeMap.put(goodsDTO.getGoodsType(), null);
                }
            }
        }
        /*if (tag != null && !OrderTagType.NEW_USER_SHARE_DEDUCTIBLE_EXPENSE.getCode().equals(tag)
            && !OrderTagType.OLD_USER_SHARE_DEDUCTIBLE_EXPENSE.getCode().equals(tag)) {
            return ResultUtil.illegalParam("illegal param:[tag]");
        }*/

        try {
            CreateOrderContext context = new CreateOrderContext();
            context.setBizCode(bizCode);
            context.setBuyerId(buyerId);
            context.setMainGoodsDTO(CreateOrderDTOConverter.converterGoodsDTO(mainGoodsDTO));
            context.setAssociatedGoodsDTOList(CreateOrderDTOConverter.converterGoodsDTOList(associatedGoodsDTOList));
            context.setCouponIds(couponIds);
            context.setTag(tag);
            context.setCommonParams(getCommonParams());
            context.setAttach(extInfo);
            ResultMessage resultMessage = orderAppService.createOrder(context);

            if (resultMessage.isSuccess()) {
                Map<String, Object> content = resultMessage.getContent();
                CreateOrderRespDTO respDTO = new CreateOrderRespDTO();
                respDTO.setOrderId(content.get(CommonConstants.ORDER_ID).toString());
                return ResultUtil.success(respDTO);
            }
            return ResultUtil.error(resultMessage.getCode(), resultMessage.getMessage());

        } catch (Exception e) {
            logger.error("createOrder() internal error, bizCode:{}, buyerId:{}, mainGoodsDTO:{}, associatedGoodsDTOList:{}, couponId:{}, attach:{}",
                bizCode, buyerId, mainGoodsDTO, associatedGoodsDTOList, couponIds, extInfo, e);
            return ResultUtil.internalError();
        }
    }

正例:

    @Override
    public ResultDTO<OrderItemShotDisplayDTO> getDisplayOrderItemShot(Long uid, String orderId) {

        OrderItemShotDisplayDTO orderItemShotDisplayDTO = orderAppService.getDisplayOrderItemShot(uid, orderId);

        return ResultUtil.success(orderItemShotDisplayDTO);
    }
  • 【建议】一个接口实现类只应该引入一个AppService,如果你的接口需要引入多个,需要分两种情况讨论:
    • 每个方法只会调用某个AppService的方法,但实现类中的方法分别需要不同的AppService。说明你的类可能已经违反单一职责原则,或者你的AppService划分维度有问题,导致它不能承载单一业务领域的所有服务。
    • 对多个AppService的方法进行了组合。编排服务是AppService的责任。
      • ecbp-xxx-domain:
  • 【强制】严格分层、平级不可互相调用,更不可跨层级调用,必须利用上层调用下层接口。这样做的意义是调用链的复杂度降低,不会出现蜘蛛网一样的调用关系。
    • 应用服务层:AppService、Activity
    • 领域服务层:DomainService、Process、Entity、ValueObject
    • 技术实现接口层:Repo、Client、Cache、Message接口

建议通过DomainService调用Repo、Cache、Client或Message返回Entity或ValueObject,再利用Entity的操作方法计算出结果返回。(参考对象分层的图)
正例:

@Service
public class DeliveryServiceDomainServiceImpl implements DeliveryServiceDomainService {
    @Autowired
    ItemCoreClient itemCoreClient;
    @Autowired
    DeliveryServiceCache cache;

    @Override
    public DeliverServiceValueObject getDeliverService(ServiceBatchQueryBO serviceBatchQuery) {
        if (Objects.isNull(serviceBatchQuery) || StringUtils.isBlank(serviceBatchQuery.getVirtualItemId())) {
            return null;
        }
        ServicePackageBO servicePackage = cache.getDeliveryServiceCache(serviceBatchQuery.getVirtualItemId());
        if (Objects.isNull(servicePackage)) {
            servicePackage = this.itemCoreClient.getDeliveryService(serviceBatchQuery);
            if (Objects.nonNull(servicePackage)) {
                cache.setDeliverServiceCache(servicePackage);
            }
        }
        if (Objects.nonNull(servicePackage)) {
            return new DeliverServiceValueObject(servicePackage.getId(), VirtualItemTypeEnum.SERVICE, servicePackage.getTitle(), servicePackage.getDesc(), servicePackage.getPrice());
        }
        return null;
    }
}
  • 【强制】单一职责,DomainService尽量不要引入其他DomainService的方法,如果有则说明此DomainService职责不单一,引入实现层接口也只引入跟自己相关的接口。

反例(这个类总共2337行,只贴出spring注入的属性给大家感受一下)

Component
public class OrderDomainServiceImpl implements OrderDomainService {

    private static final Logger logger = Logger.getLogger(OrderDomainServiceImpl.class);

    @Autowired
    private OrderInfoRepo orderInfoRepo;

    @Autowired
    private ItemShotRepo itemShotRepo;

    @Autowired
    private FulfillmentOrderRepo fulfillmentOrderRepo;

    @Autowired
    private DiscountShotRepo discountShotRepo;

    @Autowired
    private OrderTagRepo orderTagRepo;

    @Autowired
    private MainPayOrderRepo mainPayOrderRepo;

    @Autowired
    private PayOrderRepo payOrderRepo;

    @Autowired
    private OrderPageBtnConfigRepo orderPageBtnConfigRepo;

    @Autowired
    private PageDisplayConfigRepo pageDisplayConfigRepo;

    @Autowired
    private ExtPointExecutor extPointExecutor;

    @Autowired
    private MemberDomainClient memberDomainClient;

    @Autowired
    private RefundOrderDomainService refundOrderDomainService;

    @Autowired
    private ECBPTradeIndexClient ecbpTradeIndexClient;

    @Autowired
    private OrderStatusUpdateEventProducer updateEventProducer;

    @Autowired
    private RefundRDBRepo rdbRepo;

    @Autowired
    private TradeRuleConfigRepo ruleConfigRepo;

    @Autowired
    private OrderDeliverInfoRepo deliverInfoRepo;

    @Autowired
    TradeRuleConfigRepo tradeRuleConfigRepo;

    @Autowired
    private OrderEntityToValueObjectMapper orderEntityToValueObjectMapper;

    @Autowired
    private BizOrderClient bizOrderClient;

    @Autowired
    private FundClearDomainClient fundClearDomainClient;

    @Autowired
    private FulfillmentDomainClient fulfillmentDomainClient;

    @Autowired
    private InsuranceEventProducer insuranceEventProducer;

    @Autowired
    private MarketingDomainClient marketingDomainClient;

正例:可以根据用途把OrderDomainService可以拆出:
OrderDisplayDomainService(订单展示相关,包括页面的对象)、OrderItemDomainService(订单跟商品相关)、OrderInfoDomainService(订单的状态流转等核心信息)。

  • 【强制】一个入参对象就用在一个方法,不可以定一个大而全的参数对象到处在用。

反例(这个入参对象有38个引用,横跨了多个DomainService和Activity):
image.png

  • 【建议】使用JSR-303:Bean Vallidation代替入参校验,且校验注解要放在domain模块的BO,不可透出到open包的DTO污染对方。使用的好处是能把校验规则收拢在入参对象、使用成本低、校验信息全;ecbp-common包会拦截vailidateException,把校验错误信息放到ResultDTO.errorMessage。

正例:

@Validated
public interface ItemQueryAppService extends ApplicationService {

    /**
     * 查询页面商详
     * @param queryBO
     * @return
     */
    ItemPageDetailBO getItemPageDetail(@Valid ItemPageDetailQueryBO queryBO);

    /**
     * 商详页推荐商品列表
     * @param queryBO
     * @return
     */
    List<RecommendItemInfoBO> getItemPageDetailRecommendItems(@Valid ItemRecommendQueryBO queryBO);

    /**
     * 查询商品详情
     *
     * @param queryBO
     * @return
     */
    ItemDetailBO getItemDetail(@Valid ItemDetailQueryBO queryBO);

    /**
     * 获取商品敏感信息
     *
     * @param itemSensitiveInfoQueryBO
     * @return
     */
    ItemSensitiveInfoBO getItemSensitiveInfo(@Valid ItemSensitiveInfoQueryBO itemSensitiveInfoQueryBO);

    /**
     * 获取商品需要检查的属性
     *
     * @param query
     * @return
     */
    ItemCheckInfoBO getItemCheckInfo(@Valid ItemCheckInfoQueryBO query);
}
@Data
public class ItemRecommendQueryBO implements Serializable {

    private static final long serialVersionUID = -4280096944419738444L;

    /**
     *  业务身份
     */
    private String bizCode;

    /**
     * 商品id
     */
    @NotBlank
    private String itemId;

    /**
     *
     */
    private Integer page;

    /**
     * 每页条数
     */
    @NotNull
    @Min(BusinessConstant.RECOMMEND_MIN_PAGE_SIZE)
    @Max(BusinessConstant.RECOMMEND_MAX_PAGE_SIZE)
    private Integer pageSize;

    /**
     * ssids
     */
    private String ssids;

    /**
     * 端 h5
     */
    private String terminal;

}
  • 通用:

【建议】善用MapStruct进行对象转换,这是一种性能与成本俱佳的方案。由于分层隔离,项目中不可避免地存在较多对象的转换,比如DTO与BO的互转、DO与Entity、ValueObject的互转、Entity与BO互转,之所以向大家安利MapStruct,因为这确实是我使用过最好的对象转换方案了。

DDD技术方案设计规范

DDD技术方案设计中,领域建模毫无疑问是最重要的一环。领域建模的方式很多种,比如著名的四色建模、OOAD还有事件风暴,我们倾向于使用事件风暴,理由是:只要把需求中包含的所有事件都罗列出来,那我们设计的领域模型一定能实现全部需求。

事件风暴

  • 与会人员:产研测

通过事件风暴我们可以归纳出承载业务的实体和聚合,这便是领域建模。而领域建模是统一团队语言的重要过程,因此建议团队内部都应该参与事件风暴,这样既能高效建立起团队的通用语言,也能使得领域模型更容易与代码实现保持一致。

  • 会议流程:四步骤

我认为可以把事件风暴具体可以分为这样四步:
第一步:根据需求罗列出命令、事件。
第二步:从命令和事件可以提取产生这些行为的实体。
第三步:从实体中找出聚合根。比如,商品可以管理商品基础信息、商品拓展信息、商品特征、商品标签,可以归纳出商品为聚合根。然后根据业务依赖和业务内聚原则,将聚合根以及它关联的实体和值对象组合为聚合。
第四步:划定限界上下文,根据上下文语义将聚合归类,比如交易领域可以按照“正向”和“逆向”两个流程分出order和refund两个限界上下文。

  • 输出物:事件风暴战术设计图
    • 事件风暴要有一定的输出物,按照大家的讨论结果总结成文档以便形成统一的参照物。形式的话我推荐要有一张事件风暴的图,按照命令、实体、事件、补充信息排列。

image.png

3.参考资料

《领域驱动设计——软件核心复杂性应对之道》——Eric Evan
《企业应用架构模式》——Martin Fowler
《殷浩详解DDD系列 第四讲 - 领域层设计规范》——殷浩同学在ATA发表的文章