|
16 | 16 | - [Draining events](#draining-events) |
17 | 17 | - [Restoring aggregate version on reload](#restoring-aggregate-version-on-reload) |
18 | 18 | - [Constructing event records directly](#constructing-event-records-directly) |
| 19 | + + [Integration events and the Anti-Corruption Layer](#integration-events-and-the-anti-corruption-layer) |
| 20 | + - [Declaring integration events](#declaring-integration-events) |
| 21 | + - [Writing a translator](#writing-a-translator) |
| 22 | + - [Registering translators](#registering-translators) |
| 23 | + - [Constructing integration event records directly](#constructing-integration-event-records-directly) |
19 | 24 | + [Event sourcing](#event-sourcing) |
20 | 25 | - [Applying events to state](#applying-events-to-state) |
21 | 26 | - [Creating a blank aggregate](#creating-a-blank-aggregate) |
@@ -477,6 +482,157 @@ read these fields off `EventRecord` directly without instantiating one. |
477 | 482 | ); |
478 | 483 | ``` |
479 | 484 |
|
| 485 | +### Integration events and the Anti-Corruption Layer |
| 486 | + |
| 487 | +`DomainEvent` describes facts that happened inside the bounded context and evolves freely with the |
| 488 | +internal model. `IntegrationEvent` describes the stable public contract that flows to external |
| 489 | +consumers and must remain backward-compatible. The two interfaces are siblings, not parent and |
| 490 | +child. An `IntegrationEvent` is produced by an `IntegrationEventTranslator`, which acts as the |
| 491 | +Anti-Corruption Layer (Vernon, IDDD Chapter 3) between the internal model and the public contract. |
| 492 | + |
| 493 | +#### Declaring integration events |
| 494 | + |
| 495 | +* `IntegrationEvent`: marker interface for events that cross bounded-context boundaries. Carries |
| 496 | + a `revision()` method that versions the public schema independently from the underlying domain |
| 497 | + event's schema. |
| 498 | +* `IntegrationEventBehavior`: default implementation that returns `Revision::initial()`. Use it |
| 499 | + on every integration event unless the public schema has been bumped. |
| 500 | + |
| 501 | +Class names for integration events must follow the bounded-context ubiquitous language and must |
| 502 | +**not** carry a technical suffix such as `IntegrationEvent`. The domain event `TransactionConfirmed` |
| 503 | +is translated into the integration event `PaymentConfirmed`, not `PaymentConfirmedIntegrationEvent`. |
| 504 | + |
| 505 | + ```php |
| 506 | + <?php |
| 507 | + |
| 508 | + declare(strict_types=1); |
| 509 | + |
| 510 | + use TinyBlocks\BuildingBlocks\Event\IntegrationEvent; |
| 511 | + use TinyBlocks\BuildingBlocks\Event\IntegrationEventBehavior; |
| 512 | + |
| 513 | + final readonly class PaymentConfirmed implements IntegrationEvent |
| 514 | + { |
| 515 | + use IntegrationEventBehavior; |
| 516 | + |
| 517 | + public function __construct(public string $orderId, public float $amount) |
| 518 | + { |
| 519 | + } |
| 520 | + } |
| 521 | + ``` |
| 522 | + |
| 523 | + Bumping the public schema revision independently from the underlying domain event: |
| 524 | + |
| 525 | + ```php |
| 526 | + <?php |
| 527 | + |
| 528 | + declare(strict_types=1); |
| 529 | + |
| 530 | + use TinyBlocks\BuildingBlocks\Event\IntegrationEvent; |
| 531 | + use TinyBlocks\BuildingBlocks\Event\IntegrationEventBehavior; |
| 532 | + use TinyBlocks\BuildingBlocks\Event\Revision; |
| 533 | + |
| 534 | + final readonly class PaymentConfirmedV2 implements IntegrationEvent |
| 535 | + { |
| 536 | + use IntegrationEventBehavior; |
| 537 | + |
| 538 | + public function __construct( |
| 539 | + public string $orderId, |
| 540 | + public float $amount, |
| 541 | + public string $currency |
| 542 | + ) { |
| 543 | + } |
| 544 | + |
| 545 | + public function revision(): Revision |
| 546 | + { |
| 547 | + return Revision::of(value: 2); |
| 548 | + } |
| 549 | + } |
| 550 | + ``` |
| 551 | + |
| 552 | +#### Writing a translator |
| 553 | + |
| 554 | +`IntegrationEventTranslator` is the Anti-Corruption Layer seam. Each implementation declares |
| 555 | +which `EventRecord` it handles via `supports()` and produces the corresponding |
| 556 | +`IntegrationEvent` via `translate()`. Implementations must be pure functions with no side |
| 557 | +effects or I/O. |
| 558 | + |
| 559 | + ```php |
| 560 | + <?php |
| 561 | + |
| 562 | + declare(strict_types=1); |
| 563 | + |
| 564 | + use TinyBlocks\BuildingBlocks\Event\EventRecord; |
| 565 | + use TinyBlocks\BuildingBlocks\Event\IntegrationEvent; |
| 566 | + use TinyBlocks\BuildingBlocks\Event\IntegrationEventTranslator; |
| 567 | + |
| 568 | + final readonly class TransactionConfirmedTranslator implements IntegrationEventTranslator |
| 569 | + { |
| 570 | + public function supports(EventRecord $record): bool |
| 571 | + { |
| 572 | + return $record->event instanceof TransactionConfirmed; |
| 573 | + } |
| 574 | + |
| 575 | + public function translate(EventRecord $record): IntegrationEvent |
| 576 | + { |
| 577 | + /** @var TransactionConfirmed $event */ |
| 578 | + $event = $record->event; |
| 579 | + |
| 580 | + return new PaymentConfirmed(orderId: $event->orderId, amount: $event->amount); |
| 581 | + } |
| 582 | + } |
| 583 | + ``` |
| 584 | + |
| 585 | +#### Registering translators |
| 586 | + |
| 587 | +`IntegrationEventTranslators` is an ordered collection of translators. `findFor()` returns the |
| 588 | +first translator whose `supports()` returns `true` for a given record, or `null` when no |
| 589 | +translator handles it. A `null` result is the canonical signal that the event is purely internal |
| 590 | +and must not cross the bounded-context boundary. |
| 591 | + |
| 592 | + ```php |
| 593 | + <?php |
| 594 | + |
| 595 | + declare(strict_types=1); |
| 596 | + |
| 597 | + use TinyBlocks\BuildingBlocks\Event\IntegrationEventTranslators; |
| 598 | + |
| 599 | + $translators = IntegrationEventTranslators::createFrom(elements: [ |
| 600 | + new TransactionConfirmedTranslator(), |
| 601 | + new OrderShippedTranslator() |
| 602 | + ]); |
| 603 | + |
| 604 | + $translator = $translators->findFor(record: $record); |
| 605 | + |
| 606 | + if (!is_null($translator)) { |
| 607 | + $integrationEvent = $translator->translate(record: $record); |
| 608 | + } |
| 609 | + ``` |
| 610 | + |
| 611 | +#### Constructing integration event records directly |
| 612 | + |
| 613 | +`IntegrationEventRecord::from()` envelopes a translated integration event with the transport |
| 614 | +metadata from the originating `EventRecord`. The identifier is reused from the originating |
| 615 | +record so that outbox relay retries remain idempotent. The revision and event type are derived |
| 616 | +from the integration event, not from the domain event. |
| 617 | + |
| 618 | + | Parameter | Type | Description | |
| 619 | + |--------------------|--------------------|----------------------------------------------------------------------------------| |
| 620 | + | `eventRecord` | `EventRecord` | Originating domain event record. Supplies transport metadata. | |
| 621 | + | `integrationEvent` | `IntegrationEvent` | Integration event produced by the translator. Supplies payload and public schema. | |
| 622 | + |
| 623 | + ```php |
| 624 | + <?php |
| 625 | + |
| 626 | + declare(strict_types=1); |
| 627 | + |
| 628 | + use TinyBlocks\BuildingBlocks\Event\IntegrationEventRecord; |
| 629 | + |
| 630 | + $integrationEventRecord = IntegrationEventRecord::from( |
| 631 | + eventRecord: $eventRecord, |
| 632 | + integrationEvent: $integrationEvent |
| 633 | + ); |
| 634 | + ``` |
| 635 | + |
480 | 636 | ### Event sourcing |
481 | 637 |
|
482 | 638 | `EventSourcingRoot` stores no state of its own, state is derived by replaying the event stream. |
@@ -910,6 +1066,41 @@ directly inside the trait, which is legal because the assignment happens in the |
910 | 1066 | after the trait flattens into the aggregate. Eliminating the public method tightens the surface and removes the |
911 | 1067 | documentation burden of explaining when calling it is correct. |
912 | 1068 |
|
| 1069 | +### 13. Why are `DomainEvent` and `IntegrationEvent` siblings instead of parent and child? |
| 1070 | + |
| 1071 | +Domain events evolve freely with the internal model. Integration events are a public contract |
| 1072 | +that must remain backward-compatible across bounded-context consumers. A parent/child relationship |
| 1073 | +would make every domain event eligible to cross the bounded-context boundary by virtue of typing, |
| 1074 | +reintroducing the very coupling the distinction exists to eliminate. Sibling interfaces force the |
| 1075 | +boundary crossing to be an explicit translation step, observable in the type system. There is no |
| 1076 | +accidental publication: the compiler rejects a `DomainEvent` where an `IntegrationEvent` is |
| 1077 | +expected, and the `IntegrationEventTranslator` is the only path between the two. |
| 1078 | + |
| 1079 | +> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 3, |
| 1080 | +> "Context Maps". |
| 1081 | +
|
| 1082 | +### 14. Why doesn't the library let me publish a `DomainEvent` directly through the outbox? |
| 1083 | + |
| 1084 | +The Anti-Corruption Layer exists precisely to keep the public contract isolated from the internal |
| 1085 | +model. A shortcut that lets a domain event become an integration event without an explicit |
| 1086 | +translation step erases that boundary. |
| 1087 | + |
| 1088 | +Without translation, internal model refactors propagate silently to external consumers. A renamed |
| 1089 | +field or a new value object on a domain event changes the published payload with no compile-time |
| 1090 | +signal. Consumers break at runtime, not at the CI boundary where the change was introduced. |
| 1091 | + |
| 1092 | +Domain events are versioned by the internal model; integration events are versioned by the public |
| 1093 | +contract. Coupling them forces a single revision counter to serve two evolution speeds, which |
| 1094 | +collapses the ability to evolve each side independently. |
| 1095 | + |
| 1096 | +Even when a domain event and an integration event happen to share the same shape today, the cost |
| 1097 | +of writing a translator that copies fields is a few seconds per event. In return: static analysis |
| 1098 | +flags drift between the two shapes, refactor pressure surfaces in CI as a compile error inside the |
| 1099 | +translator, and the public contract is locatable as a single namespace in the codebase. |
| 1100 | + |
| 1101 | +> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 3, |
| 1102 | +> "Context Maps", section "Anticorruption Layer". |
| 1103 | +
|
913 | 1104 | ## License |
914 | 1105 |
|
915 | 1106 | Building Blocks is licensed under [MIT](LICENSE). |
|
0 commit comments