ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java企业级应用框架设计:DDD+CQRS实战与踩坑记录

Java企业级应用框架设计:DDD+CQRS实战与踩坑记录 如果你的 Java 企业级应用已经开始因为十几个模块共用一套 Service 而头疼那么 DDD 和 CQRS 这套组合多半已经在你的候选清单里。我最近在重构一个订单中台项目时基于 DDD 与 CQRS 设计了一套适合 Java 的企业级应用框架核心思路是先按业务能力拆域再把读写链路彻底分离。整个过程踩了不少坑也推翻了两次上一版的架构设计所以这篇文章会尽量讲清楚“为什么这样做”而不只是贴一张看起来很漂亮的架构图。这套框架适合两类人一类是想在团队里引入 DDD 但不知道怎么落地的同学另一类是已经被 CQRS 的异步读模型和事件风暴绕晕、只想找一个务实方案的开发。我不打算堆砌抽象概念而是直接给出一套能跑的代码骨架、设计取舍和真实踩坑记录。1. 先把问题想明白Java 单体应用为什么会走到 DDD CQRS 这一步1.1 传统 Service 三层结构失控的真实过程大多数 Java 企业项目起步时都是 Controller-Service-DAO开发速度确实快。可只要业务跑两年订单、支付、库存、会员这些模块互相调用Service 层就会迅速膨胀成一个“上帝类”。我见过一个订单 Service 有两千多行里面既有下单校验、优惠计算也有发送短信、修改库存的逻辑。更夸张的是连订单列表的查询条件也塞在同一个方法里。这种结构下最明显的问题不是代码长而是领域规则没有边界。任何想复用订单逻辑的地方都只能调用这个 Service调着调着原本属于促销领域的规则被塞进了订单领域订单领域的状态流转又被营销活动牵制。改一处代码需要把整个调用链都看一遍最后谁都不太敢动。DDD 的价值在这里就体现出来了它强制你先画出业务边界区分核心领域、支撑领域和通用领域再把每个领域内部的对象分成聚合根、实体、值对象。这个动作本身就能暴露出很多“不该由订单 Service 管”的逻辑。1.2 同一个业务对象读写诉求完全不一样当我把订单模块重构到时最先意识到的是同一个 Order根本不应该只有一个实体模型。在写操作这一侧下订单、取消订单、确认收货这些动作需要严格的业务规则。比如“已发货的订单不能取消”“超时未支付要自动关闭”这些规则和状态机强相关要求模型完整、一致、可控。在读操作这一侧用户订单列表、管理后台订单筛选、运营报表统计需要的字段往往来自多张表。比如列表页要显示“商品名称 店铺名 物流状态 实付金额”这些数据分散在不同表里。如果查询也走领域模型就要先加载整个 Order 聚合再关联一堆对象性能差不说还把领域模型搞得臃肿不堪。CQRS 的核心主张就是把“变更数据”的命令链路和“读取数据”的查询链路拆开。命令端保留完整的业务约束查询端则可以针对视图需求单独建模型。拆开之后两边可以独立优化谁也不拖累谁。1.3 不是所有 Java 项目都适合这套框架我也要泼一盆冷水DDD CQRS 不是银弹。如果项目只是一个简单的后台管理系统CRUD 占总逻辑的 80%引入这套框架完全是给自己增加负担。领域模型、事件总线、读模型同步这些概念都会变成团队交流的成本。我建议出现以下情况再考虑这个组合存在多个微服务或模块共同依赖同一个核心领域且该领域规则经常变化。同一份业务数据在写入和读取时结构差异巨大。查询性能成为瓶颈但又不想直接改业务写逻辑。团队中至少有两个人对 DDD 有实践经验并且愿意投入时间做建模。2. 框架整体分层命令端和查询端各自的调用链2.1 四层职责边界接口层、应用层、领域层、基础层这套框架沿用经典 DDD 分层但把查询链路单独拆出来形成这样一个结构接口层interfaces负责接收 HTTP 请求、MQ 消息或 RPC 调用。这层只做参数转换不能写业务逻辑。应用层application负责编排命令和查询但不持有业务规则。应用服务只做“把事情串起来”的动作比如开启事务、调用领域方法、发布事件。领域层domain承载核心业务逻辑包含聚合根、实体、值对象、领域服务和领域事件。基础层infrastructure提供数据库、消息队列、缓存等基础设施实现。在命令端这一层主要实现 Repository 接口在查询端这一层提供读模型的查询实现。2.2 命令端的一次完整调用链当用户发起“创建订单”请求时请求会按下面这条链路流动每一步的职责都固定在特定层Controller - Command - CommandHandler - 聚合根业务方法 - Repository.save - 发布领域事件CommandHandler 处于应用层它拿到 Command 对象后会先做通用校验比如字段非空然后加载聚合根调用聚合根的业务方法业务方法内部会执行真正的规则判断并修改状态。最后 Repository 负责持久化持久化完成后事件总线再发布领域事件。这样做的好处是领域层完全不感知 Spring、MyBatis、Spring Cloud 这些技术细节。聚合根只是一个纯 Java 对象可以方便地做单元测试。2.3 查询端的一次完整调用链查询端不加载聚合根不触发业务规则。它的调用链简单得多Controller - Query - QueryHandler - ReadModel - DataSource / 缓存QueryHandler 直接从读模型查询数据。读模型可以是关系库的视图、一张专门的订单列表表也可以是 Redis 中的缓存结构。它跟领域模型是两套数据互不影响。这种分离带来一个好处我可以针对“订单列表”单独建一张宽表冗余商品名、店铺名这些字段查询时只查一次不用关联十几张表。虽然数据存在冗余但对查询性能来说这是非常划算的。2.4 模块划分与依赖规则我在工程里把框架分成了四个 Maven Module依赖方向从上层指向下层ddd-adapter接口层 ↓ ddd-application应用层含命令和查询处理 ↓ ddd-domain领域层 ↓ ddd-infrastructure基础层查询模型虽然没有领域逻辑但它的接口定义可以放在 application 层或独立的 query-model 包里。具体包结构如下com.example.ddd ├── interfaces │ ├── controller │ ├── command │ └── query ├── application │ ├── commandhandler │ ├── queryhandler │ └── dto ├── domain │ ├── model │ ├── repository │ ├── service │ └── event ├── infrastructure │ ├── repositoryimpl │ ├── readmodel │ └── message └── common依赖规则只有一个核心约束领域层不依赖任何外部框架Repository 接口必须定义在领域层实现放在基础设施层。这个约束保证了领域层是整个项目里最稳定、最不容易被污染的部分。3. 领域层设计聚合根、值对象和 Repository 的正确姿势3.1 聚合根边界到底哪些对象应该归一个聚合管引入 DDD 后团队最先争论的往往是“订单这个聚合根到底该包含哪些子实体”。拿订单来说订单里面通常有订单行OrderLine、收货地址、优惠信息。如果把这几样都塞进订单聚合每次加载订单都要把全部子对象查出来。我的建议是聚合根边界尽量“小”而不是“大”。聚合根解决的是一致性边界问题只有在同一事务里必须保证一致的数据才应该放进一个聚合。订单和订单行属于强一致关系放进同一个聚合。但订单和支付记录就不一定了如果订单必须在“创建”时就校验支付信息那可以放如果支付是异步流程订单和支付就应该分成两个聚合用领域事件或 Saga 协调。实际操作中我给自己定了一个简单的判断标准如果删除订单行时订单总金额需要立即重算并且两者不能出现短暂不一致那就放进同一个聚合。如果只是“展示给用户看”的关系就拆开放。3.2 值对象的建模别把所有字段都当成实体很多从 MyBatis 转过来的同学会觉得数据库表字段就应该一一映射到实体字段但 DDD 里“值对象”才是用来描述度量、金额、地址这些属性的。比如Money如果我在订单实体里定义BigDecimal amount一旦多个地方都需要对金额做单位换算、精度校验代码会特别分散。更合理的做法是定义一个Money值对象public class Money { private final BigDecimal amount; private final String currency; public Money(BigDecimal amount, String currency) { if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(金额不合法); } this.amount amount; this.currency currency; } public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException(币种不一致); } return new Money(this.amount.add(other.amount), this.currency); } public Money multiply(int quantity) { return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency); } // getter }把金额包装成值对象后所有跟金额相关的校验和运算都收拢到一个类里业务代码反而更干净。值对象是不可变的这也符合并发场景下对安全性的要求。3.3 Repository 接口不是 DAO约束来自领域而不是表结构很多开发一写 Repository就下意识把它当成 MyBatis 的 Mapper方法名都是insert、updateById、selectByCondition。但 DDD 里的 Repository 更应该表达领域概念。我习惯在领域层先定义这样的接口public interface OrderRepository { OptionalOrder findById(OrderId id); void save(Order order); void delete(Order order); }接口里没有查询列表、分页排序这些方法因为一个聚合根的全生命周期只应该通过它自己的状态变化来管理。查找聚合根传进去的是OrderId而不是“userId status startTime”这种组合条件。读模型的事交给查询端去处理。领域层命令 Order 执行完业务动作后调用save方法基础设施层负责把聚合根的状态同步到数据库表里。因为 Order 里可能包含多个子实体save 实现里需要自己控制事务和主从表的写入顺序。我分享一个订单聚合根返回的方法示例注意看业务规则是如何写在“方法内部”而不是写在 Application Service 里的public class Order extends AggregateRootOrderId { private OrderId id; private CustomerId customerId; private Money totalAmount; private OrderStatus status; private ListOrderLine orderLines; public static Order create(OrderId id, CustomerId customerId, ListOrderLine orderLines) { if (orderLines null || orderLines.isEmpty()) { throw new IllegalArgumentException(订单必须包含商品行); } Order order new Order(); order.id id; order.customerId customerId; order.status OrderStatus.CREATED; order.orderLines orderLines; order.totalAmount orderLines.stream() .map(line - line.getSubTotal()) .reduce(Money.ZERO, Money::add); order.addEvent(new OrderCreatedEvent(id, customerId, order.totalAmount)); return order; } public void cancel(String reason) { if (status ! OrderStatus.CREATED status ! OrderStatus.PAYMENT_PENDING) { throw new IllegalStateException(当前状态不能取消); } this.status OrderStatus.CANCELLED; this.addEvent(new OrderCancelledEvent(id, reason)); } public void markPaid() { if (status ! OrderStatus.CREATED status ! OrderStatus.PAYMENT_PENDING) { throw new IllegalStateException(只有待支付订单可以标记为已支付); } this.status OrderStatus.PAID; } // 省略 getter }看到没有订单能否取消、能否标记已支付这些规则都写在聚合根内部。外面任何调用方都无法绕过cancel()方法直接改变状态因为status字段没有 public setter。这正是 DDD 和传统 JavaBean 式实体的最大区别——数据的安全和完整由领域对象自己负责。3.4 框架层如何防住“贫血模型回潮”贫血模型指实体只有 getter / setter所有逻辑都在 Service 里这正是很多 DDD 项目失败的原因。要防止回潮单纯靠代码审查是不够的我在框架层做了两件事聚合根需要继承AggregateRoot基类这个基类内部维护领域事件列表并通过模板方法约束事件只能在聚合根内部添加。框架扫描到Entity类型时会检查所有属性是否都有对应的业务方法来修改。如果发现某个实体只能 set而没有任何业务方法使用它会在构建时输出警告。这样做不能完全杜绝乱写但至少让团队在提交代码时不得不考虑“这个逻辑是不是放错了层”。4. 领域事件、读模型与最终一致性从业务事件到查询视图4.1 领域事件什么时候触发领域事件是 CQRS 里命令端和查询端之间的桥梁。在命令端聚合根的状态发生变化后会发布事件。比如OrderCreatedEvent这个事件本身是一次“已经发生过的事实”所以它的命名应该是过去式而不是一个指令。一个容易忽略的细节是领域事件必须在聚合根状态变更且事务提交后再发布。如果在事务还没提交时就发事件读模型去查数据库会查到旧数据产生奇怪的不一致问题。我见过不少项目在 Service 里调用完方法立刻发事件结果消息消费者读不到最新数据。更稳妥的做法是利用 Spring 的TransactionSynchronizationManager在事务成功提交后执行事件发布逻辑Transactional public OrderId handle(CreateOrderCommand command) { Order order Order.create(...); orderRepository.save(order); // 事务提交后发布事件 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { order.publishEvents(eventBus); } }); return order.getId(); }我之前在这个地方踩过一次大坑事件先发布了结果订单表还没提交成功消息消费者那边收到事件后去查订单详情查不到直接报错。改成 afterCommit 之后问题就消失了。4.2 读模型投影从领域事件到列表宽表查询端的数据不一定来自原始表它可以通过监听事件来构建自己的“投影”。我管这套机制叫 ReadModel Projection。最直接的实现方式是监听OrderCreatedEvent后把订单表、商品表、会员表的字段组合成一条订单列表记录写入order_list表或更新到 Redis。这样查询订单列表时就不用再去 join 商品表和会员表了。投影的实现要满足两个基本要求幂等性和顺序性。同一个事件可能因为重复投递被执行两次所以投影逻辑必须支持重入。我通常在宽表里加一个event_id唯一建消费前先查一下是否已处理过避免重复写入。订单超时状态是我遇到的另一个顺序性问题。用户支付成功后支付服务可能先发出PaymentSucceededEvent同时支付立即失效的OrderClosedEvent又触发了两个事件并发到达。如果顺序反了订单状态会从“已支付”被改成“已关闭”。处理方案是给事件加版本号或时间戳更新宽表时判断当前记录版本是否比事件版本旧只有旧版本才允许被更新。4.3 最终一致的延迟怎么向业务方交代CQRS 分离后读写模型之间存在短暂的延迟。一般来说本地数据库读模型同步可以做到毫秒级基本无感。但如果通过 MQ 同步用户提交订单后立刻查列表可能发现新订单还没出现。我的处理经验是把“必须实时读到的数据”留在命令端返回给前端把“可以稍后展示的数据”交给读模型。比如创建订单成功后接口直接返回订单 ID 和总价前端根据返回结果跳转详情页。详情页第一次加载时如果读模型还没更新再走一次兜底查询从订单表实时取数。等到用户第二次进入时读模型大概率已经同步好了。这样既保留了查询端的性能优势又避免用户感知到数据延迟是成本最低的妥协方案。5. Saga 编排与异常恢复订单、库存、支付在分布式环境下的最终一致性5.1 2PC 为什么不适合企业级应用分布式事务常见的方案有 2PC两阶段提交和 Saga。框架里我一开始也考虑过 2PC但很快放弃了。2PC 要求所有参与方在事务期间持有数据库锁如果某个节点宕机整个事务会长时间阻塞。在订单、支付、库存这种跨服务场景里2PC 的吞吐量太低而且要引入独立的协调者组件成本和复杂度都很高。CQRS 框架里更自然的方案是 Saga。Saga 把一个长事务拆成多个本地事务每个本地事务执行完成后发布事件触发下一个步骤。如果中间某一步失败执行反向补偿操作。5.2 编排式 Saga 和协同式 Saga 怎么选Saga 有两种实现方式。编排式 Saga 有一个独立的协调器Orchestrator它知道整个流程步骤负责调用每个服务。协同式 Saga 没有中心协调器每个服务消费事件后自行决定下一步动作。我推荐订单类流程优先使用编排式 SAGA因为订单流程环节多、状态明确用中心化协调器更好排查问题。比如创建订单后Saga Orchestrator 负责依次调用库存服务、支付服务任何一个环节失败了它都知道应该补偿哪些步骤。伪代码示例public class OrderCreateSaga { private final InventoryClient inventoryClient; private final PaymentClient paymentClient; private final OrderCommandService orderCommandService; public void onOrderCreated(OrderCreatedEvent event) { // 1. 锁定库存如果失败直接取消订单 boolean locked inventoryClient.tryLock(event.getOrderItems()); if (!locked) { orderCommandService.cancelOrder(event.getOrderId(), 库存不足); return; } // 2. 请求支付 PaymentResult paymentResult paymentClient.pay(event.getOrderId(), event.getTotalAmount()); if (paymentResult.isSuccess()) { orderCommandService.markPaid(event.getOrderId()); } else { inventoryClient.unlock(event.getOrderItems()); orderCommandService.cancelOrder(event.getOrderId(), 支付失败); } } }如果项目里只有少量跨服务事件协同式 Saga 更轻。它的缺点是流程隐含在各个事件处理器里出了问题要串联事件记录才能看清全貌。我一般建议代码里额外画一张“事件流转图”存到文档里否则三个月后连写代码的人都看不懂。5.3 超时、幂等与补偿的落地细节分布式环境下超时按“结果未知”而非“失败”处理这是一个基本认知。支付超时后订单可能实际已经支付成功了只是响应丢了。这时代理如果立刻补偿取消订单就会造成“先取消后支付成功”的严重问题。我使用的策略是把 Saga 流程建模成一张状态机步骤状态包括状态说明下一步动作PENDING等待处理触发调用PROCESSING已发起调用设置超时定时器SUCCESS业务成功进入下一节点FAILED明确失败执行补偿COMPENSATING补偿中调用反向接口COMPENSATED补偿完成流程结束所有状态变化都持久化到 Saga 日志表。收到超时消息后先查询当前状态如果状态是 PENDING 或 PROCESSING才允许发起重试查询调用查询接口确认最终结果而不是立刻走补偿。这样的设计既解决了超时的模糊性也让运维可以在页面上直观地看到 Saga 走到哪一步了。5.4 一个取消订单的 Saga 操作对照我把常见操作和它的反向补偿操作列了一个表做 Saga 的时候可以直接参考正向操作反向补偿操作补偿触发条件锁定库存释放库存订单取消或支付超时创建支付单关闭支付单支付流程异常扣除优惠券返还优惠券业务失败生成物流单作废物流单支付未完成或订单取消注意补偿操作本身也需要幂等。比如释放库存请求被重复调用时不能出现“库存被多释放一次”的情况。我在库存服务里用“原始锁定单号 操作类型”作为唯一键重复补偿请求直接返回成功。6. 手写一个最小可运行的框架核心注解、命令总线和处理器注册6.1 框架核心注解与启动注册机制为了让这套框架有“框架感”而不是散落一堆代码我实现了几个基础注解和一套处理器注册机制。命令处理器通过CommandHandler注解被框架识别Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface CommandHandler { Class? commandType(); }在 Spring Boot 启动时打上这个注解的类会被扫描并注册到CommandBus里。考虑到可能要把这套框架用在多个项目中我把它做成了ddd-cqrs-starter内部封装了CommandBus、QueryBus、EventBus对业务项目只暴露相关接口。6.2 CommandBus 的核心实现CommandBus 不需要多复杂本质上是命令类型到处理器的映射关系。下面这个实现省去了很多功能但保留了最核心的注册和分发逻辑Component public class DefaultCommandBus implements CommandBus { private final MapClass?, CommandHandler handlerMap new ConcurrentHashMap(); Override public void register(Class? commandType, CommandHandler handler) { handlerMap.put(commandType, handler); } Override SuppressWarnings(unchecked) public R R dispatch(Object command) { CommandHandler handler handlerMap.get(command.getClass()); if (handler null) { throw new IllegalStateException(no handler for command: command.getClass()); } return (R) handler.handle(command); } }真实的框架里还要支持拦截器比如日志、权限、幂等校验。我建议把幂等校验做成 CommandBus 的拦截器统一在命令进入处理器之前检查。如果某个命令已经执行过直接返回之前的结果。6.3 事件总线的同步与异步策略事件总线我做了两种模式同步和异步。同步模式用于“同一个事务内必须立即生效”的事件比如订单创建后要更新某个统计表。异步模式用于跨服务和耗时操作比如发短信、推送消息。这里有一个很实际的问题如果异步发布事件使用的是线程池那应用重启时线程池里排队的事件会丢。解决思路是把“待发布事件”先作为一张本地事件表存进数据库然后由发件器扫描表并投递到 MQ。这个方案保证事件不丢就是多了两步代码但企业级应用里很值得。6.4 一个订单创建全链路的最小代码下面我给出一个完整的订单创建命令端代码读者可以看到框架怎么串起来public class CreateOrderCommand { private final String orderId; private final String customerId; private final ListOrderItemDto items; // 构造器、getter } Component CommandHandler(commandType CreateOrderCommand.class) public class CreateOrderCommandHandler implements CommandHandlerInterfaceCreateOrderCommand, String { private final OrderRepository orderRepository; Override Transactional public String handle(CreateOrderCommand command) { ListOrderLine orderLines command.getItems().stream() .map(dto - new OrderLine(dto.getProductId(), dto.getQuantity(), new Money(dto.getUnitPrice()))) .toList(); Order order Order.create(new OrderId(command.getOrderId()), new CustomerId(command.getCustomerId()), orderLines); orderRepository.save(order); return order.getId().getValue(); } }在接口层Controller 接收到请求后只做数据转换RestController RequestMapping(/api/orders) public class OrderController { private final CommandBus commandBus; PostMapping public ResponseEntityString create(RequestBody CreateOrderRequest request) { String orderId commandBus.dispatch(request.toCommand()); return ResponseEntity.ok(orderId); } }查询端也大同小异命令端使用 CommandBus查询端使用 QueryBus。查询处理器直接返回查询结果不需要经过领域层。7. 落地过程中绕不开的坑分布式 ID、幂等、大事务和调试7.1 分布式 ID 生成与分库分表键的选择CQRS 架构下写模型和读模型可能需要不同的主键策略。我最早用了自增 ID结果分库分表后订单表和订单列表宽表的主键经常冲突排查问题很痛苦。后来换成雪花算法生成Long型 ID并在OrderId这个值对象里保存了 ID 的生成时间戳和机器标识。这样在排查问题时从 ID 就能看出大致生成的机器和时间段。分库分表键我建议用 customerId 而不是 orderId。因为订单维度的查询基本都是按用户来查的把同一个用户的订单分到同一分片可以避免跨分片查询。如果要用 orderId 查订单就需要额外维护“订单和用户”的映射关系增加复杂度。7.2 幂等性同一个命令重复执行两次会怎样幂等是命令端最容易踩的坑。比如用户点击“创建订单”按钮前端因为网络重试发送了两次请求。如果命令处理器不做幂等处理就会生成两笔订单。我在框架层面的 CommandBus 里做了一层幂等拦截用法很简单Idempotent(key #request.requestId) public String handle(CreateOrderCommand command) { // ... }实现原理是根据请求方传入的requestId查数据库里的幂等表。如果已经存在相同 requestId 的记录说明命令已执行过直接返回原结果。如果不存在先插入一条状态为 PROCESSING 的记录再执行真正的业务逻辑最后把状态改成 SUCCESS。注意幂等表的插入和业务操作必须在同一个本地事务里。否则先插入记录、后执行业务如果业务失败事务回滚幂等记录也会回滚重试还能重新执行这正是我们想要的。7.3 大事务拆分聚合根 save 不要太“贪”DDD 的聚合根保存天然容易把多个子对象写进同一事务尤其当一个订单包含大量订单行时整个 Order 聚合的保存会涉及几十条 SQL如果全部放在一个大事务里锁竞争和回滚代价都很高。我的经验是聚合根保存时只保存变化的部分。框架给聚合根增加了“变更追踪”能力保存前对比快照只把发生变化的实体更新到数据库。创建订单时如果一次性插入几十条订单行没问题但取消订单时就不需要把整个订单行列表重新更新一遍。实测下来订单从原来的全量更新改成效验更新后数据库压力至少降了一半。7.4 调试读模型和写模型的技巧CQRS 架构最让团队头疼的是线上出问题后你分不清到底是命令端没写对还是读模型没同步对。我常用的调试链路是先通过订单 ID查命令端的订单事件表确认事件是否发布。查 Saga 日志表看事件流转到哪一步是否触发补偿。查看读模型的宽表看对应的event_id是否已经落库如果没落库翻消费者日志。这个方法把一条复杂的链路拆成三段每一段都能独立定位。我也在框架里加了一个运维接口可以直接输入订单 ID 返回事件流、状态流转和补偿记录排查时间从小时级降到了分钟级。最后聊点个人体会重构完这个框架之后我最大的体会是DDD 和 CQRS 真正降低的是长期维护成本而不是短期开发成本。刚开始每个命令都要写 Command、Handler、聚合方法、Repository 实现代码量确实比传统 Controller-Service 要大一圈。但只要业务规则进入复杂阶段这种结构的优势就非常明显——领域规则收拢在聚合根内部查询性能瓶颈可以单独优化再也不会出现改一个 Service 方法被十几个调用方连带影响的情况。给你一个最直接的配置建议第一次做 DDD CQRS不要把事件溯源一起引入。事件溯源会带来不小的事件存储和版本迁移复杂度先只做模型分离 命令查询总线 事件投递读模型就足够解决 90% 的问题了。把架子搭稳以后再决定要不要往事件溯源方向演进。
返回列表