基于DDD与CQRS架构:如何通过事件在事务中同时创建User与Contact聚合并解决关联问题
Hey there, let's break down how to tackle this problem step by step—you're already on the right track by avoiding a single overloaded command and leaning into events! Let's address your core concerns one by one, with practical DDD/CQRS-aligned solutions.
First, let's recap two key rules to keep us aligned:
- Aggregate roots are unaware of other aggregates: A
Userdoesn't need to know aContactexists, and vice versa. Their only job is to enforce their own business rules and emit events when their state changes. - Single Responsibility stays intact: Each command handler should only deal with one aggregate. The coordination of multiple aggregates belongs to the application layer, not a single command or aggregate.
Solution 1: Strong Consistency (Transactional Persistence)
If you need both the User and Contact to be persisted atomically (either both succeed or both fail), the application layer acts as a coordinator within a single transaction.
How it works:
- Receive a composite trigger: Create a top-level command like
CreateUserWithLinkedContactCommand(this is just a trigger, not a handler for both aggregates). It contains all necessary data for both the user and contact. - Orchestrate within a transaction:
- Start a transaction via your unit of work (or database transaction scope).
- First, invoke the
CreateUserCommandHandlerto create and persist theUseraggregate. Grab the generatedUserIdfrom the persisted user. - Then, pass that
UserIdto theCreateContactCommandHandleralong with the contact data to create and persist the linkedContactaggregate. - Commit the transaction only if both steps succeed. Rollback if either fails.
- Let aggregates emit their own events: After persistence, the
Useraggregate should emit aUserCreatedEvent(triggered internally when the user is successfully created), and theContactaggregate emits aContactCreatedEvent. The application layer can publish these events to the bus once the transaction is committed.
Example Pseudocode:
public class CreateUserWithLinkedContactHandler { private final UnitOfWork unitOfWork; private final CreateUserCommandHandler userHandler; private final CreateContactCommandHandler contactHandler; private final EventBus eventBus; public void handle(CreateUserWithLinkedContactCommand cmd) { try (var transaction = unitOfWork.beginTransaction()) { // Step 1: Create User var createUserCmd = new CreateUserCommand(cmd.getUserName(), cmd.getUserEmail()); User user = userHandler.handle(createUserCmd); unitOfWork.save(user); // Step 2: Create Contact with the new UserId var createContactCmd = new CreateContactCommand( user.getId(), cmd.getContactName(), cmd.getContactPhone() ); Contact contact = contactHandler.handle(createContactCmd); unitOfWork.save(contact); // Commit transaction transaction.commit(); // Publish events from both aggregates eventBus.publishAll(user.getUncommittedEvents()); eventBus.publishAll(contact.getUncommittedEvents()); user.clearUncommittedEvents(); contact.clearUncommittedEvents(); } catch (Exception e) { unitOfWork.rollback(); throw e; } } }
Solution 2: Final Consistency (Event-Driven Async Flow)
If atomicity isn't strictly required (e.g., the contact can be created shortly after the user without breaking business rules), this approach leans fully into CQRS event-driven patterns and avoids distributed transactions.
How it works:
- Initial User Creation: The application layer receives a
CreateUserWithContactIntentCommand, which triggers theCreateUserCommandHandlerto create theUseraggregate. TheUseremits aUserCreatedEventonce persisted. - Event-Driven Contact Creation: An event handler (managed by the application layer or a dedicated event processor) listens for
UserCreatedEvent. When it receives the event, it extracts theUserIdand uses it to send aCreateContactCommandwith the pre-provided contact data. - Handle Failure Gracefully: Add retry logic or a dead-letter queue for cases where the contact creation fails. You can also emit a
ContactCreationFailedEventto trigger manual intervention if needed.
Key Benefits:
- No cross-aggregate dependencies
- Avoids heavy transaction overhead
- Scales better for distributed systems
Answering Your Two Core Questions
1. How to persist both aggregates in a transaction?
- Same database: Use a unit of work pattern to wrap both aggregate saves in a single local transaction (like the example above).
- Different databases: If
UserandContactlive in separate databases, you have two options:- Use a distributed transaction (e.g., 2PC) – note that this adds complexity and can impact performance.
- Switch to the final consistency approach above, with compensation logic for failures.
2. How to pass UserId to the Contact command?
- Strong consistency: The application layer gets the
UserIddirectly from the persistedUseraggregate and passes it to theCreateContactCommand. - Final consistency: The
UserCreatedEventincludes theUserIdas part of its payload. The event handler extracts this ID and injects it into theCreateContactCommand.
Critical Best Practices
- Never let aggregates depend on each other: A
Usershould never trigger aContactaction directly. Aggregates only emit events about their own state changes. - Keep command handlers focused: Each command handler should only handle logic for one aggregate. The application layer handles coordination, not business rules.
- Publish events after transaction commit: Ensure events are only published if the aggregate's state is successfully persisted. This avoids "ghost events" where an event is sent but the aggregate isn't saved.
内容的提问来源于stack exchange,提问作者Mehdi FarokhTabar

