Alpha Version: You are viewing the ALPHA documentation. This is an experimental version and may contain breaking changes.
Skip to main content

Aggregate vs DCB: Decision Guide

This guide helps developers (and AI skills) choose between the Aggregate approach, the DCB (Dynamic Consistency Boundary) approach, or a Hybrid mix within a Reventless plugin.


Seeing it decided on a real domain

Choosing an approach applies this guide to the online shop, entity by entity — including the case that catches people out, where a self-contained entity has to be DCB because another slice reads it.

Quick Decision Table​

QuestionAggregateDCB Slices
Does the entity have a self-contained lifecycle?Yes--
Do multiple entities share consistency rules?--Yes
Is the command validation logic isolated to one entity?Yes--
Must a command check state across multiple entity IDs?--Yes
Is the entity independent of other entities in the same plugin?Yes--
Does the entity exist mainly to sync external data?--Yes
Is the domain well-understood with clear entity boundaries?YesEither

"--" means the approach is not a natural fit but is not prohibited.

Needing an automation or a translation slice is not a reason to choose DCB. Those slices react to Aggregate events as well as DCB events, and send their commands to an Aggregate or a StateChangeSlice (see Automation and Side Effects).


The Two Approaches​

Aggregate Approach​

An Aggregate is a write-side component with its own private event stream per entity instance. Each aggregate ID has its own event history, and state is reconstructed by replaying that history on every command.

Components:

  • Aggregate (Spec + Behavior) — handles commands, produces events
  • EventLog — one append-only stream per aggregate ID
  • ReadModel + Projections — query-side views consuming aggregate events

Command flow:

Command → CommandTopic (FIFO, ordered per ID)
→ replay(id) → evolve events into state
→ decide(state, command) → new events
→ append to EventLog → publish to EventTopic
→ ReadModel projects events into QueryDb

DCB Approach​

A DCB uses a single shared event log per plugin. Commands are handled by StateChangeSlices that query the log using tags, build a minimal decision model, and append new events with optimistic concurrency.

Components:

  • StateChangeSlice — handles commands, queries shared event log, produces events

  • StateViewSlice — projects events into queryable state (read-side)

  • DcbEventLog — single shared event log with tag-based filtering The automation and translation slices below grew up with DCB, but they also serve aggregates (see Automation and Side Effects):

  • AutomationSlice — event-driven autonomous workflows

  • InboundTranslationSlice — converts external input to commands

  • OutboundTranslationSlice — reacts to events by calling external systems

Command flow:

Command → CommandTopic (shared, one per plugin)
→ extract tags from command
→ query DcbEventLog by tags + event types
→ evolve matching events into decision model
→ decide(state, command) → new events
→ conditional append (retry on conflict)
→ publish to EventTopic
→ StateViewSlice projects events into QueryDb

Hybrid Approach​

A single plugin can mix both approaches. Independent entities use aggregates; interdependent entities share a DCB event log. Both participate in the same plugin assembly, share the same API, and can define extension points and extensions.


Decision Criteria​

1. Entity Independence​

Choose Aggregate when the entity has a self-contained lifecycle. Its commands only need the entity's own history to make decisions. No other entity's state is required at decision time.

Choose DCB when entities are interdependent. A command on entity A needs to check the state of entity B (or a set of entities) to decide whether to proceed.

Examples:

  • Aggregate: Category — adding, renaming, or archiving a category only depends on the category's own state (does it exist? is it already archived?).
  • DCB: PlaceOrder — placing an order must verify that all referenced products exist and are available, which requires reading CatalogProduct events alongside Order events.

2. Consistency Boundary Shape​

Choose Aggregate when consistency boundaries align with entity identity. Every invariant you need to enforce involves exactly one entity ID.

Choose DCB when consistency boundaries span multiple entities or are determined at runtime. The set of events needed for a decision varies per command.

Examples:

  • Aggregate: "A product cannot be added twice" — one product ID, one check.
  • DCB: "An order can only be placed if all its line items reference existing products" — one order ID + N product IDs, checked atomically via tag-based query.

3. Command Granularity​

Choose Aggregate when commands map naturally to a single entity with multiple operations. The aggregate accumulates rich state and each command variant operates on that state.

Choose DCB when commands are thin and focused. Each command does one thing, often involving a cross-entity check. DCB slices are naturally fine-grained — one slice per command type.

Examples:

  • Aggregate: Product with commands Add, UpdateName, UpdateDescription, UpdatePrice — all operating on the same entity state. A single behavior module handles all variants.
  • DCB: AddProduct, ChangeProductName, ChangeProductDescription, ChangeProductPrice — each is a separate StateChangeSlice. They share the event log but have independent decision logic.

4. External Data Synchronization​

Choose DCB when an entity exists primarily to mirror data from another plugin or external system. DCB's StateChangeSlice + Extension pattern is purpose-built for this.

Examples:

  • DCB: SyncCatalogProduct — a StateChangeSlice in the Ordering plugin that receives product data from the Catalog plugin's extension point and writes it to the shared event log. The Ordering plugin's PlaceOrder slice can then query these synced product events alongside order events.
  • Aggregate: ProductDemand in the aggregate approach requires a dedicated aggregate with no user commands (extension-driven only), separate EventLog, separate ReadModel — more infrastructure for the same result.

5. Automation and Side Effects​

This criterion does not decide between the approaches. Three slice types cover autonomous workflows and external system integration, and they work with both:

  • AutomationSlice — reacts to events by generating new commands (e.g., auto-ship orders after payment confirmation). It declares one mapping per source, so it can observe an Aggregate's events, the plugin's DCB log, or both. See Mixed-source AutomationSlice.
  • OutboundTranslationSlice — reacts to events by calling external systems (e.g., send order confirmation email). Its sourceNames lists the Aggregates or DCB logs it listens to; [] means the plugin's own DCB log.
  • InboundTranslationSlice — receives external input and translates to commands (e.g., import products from supplier feed).

All three send their commands to the component named by targetName, which may be an Aggregate or a StateChangeSlice. The hybrid online shop shows it: the Customer aggregate's events feed GeocodeCustomerAddress, an outbound slice whose answer goes back to Customer.

Choose the approach for the entity on the other criteria, then add these slices where you need them. EventMappers remain the lighter option for stateless aggregate-to-aggregate routing, and Tasks for scheduled or file-triggered work. Choose an AutomationSlice over an EventMapper when you need its TODO list: retries, completion tracking and a heartbeat sweep.

6. Event Log Size and Query Performance​

Choose Aggregate when you expect high event volumes per entity. Each aggregate has its own stream — replay cost scales with the entity's history, not the entire domain.

Choose DCB when individual entities have modest event counts but you need flexible cross-entity queries. All events share one log, filtered by tags.

Trade-offs:

  • Aggregate replay is fast for small streams (< 1000 events per ID) but grows linearly. Snapshots can mitigate this.
  • DCB queries use DynamoDB secondary indexes indexed by tag values. Cross-entity queries are efficient but all events compete for the same table throughput.

Advantages and Consequences​

Aggregate Approach​

AdvantageConsequence
Clear entity boundaries — one aggregate, one event stream, one behaviorMore infrastructure per entity (CommandTopic, EventLog, EventTopic per aggregate)
Strong consistency within entity — commands serialized by IDNo cross-entity transactions — must use sagas or extension-driven aggregates for coordination
Full audit trail per entity — replay any ID to any point in timeState reconstruction cost — every command replays the full event history
Isolated testing — BehaviorTest DSL tests decide/evolve in pure functionsSeparate ReadModel infrastructure required for every query view
Mature pattern — well-documented, widely understoodIdempotency must be explicit (Ok([]) for no-change commands)
Efficient per-ID storage — DynamoDB partition per aggregate IDEvent schema is per-aggregate — no shared type across entities

DCB Approach​

AdvantageConsequence
Shared event log — one DcbEventLog per plugin, simpler infrastructureAll events share one table — throughput planning is domain-wide
Dynamic consistency — tag-based queries determine boundaries at runtimeTag annotations required on all filterable fields (@s.matches(DcbTag.string))
Cross-entity decisions — one query can span multiple entity typesOptimistic concurrency with retries (up to 3) — conflicts are expected
Fine-grained slices — one slice per command, easy to add/removeMore files per domain concept (one slice file per command vs one behavior with all commands)
Minimal decision model — evolve only the fields needed for the decisionEvent type union grows as slices are added (all events in one schema)
No separate ReadModel wiring — StateViewSlice projects directlyStateViewSlice is simpler but less flexible than ReadModel (single event source)

Hybrid Approach​

AdvantageConsequence
Best of both — independent entities get aggregates, interdependent entities share DCBTwo mental models in one plugin — team must understand both
Natural domain decomposition — each entity uses its natural boundary stylePlugin assembly is larger (both aggregate and DCB parameters)
Incremental migration — start with aggregates, introduce DCB where neededMust choose per entity — no entity can be both

Use Cases​

Use Case 1: Product Catalog (Aggregate)​

A catalog where products have independent lifecycles. Adding a product only checks if that product ID already exists. Updating a product only needs the product's current state.

Why Aggregate: Each product is fully self-contained. No product command needs another product's state. Clear entity boundary = clear aggregate.

Aggregate: Product (Add, UpdateName, UpdatePrice, Archive)
ReadModel: ProductsView (projects product events)

Use Case 2: Order Placement with Inventory Check (DCB)​

An ordering system where placing an order must verify that all referenced products exist and are available. The decision spans order state + product catalog state.

Why DCB: The PlaceOrder command needs to read product availability events alongside order events. A tag-based query on productId pulls events from both SyncCatalogProduct and PlaceOrder slices atomically.

StateChangeSlice: PlaceOrder (queries product + order events)
StateChangeSlice: SyncCatalogProduct (mirrors catalog data)
StateViewSlice: OrdersView, AvailableProductsView
AutomationSlice: AutoShipOrder (auto-ships after payment)
OutboundTranslationSlice: SendOrderConfirmation (emails)

Use Case 3: Category Management (Aggregate in Hybrid)​

Categories in a catalog have a simple, independent lifecycle alongside a DCB-based product system.

Why Aggregate in a Hybrid plugin: Category has no dependency on products or other entities. It doesn't need cross-entity consistency. Making it an aggregate keeps it simple while the more complex product/demand domain uses DCB.

Plugin: Catalog
Aggregate: Category (Add, Rename, Archive)
ReadModel: CategoriesView
DCB: Product slices, ProductDemand slices (shared event log)

Use Case 4: Customer Management (Aggregate)​

Customers register, update profile fields, and deactivate. Each operation depends only on the customer's own state.

Why Aggregate: Classic self-contained entity. No cross-customer invariants. Registration checks if the customer exists; updates check current state; deactivation is idempotent.

Aggregate: Customer (Register, UpdateEmail, UpdateAddress, Deactivate)
ReadModel: CustomersView

Use Case 5: Reservation System with Capacity Limits (DCB)​

A venue booking system where reserving a seat must check remaining capacity across all existing reservations for the same event.

Why DCB: The "reserve seat" command must count existing reservations for the event (cross-entity query by eventId tag) and compare against venue capacity. The consistency boundary spans all reservations for one event.

StateChangeSlice: ReserveSeat (queries reservations by eventId tag)
StateChangeSlice: CancelReservation
StateViewSlice: EventAvailabilityView
StateViewSlice: ReservationsView

Use Case 6: Supplier Product Import (DCB with InboundTranslation)​

An external supplier feed provides product data that must be validated and imported into the catalog.

Why DCB: InboundTranslationSlice receives external JSON, validates it (currency, required fields), and translates to AddProduct commands. The import itself does not force DCB, since the translation slice could equally target a Product aggregate. Product is DCB here because other slices make cross-entity decisions over product events.

InboundTranslationSlice: ImportProduct (validates + translates)
StateChangeSlice: AddProduct (handles the generated command)

Use Case 7: Audit Log / Compliance Tracking (Aggregate)​

A compliance system where every entity action must be independently auditable with full replay capability.

Why Aggregate: Per-entity event streams provide a complete audit trail. You can replay any entity to any point in time. The isolation guarantee means one entity's events never mix with another's.


Decision Flowchart​

Does the command need state from multiple entity types?
├─ Yes → DCB
└─ No
│
Does the entity have a self-contained lifecycle?
├─ Yes → Aggregate
└─ No (e.g., sync-only, reactive)
│
Is it part of a plugin that already uses DCB?
├─ Yes → DCB (add as another slice)
└─ No → Consider DCB or extension-driven Aggregate

Mixing Approaches: Guidelines​

  1. Per-entity decision. Each entity in a plugin is either an aggregate or a set of DCB slices. Never both.

  2. Group interdependent entities in DCB. If entities A and B share consistency rules, both must be DCB slices on the same DcbEventLog.

  3. Keep independent entities as aggregates. If an entity's commands never need another entity's state, an aggregate is simpler and more isolated.

  4. Extension points work with both. Both aggregates and DCB slices can define extension points and extensions. The mapping layer abstracts the internal approach.

  5. One DcbEventLog per plugin. All DCB slices in a plugin share a single event log. You cannot have multiple DcbEventLogs in one plugin.

  6. Start with aggregates, introduce DCB when needed. If you're unsure, start with aggregates. When you hit a cross-entity consistency requirement, convert those entities to DCB slices. The hybrid approach makes this migration incremental.


Identities in both approaches​

An entity's id type does not depend on the approach. Declare it once with Id.Make (see Id) and both kinds of component use it:

  • An aggregate is keyed by it: module Id = OrderId. The id travels in the envelope, not the payload, so an aggregate's own events rarely mention it. The payload fields that name other entities are typed.
  • A DCB slice types the fields that carry it, and the partition field is the entity the slice decides about. Its tags and partition are derived from the identity exactly as they were from the name.
  • A read model or view is keyed by it (module Id = OrderId for a read model, module Key = OrderId for a StateView), so a projection that keys a row by another entity's id does not compile.

In a hybrid plugin the same OrderId.res serves aggregates and slices alike, which is one reason to declare it in its own file rather than inside a component. Aggregates are the easier first adopter: an aggregate has no DCB tags, so a mistake there is a compile error rather than a tag that goes missing.


Naming: what a name means in each approach​

The last table says a DCB plugin has one shared CommandTopic and one shared EventTopic where the aggregate approach has one of each per aggregate. That is a deployment fact with a modelling consequence people meet late, so it is worth saying directly:

In a DCB plugin, a command name and an event name are routing keys, and the namespace is the whole plugin. In the aggregate approach they are scoped to the component that declares them.

What that changes​

Aggregates may repeat each other's names. Commands are addressed per component channel, and an aggregate's event stream is entity-scoped — consumers bind the source module (Mapping.Make(Product, …)) rather than matching a bare name. So Category.Added and Product.Added coexist in the shipped aggregates example and nobody has to think about it.

DCB slices may not. The runtime builds one handlersByType map across every StateChangeSlice in the plugin and dispatches on the incoming command's TAG alone. A DCB query selects on the event type name the same way.

The GraphQL surface shows the difference, which is usually where it is first noticed:

Ordering_Customer_Register(...)   # aggregate: plugin, component, command
Ordering_Subscribe(...) # DCB slice: plugin, command

Commands and events are not the same case​

They are both routing keys, but for opposite reasons, and it matters if you ever consider qualifying them:

  • A command has exactly one handler by definition. Its flat namespace is an implementation choice, and a duplicate is simply a bug.
  • An event has as many readers as care to consume it — PlaceOrder reads CatalogProductSynced, produced by a different slice entirely. A flat event namespace is what a dynamic consistency boundary is. Qualifying event names by component would couple every reader to its producer, which is the thing DCB exists not to do.

So the two constraints feel alike and are not: one is a detail, the other is the model.

What a duplicate command name does​

Nothing, at first. The second registration overwrites the first and the plugin deploys; the losing slice is simply never reached, and the runtime notices only later, when it goes to stamp owner fields on a command it cannot attribute.

The plugin structure therefore refuses it at build time, naming the command and both slices. Aggregates are exempt and are not checked.

How to avoid it​

Qualify names you might repeat, with whatever the slice is about:

AttachProductImage   ProductImageAttached   ProductImages
AttachCategoryImage CategoryImageAttached CategoryImages

That is not a style rule invented for this page — it is what the domain traits emitters do, and it is why one trait can be grafted onto two entities of the same plugin. It is also the reason a trait cannot hand a DCB slice its own constructors the way it can hand them to an aggregate: a spread cannot rename what it splices, so the trait's fixed names would land in the plugin-wide namespace and collide with the next host.


Reference: Component Mapping​

Aggregate ApproachDCB ApproachPurpose
Aggregate (Spec + Behavior)StateChangeSliceCommand handling, event production
EventLog (per ID)DcbEventLog (shared, tag-filtered)Event storage
ReadModel + ProjectionsStateViewSliceQuery-side projections
EventMapperAutomationSliceEvent-driven command generation
TaskInboundTranslationSliceExternal data ingestion
Side Effect HandlerOutboundTranslationSliceExternal system integration
CommandTopic (per aggregate)CommandTopic (shared per plugin)Command delivery
EventTopic (per aggregate)EventTopic (shared)Event distribution

The rows pair the closest equivalents; they are not exclusive. AutomationSlice and both translation slices also work alongside aggregates, and a ReadModel can project DCB events (see Mixed-source ReadModel).