Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Design Decisions

This page explains the reasoning behind key architectural choices in the crate.

Events as First-Class Structs

Decision: Events are standalone structs implementing DomainEvent, not just enum variants.

Why:

  • Events can be shared across multiple aggregates
  • Projections subscribe to event types, not aggregate enums
  • Easier to evolve events independently
  • Better compile-time isolation

Trade-off: More boilerplate (mitigated by derive macro).

// This crate: events stand alone
struct OrderPlaced { /* ... */ }
struct PaymentReceived { /* ... */ }

// Both Order and Payment aggregates can use PaymentReceived

IDs as Infrastructure

Decision: Aggregate IDs are passed to the repository, not embedded in events or handlers.

Why:

  • Domain events stay pure (no infrastructure metadata)
  • Same event type works with different ID schemes
  • Command handlers focus on behavior, not identity
  • IDs travel in the envelope alongside events

Trade-off: You can’t access the ID inside Handle<C>. If needed, include relevant IDs in the command.

// ID is infrastructure
repository
    .execute_command::<Account, Deposit>(&account_id, &command, &metadata)
    .await?;

// Event doesn't contain ID
struct FundsDeposited { amount: i64 }  // No account_id field

No Built-In Command Bus

Decision: The crate provides Repository::execute_command() but no command routing infrastructure.

Why:

  • Command buses vary widely (sync, async, distributed, in-process)
  • Many applications don’t need one (web handlers call repository directly)
  • Easy to add on top: trait object dispatch, actor messages, etc.
  • Keeps the crate focused on event sourcing, not messaging

What you might build:

// Your command bus (not provided)
trait CommandBus {
    fn dispatch<C: Command>(&self, id: &str, command: C) -> Result<(), Error>;
}

Versioning at the Codec Layer

Decision: No explicit “upcaster” concept. Event migration happens in the codec or via serde.

Why:

  • Simpler mental model—one place for serialization concerns
  • Works with any serde-compatible migration library
  • Codec can optimize (e.g., cache parsed schemas)
  • Avoids framework lock-in for versioning strategy

How:

// Option 1: serde defaults
#[serde(default)]
pub marketing_consent: bool,

// Option 2: serde-evolve
#[derive(Evolve)]
#[evolve(ancestors(V1, V2))]
pub struct Event { /* ... */ }

// Option 3: Codec-level
impl Codec for VersionedCodec {
    fn deserialize(&self, data: &[u8]) -> Result<E> {
        // Parse, migrate, return current version
    }
}

Projections Decoupled from Aggregates

Decision: Projections implement ApplyProjection<E> for event types, not aggregate types.

Why:

  • A projection can consume events from many aggregates
  • Projections don’t need to know about aggregate enums
  • Enables cross-aggregate views naturally
  • Better separation of read and write concerns
// Projection consumes from multiple aggregates
impl Projection for Dashboard { type Id = String; type Metadata = (); }
impl ApplyProjection<OrderPlaced> for Dashboard { /* ... */ }
impl ApplyProjection<PaymentReceived> for Dashboard { /* ... */ }
impl ApplyProjection<ShipmentDispatched> for Dashboard { /* ... */ }

Minimal Infrastructure

Decision: No outbox, no scheduler, no event streaming, no saga orchestrator.

Why:

  • Event sourcing is the foundation; infrastructure varies by use case
  • Async runtimes differ (tokio, async-std, sync-only)
  • Message brokers differ (Kafka, RabbitMQ, NATS, none)
  • Database choices affect outbox patterns
  • Keeps dependencies minimal
  • may be added in future!!

What we do provide:

  • Core traits for aggregates and projections
  • Repository for command execution
  • In-memory store for testing
  • Test framework for aggregate testing

What you add:

  • Production event store
  • Outbox pattern (if needed)
  • Process managers / sagas
  • Event publishing to message broker