Skip to content

Commit 027ea9c

Browse files
authored
Release/4.0.0 (#9)
* feat: Add IntegrationEvent and Anti-Corruption Layer primitives. * test: Cover IntegrationEvent and Anti-Corruption Layer primitives. * docs: Document integration events and the Anti-Corruption Layer. * feat: Add integration-events category to composer.json and reorder initial revision method.
1 parent 8a01f84 commit 027ea9c

18 files changed

Lines changed: 795 additions & 22 deletions

README.md

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
- [Draining events](#draining-events)
1717
- [Restoring aggregate version on reload](#restoring-aggregate-version-on-reload)
1818
- [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)
1924
+ [Event sourcing](#event-sourcing)
2025
- [Applying events to state](#applying-events-to-state)
2126
- [Creating a blank aggregate](#creating-a-blank-aggregate)
@@ -477,6 +482,157 @@ read these fields off `EventRecord` directly without instantiating one.
477482
);
478483
```
479484

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+
480636
### Event sourcing
481637

482638
`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
9101066
after the trait flattens into the aggregate. Eliminating the public method tightens the surface and removes the
9111067
documentation burden of explaining when calling it is correct.
9121068

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+
9131104
## License
9141105

9151106
Building Blocks is licensed under [MIT](LICENSE).

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"aggregate-root",
1414
"event-sourcing",
1515
"building-blocks",
16+
"integration-events",
1617
"domain-driven-design"
1718
],
1819
"authors": [

src/Aggregate/AggregateRootBehavior.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ private function buildEventRecord(DomainEvent $event): EventRecord
5454
id: Uuid::uuid4(),
5555
event: $event,
5656
revision: $event->revision(),
57-
eventType: EventType::fromEvent(event: $event),
57+
eventType: EventType::fromDomainEvent(event: $event),
5858
occurredAt: Instant::now(),
5959
aggregateId: $this->identity(),
6060
aggregateType: $this->aggregateType(),

src/Event/EventRecord.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public static function of(
5151
id: $id ?? Uuid::uuid4(),
5252
event: $event,
5353
revision: $event->revision(),
54-
eventType: EventType::fromEvent(event: $event),
54+
eventType: EventType::fromDomainEvent(event: $event),
5555
occurredAt: $occurredAt ?? Instant::now(),
5656
aggregateId: $aggregateId,
5757
aggregateType: $aggregateType,

src/Event/EventType.php

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,27 +22,39 @@ private function __construct(public string $value)
2222
}
2323
}
2424

25+
/**
26+
* Creates an EventType from a raw type identifier.
27+
*
28+
* @param string $value The PascalCase type identifier.
29+
* @return EventType The created instance.
30+
* @throws InvalidEventType If the value does not match the required pattern.
31+
*/
32+
public static function fromString(string $value): EventType
33+
{
34+
return new EventType(value: $value);
35+
}
36+
2537
/**
2638
* Creates an EventType from a domain event using its short class name.
2739
*
28-
* @param DomainEvent $event The event whose class name carries the type.
40+
* @param DomainEvent $event The domain event whose class name carries the type.
2941
* @return EventType The created instance.
3042
* @throws InvalidEventType If the resolved class name does not match the required pattern.
3143
*/
32-
public static function fromEvent(DomainEvent $event): EventType
44+
public static function fromDomainEvent(DomainEvent $event): EventType
3345
{
3446
return new EventType(value: new ReflectionClass(objectOrClass: $event)->getShortName());
3547
}
3648

3749
/**
38-
* Creates an EventType from a raw type identifier.
50+
* Creates an EventType from an integration event using its short class name.
3951
*
40-
* @param string $value The PascalCase type identifier.
52+
* @param IntegrationEvent $event The integration event whose class name carries the type.
4153
* @return EventType The created instance.
42-
* @throws InvalidEventType If the value does not match the required pattern.
54+
* @throws InvalidEventType If the resolved class name does not match the required pattern.
4355
*/
44-
public static function fromString(string $value): EventType
56+
public static function fromIntegrationEvent(IntegrationEvent $event): EventType
4557
{
46-
return new EventType(value: $value);
58+
return new EventType(value: new ReflectionClass(objectOrClass: $event)->getShortName());
4759
}
4860
}

src/Event/IntegrationEvent.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\BuildingBlocks\Event;
6+
7+
/**
8+
* Marker interface for facts published outside the producing bounded context.
9+
*
10+
* <p>An <code>IntegrationEvent</code> is the stable public contract that flows across
11+
* bounded contexts, typically through a transactional outbox and an asynchronous relay.
12+
* It is a sibling of {@see DomainEvent}, not a subtype: domain events describe what
13+
* happened inside the model and evolve freely with it; integration events describe
14+
* what external consumers can rely on and must remain backward-compatible.</p>
15+
*
16+
* <p>Translation from a {@see DomainEvent} to an <code>IntegrationEvent</code> happens
17+
* at the boundary via an {@see IntegrationEventTranslator}, which acts as the
18+
* Anti-Corruption Layer between the internal model and the public contract.</p>
19+
*
20+
* <p>Each integration event declares its own schema {@see Revision} via the
21+
* <code>revision()</code> method, defaulted to {@see Revision::initial} by
22+
* {@see IntegrationEventBehavior}. Override only when bumping the public schema.</p>
23+
*
24+
* @see Vaughn Vernon, <em>Implementing Domain-Driven Design</em> (Addison-Wesley, 2013),
25+
* Chapter 3 "Context Maps" and Chapter 13 "Integrating Bounded Contexts".
26+
*/
27+
interface IntegrationEvent
28+
{
29+
/**
30+
* Returns the schema revision of this integration event.
31+
*
32+
* @return Revision The current schema revision. Defaults to {@see Revision::initial}.
33+
*/
34+
public function revision(): Revision;
35+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\BuildingBlocks\Event;
6+
7+
trait IntegrationEventBehavior
8+
{
9+
public function revision(): Revision
10+
{
11+
return Revision::initial();
12+
}
13+
}

0 commit comments

Comments
 (0)