-
-
Notifications
You must be signed in to change notification settings - Fork 21
Description
Problem
In Event Sourcing / CQRS architectures, event-triggered side-effects (emails, documents, webhooks, integrations, etc.) often need access to a consistent read view that is not contained in the triggering event and may evolve over time.
Common approaches have known trade-offs:
- async persistent projections → eventual consistency
- rehydrating aggregates → coupling to the write model + unnecessary state
- enriching events → bloated and rigid event contracts
Ecotone currently has no first-class mechanism to build a minimal read model on demand, scoped to a single aggregate, without persistence.
Proposal
Introduce On-Demand (Transient) Projections as a new, optional module.
These projections would be:
- built on demand
- partitioned by Aggregate ID
- in-memory only (no persistence)
- replaying only events for the targeted aggregate
- defined declaratively (attributes)
- suitable for side-effects and internal logic
- consistent with the Event Store at execution time
The goal is to provide a lightweight, strongly scoped alternative to persistent projections, without impacting existing projection semantics.
Example (realistic use case)
An OrderPlaced event triggers a confirmation email.
Customer contact data is owned by another aggregate and evolves over time
(CustomerEmailUpdated, CustomerAddressUpdated, etc.).
We want the current customer state:
- without bloating
OrderPlaced - without rehydrating the Customer write model
- without relying on async projections
On-demand projection for the Customer aggregate
#[OnDemandProjection(stream: 'customer_stream', partitionBy: 'aggregateId')]
final class CustomerContactView
{
public string $email;
public string $fullName;
public ?string $address = null;
#[ProjectEvent]
public function whenCustomerWasCreated(CustomerWasCreated $e): void
{
$this->fullName = $e->fullName;
$this->email = $e->email;
}
#[ProjectEvent]
public function whenCustomerEmailUpdated(CustomerEmailUpdated $e): void
{
$this->email = $e->email;
}
#[ProjectEvent]
public function whenCustomerAddressUpdated(CustomerAddressUpdated $e): void
{
$this->address = $e->address;
}
}
Used from another event handler
final class OrderConfirmationEmailHandler
{
public function __construct(
private OnDemandProjectionBuilder $builder,
private MailerInterface $mailer
) {}
#[EventHandler]
public function handle(OrderPlaced $event): void
{
$customer = $this->builder->build(
CustomerContactView::class,
aggregateId: $event->customerId,
options: [
// Optional:
// rebuild state at a specific point in time
// 'upToVersion' or 'upToTimestamp'
]
);
$this->mailer->sendOrderConfirmation(
to: $customer->email,
name: $customer->fullName,
address: $customer->address,
orderId: $event->orderId
);
}
}
Note: rebuilding state at a specific point in time is optional.
The feature remains useful without version or timestamp bounding.
Non-goals / Limits
- single aggregate partition only (no cross-aggregate joins)
- no persistence
- replay per call (intended for bounded streams)
- not a replacement for persistent projections
Implementation Positioning
This feature is intended to be implemented as a new, dedicated, optional module
(e.g. OnDemandProjection), rather than an extension of the existing projection system.
This keeps:
- a clear and limited scope
- no ambiguity with persistent projections
- no impact on existing functionality
Questions
- Does this proposal align with Ecotone’s current vision and roadmap for Event Sourcing / CQRS?
- Are there any known constraints or ongoing efforts that should be considered before moving forward with this module?
I would be happy to contribute a full implementation (code, tests, documentation) as this new module if the direction aligns with the project goals.