mcpField
D
domain-driven-design

domain-driven-design

github.com/rrezartprebreza开发345110 次安装1 天前更新
代码仓库

关于 domain-driven-design

面向使用 Spring Data 的 Java 开发者,提供领域驱动设计的建模与实现指导,涵盖聚合边界、事件发布及外部系统隔离。

主要功能

  1. 01聚合根与一致性边界规则
  2. 02不可变值对象建模
  3. 03领域事件发布与持久化建议
  4. 04复杂查询规格模式
  5. 05防腐层集成指导
  6. 06验证要点与常见陷阱

适用场景

  1. 01设计聚合及其跨边界引用
  2. 02实现领域事件与可靠外部副作用
  3. 03隔离第三方 API 与领域模型

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/rrezartprebreza/spring-boot-skills --skill domain-driven-design

安装后在 Claude Code 中输入/domain-driven-design确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
examples/
bad-aggregate.java
good-aggregate.java
templates/
DomainEvent.java
Money.java

详细介绍

领域驱动设计

聚合规则

  • 每个聚合根对应一个仓储
  • 外部代码只能通过聚合根访问聚合,绝不直接访问子实体
  • 跨聚合边界时,优先通过 ID 引用;保留有意设置的映射。
  • 根据必须原子提交的不变量和预期竞争情况来选择边界, 而不是按固定的子实体数量。拆分时必须制定明确的一致性方案。

以下代码片段仅用于说明,需要使用应用程序的领域类型和导入语句。 除非任务包含迁移,否则保留现有的公共标识符和持久化映射。

// ✅ Aggregate root controls all access to children
order.addItem(productId, quantity); // through root
order.removeItem(itemId);           // through root

// ❌ Direct child access from outside
order.getItems().add(new OrderItem(...)); // bypasses invariants

值对象

不可变、无标识,通过值判断相等:

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Amount cannot be negative");
        Objects.requireNonNull(currency);
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency))
            throw new CurrencyMismatchException(currency, other.currency);
        return new Money(amount.add(other.amount), currency);
    }

    public static Money of(String amount, String currency) {
        return new Money(new BigDecimal(amount), Currency.getInstance(currency));
    }
}

public record EmailAddress(String value) {
    public EmailAddress {
        if (!value.matches("^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$"))
            throw new InvalidEmailException(value);
    }
}

领域事件

// Event — immutable record
public record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant occurredAt) {
    public static OrderPlaced of(Order order) {
        return new OrderPlaced(order.getId(), order.getCustomerId(), order.getTotal(), Instant.now());
    }
}

// Collect events in aggregate, publish after save
@Entity
public class Order {
    @Transient
    private final List<Object> domainEvents = new ArrayList<>();

    public void place() {
        this.status = OrderStatus.PLACED;
        domainEvents.add(OrderPlaced.of(this));
    }

    public List<Object> pullDomainEvents() {
        var events = List.copyOf(domainEvents);
        domainEvents.clear();
        return events;
    }
}

// Publish inside the transaction; save() is not commit.
@Service
@RequiredArgsConstructor
public class OrderApplicationService {
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Order placeOrder(PlaceOrderCommand command) {
        Order order = orderRepository.findById(command.orderId()).orElseThrow();
        order.place();
        Order saved = orderRepository.save(order);
        saved.pullDomainEvents().forEach(eventPublisher::publishEvent); // commit-bound listener below
        return saved;
    }
}

// Listen to events — bind to commit, not just publish.
// @EventListener fires synchronously inside the TX; if the TX later rolls back you've
// already sent the email. AFTER_COMMIT avoids rollback delivery but is best effort.
@Component
@RequiredArgsConstructor
public class OrderPlacedHandler {
    private final EmailService emailService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderPlaced(OrderPlaced event) {
        emailService.sendOrderConfirmation(event.customerId(), event.orderId());
    }
}

交由 Spring Data 为你发布。 不要手动调用 pullDomainEvents(),而是在聚合根上提供一个 @DomainEvents 方法(返回收集到的事件)和一个 @AfterDomainEventPublication 方法 (清除这些事件)。Spring Data 的仓储会在每次 save() 时自动提取并发布事件,无需在服务中手动配置。

这两种发布机制都不保证可靠交付。对于必须执行的外部操作, 请将发件箱条目与聚合变更一起持久化,或使用持久化发布注册表, 并采用幂等消费者以及重试/恢复机制。@Async 只改变执行方式,不改变持久性。 提交后写入数据库时需要开启新事务,因为原始资源在事务完成后可能仍处于绑定状态。请参阅事务边界、 消息传递和 Modulith。

规范(复杂查询)

public class OrderSpecifications {
    public static Specification<Order> byStatus(OrderStatus status) {
        return (root, query, cb) -> cb.equal(root.get("status"), status);
    }

    public static Specification<Order> byCustomer(UUID customerId) {
        return (root, query, cb) -> cb.equal(root.get("customerId"), customerId);
    }

    public static Specification<Order> placedAfter(Instant date) {
        return (root, query, cb) -> cb.greaterThan(root.get("placedAt"), date);
    }
}

// Compose
Specification<Order> spec = OrderSpecifications.byStatus(PLACED)
    .and(OrderSpecifications.byCustomer(customerId))
    .and(OrderSpecifications.placedAfter(lastWeek));

orderRepository.findAll(spec, pageable);

防腐层(ACL)

  • 与外部系统或旧版代码集成时,不要让它们的模型渗入你的领域
  • 创建 ACL,即将外部数据转换为领域语言的翻译层
  • ACL 属于基础设施层,而非领域层
// ✅ GOOD — ACL translates external payment API to domain concepts
@Component
@RequiredArgsConstructor
public class PaymentGatewayAdapter implements PaymentPort {

    private final ExternalPaymentClient client;  // third-party SDK

    @Override
    public PaymentConfirmation charge(OrderId orderId, Money amount) {
        // Translate domain → external
        PaymentApiRequest apiRequest = new PaymentApiRequest(
            orderId.value().toString(),
            amount.amount(), // illustrative provider accepts BigDecimal
            amount.currency().getCurrencyCode());

        // Call external system
        PaymentApiResponse apiResponse = client.charge(apiRequest);

        // Translate external → domain
        return new PaymentConfirmation(
            PaymentId.of(apiResponse.getTransactionId()),
            apiResponse.isSuccessful() ? PaymentStatus.CONFIRMED : PaymentStatus.DECLINED);
    }
}

使用提供方精确的金额表示方式。对于整数最小货币单位,应根据货币的小数位数进行转换,并使用精确算术;不要静默舍入,也不要将金额转换为二进制浮点数。

验证

测试聚合不变量、回滚如何阻止外部通知,以及提交后发生崩溃时如何恢复可靠发布。验证现有 API ID 和数据库关系仍与所请求的变更兼容。

注意事项

  • Agent 创建只有 getter/setter 的贫血模型——应将行为放在领域对象中
  • Agent 将现有的 Long/UUID API ID 替换为值对象——应保留公共契约;明确映射内部类型。
  • Agent 将领域逻辑放入服务中——服务应负责编排,而非做决策
  • Agent 从外部直接访问子实体——始终通过聚合根访问
  • Agent 混淆 save 与 commit——在事务中发布,并有意选择监听器/可靠交付语义。
  • Agent 按任意子实体数量拆分聚合——应根据业务不变量推导边界。
  • Agent 将外部 API 模型引入领域——使用防腐层进行转换

官方资料

评论

支持 Markdown