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

Hybrid Implementation

The hybrid approach mixes aggregate-based and DCB-based components within a single Plugin. Each entity gets the modeling strategy that fits best:

  • Independent entities use aggregates — simple, isolated event streams with per-instance consistency
  • Interdependent entities share a DCB event log — enabling cross-entity decision models with per-command optimistic concurrency

In this example: Category and Customer stay as aggregates because their lifecycles are fully independent. Product + ProductDemand and Order + CatalogProduct share DCB event logs because they benefit from querying each other's events in the same filtered read.

Everything that isn't aggregate-vs-DCB-specific — translation slices, automations, extension points, extensions, and all cross-plugin wiring — is identical across the aggregate, DCB, and hybrid implementations. Only the entity modeling differs.

This page tracks the real package

The code on this page describes the actual examples/online-shop-hybrid/ package. Two things differ from a hand-written sketch:

  • Plugin.res is generated, not hand-written. A prebuild step runs generate-plugin src/, which scans the plugin's src/ folders by name (Aggregate/, StateChangeSlice/, StateViewSliceStream/, ReadModel/, Task/, …) and wires every component it finds. You add a folder + file; the generator does the wiring. See Plugin composition below.
  • There is no *EventLog.res file. The shared DCB event log is implied by the slices — each slice declares its own events, and the DCB log is their union. You never write an event-log type by hand.

Plugin 1: Catalog

Manages the product catalogue — what is available for sale and how it is organized.

Aggregate: Category

A named grouping of products (e.g. "Books", "Electronics"). Category has its own event log — separate from the DCB event log.

CommandsEvents
AddCategoryCategoryAdded
RenameCategoryCategoryRenamed
ArchiveCategoryCategoryArchived

Why an aggregate? Category has no relationship to Product or ProductDemand events. Including it in the DCB log would add noise without benefit. Its simple Add/Rename/Archive lifecycle is a natural fit for an isolated aggregate with per-instance consistency.

DCB Entity: Product

A product listing with a name, description, and price. Product events are tagged by productId in the shared DCB event log.

State Change SlicesCommandsEvents
AddProductAddProductProductAdded
ChangeProductNameChangeProductNameProductNameChanged
ChangeProductDescriptionChangeProductDescriptionProductDescriptionChanged
ChangeProductPriceChangeProductPriceProductPriceChanged
State View Slice (Stream)EventsQueryable view
ProductsProductAdded, ProductNameChanged, ProductDescriptionChanged, ProductPriceChangedProducts

In the source these live in catalog/src/Product/StateViewSliceStream/ — the Stream variant projects into a live-updating view that pushes changes to subscribed clients. (Use the non-stream StateViewSlice when you don't need live updates.)

Inbound Translation: Import Product from Supplier

An InboundTranslationSlice receives external supplier data, validates it, and translates it into an AddProduct command.

Inbound Translation SliceExternal InputCommand Produced
ImportProductSupplier product JSONAddProduct

DCB Entity: ProductDemand

Tracks per-product order demand. Driven entirely by events arriving from Ordering's Extension Point. Demand events are tagged by productId — the same tag as Product events, so the ProductDemandView can combine both in a single filtered read.

State Change SlicesCommandsEvents
RecordProductDemandRecordDemand, RevokeDemandProductDemandRecorded, ProductDemandRevoked
State View Slice (Stream)EventsQueryable view
ProductDemandProductAdded, ProductDemandRecorded, ProductDemandRevokedProductDemand

Why Product + ProductDemand share DCB? ProductDemand uses the same productId tag as Product events. The ProductDemand view can query both in a single filtered read. The RecordProductDemand decision model can validate product existence — something that would require a cross-aggregate query in the aggregate-based approach.

Read Model: CatalogActivity

A plugin-wide audit feed — one denormalised row per entity (a category or a product) that has changed in the catalog. It is a mixed-source read model, populated from both the Category aggregate's events and the Product DCB log via a multi-source projection (catalog/src/CatalogActivity/ReadModel/). Categories and Products use the non-stream ReadModel variant here.

Read ModelSourcesState
CatalogActivityCategory aggregate + Product DCB log{name, kind: Category | Product, lastChange: Added | Renamed | Archived}

This is the canonical example of a read model that spans an aggregate and a DCB log in one projection — see Mixed-source read models for the pattern.

Task: ImportProducts

A background Task (catalog/src/Task/ImportProducts.res) that watches an S3 bucket (product-imports) for uploaded product files. It is the file-triggered counterpart to the webhook-style ImportProduct InboundTranslationSlice above: both ultimately produce AddProduct commands, but the Task reacts to bucket uploads while the slice reacts to inbound webhook payloads.

The shared Catalog DCB event log

There is no CatalogEventLog.res file. The shared DCB log is implied by the Product and ProductDemand slices: each slice declares the events it produces, and the log is their union. It contains only Product and ProductDemand events — no Category events, because Category is an aggregate with its own per-instance event log.

Conceptually, the events flowing through the shared Catalog DCB log are:

// Illustrative union — assembled from the slices, not a file you write.
// The `productId` tag is what lets ProductDemand and Product events be read together.
@schema
type event =
| ProductAdded({productId: @s.matches(DcbTag.string) string, name: string, /* … */})
| ProductNameChanged({productId: @s.matches(DcbTag.string) string, name: string})
// … ProductDescriptionChanged, ProductPriceChanged
| ProductDemandRecorded({productId: @s.matches(DcbTag.string) string, orderId: string})
| ProductDemandRevoked({productId: @s.matches(DcbTag.string) string, orderId: string})

Compare this with the pure DCB implementation, whose Catalog log also carries CategoryAdded, CategoryRenamed, and CategoryArchived. In the hybrid approach those events live in the Category aggregate's own event log instead.

Extension Point: ProductsExtensionPoint

Outbound API from Catalog to Ordering. Translates internal Product events into a stable public vocabulary.

EP EventTriggered By
ProductBecameAvailableProductAdded
ProductPriceChangedProductPriceChanged

Extension: OrdersExtension

Inbound subscription to Ordering's OrdersExtensionPoint. Routes demand events to RecordProductDemand slice commands.

EP Event ReceivedCommand Dispatched
ItemOrderedRecordDemand
ItemOrderCancelledRevokeDemand

Plugin 2: Ordering

Handles the purchase flow — who is buying and what they ordered.

Aggregate: Customer

A registered buyer with contact details and account status. Customer has its own event log — separate from the DCB event log.

CommandsEvents
RegisterCustomerCustomerRegistered
UpdateEmailEmailUpdated
UpdateAddressAddressUpdated
DeactivateCustomerCustomerDeactivated

Why an aggregate? Customer lifecycle is fully independent. No cross-entity consistency with Order or CatalogProduct is needed. Its register/update/deactivate lifecycle is a natural fit for a simple aggregate.

DCB Entity: Order

A confirmed purchase referencing product IDs and a customer. Order events are tagged by orderId in the shared DCB event log.

State Change SlicesCommandsEvents
PlaceOrderPlaceOrderOrderPlaced
ShipOrderShipOrderOrderShipped
CancelOrderCancelOrderOrderCancelled
RefundOrderIssueRefundRefundIssued

RefundOrder is an internal, admin-only slice: its command is marked @noApi, so it is not exposed on the public GraphQL API. It models a refund workflow triggered after a cancellation rather than by an external client — a small but realistic example of a command that exists for automation/operations only.

State View Slice (Stream)EventsQueryable view
OrdersOrderPlaced, OrderShipped, OrderCancelledOrders

Automation: Auto-Ship Order

An AutomationSlice automatically ships every placed order.

Automation SliceTrigger EventCommand IssuedResolved By
AutoShipOrderOrderPlacedShipOrderOrderShipped

Outbound Translation: Send Order Confirmation Email

An OutboundTranslationSlice sends a confirmation email whenever an order is placed.

Outbound Translation SliceTrigger EventExternal Action
SendOrderConfirmationOrderPlacedSend email via EmailService

EmailService is a real (stubbed) domain service at ordering/src/Service/EmailService.res. Keeping the integration behind a service module is the recommended pattern: the slice depends on the service interface, and only the service knows how to talk to the outside world.

DCB Entity: CatalogProduct

A lightweight shadow copy of Catalog product data, kept in sync via Catalog's Extension Point. CatalogProduct events are tagged by productId in the shared DCB event log.

State Change SlicesCommandsEvents
SyncCatalogProductSyncNewProduct, ChangeSyncedPriceCatalogProductSynced, CatalogProductPriceChanged
State View Slice (Stream)EventsQueryable view
AvailableProductsCatalogProductSynced, CatalogProductPriceChangedAvailableProducts

Why Order + CatalogProduct share DCB? Both entities benefit from living in the same event log. The shared log means CatalogProduct sync events and Order events are available together, enabling the framework to deliver both in filtered reads for projections like AvailableProductsView.

Cross-entity validation: The PlaceOrder command uses a tagged array field (productId: array<@s.matches(DcbTag.string) string>) to reference product IDs. The runtime automatically builds a multi-clause OR query that fetches both Order events (by orderId) and CatalogProduct events (by each productId) into the same decision model — enabling PlaceOrder to reject orders referencing unknown products.

The shared Ordering DCB event log

As in Catalog, there is no OrderingEventLog.res file — the shared DCB log is implied by the Order and CatalogProduct slices. It contains only Order and CatalogProduct events — no Customer events, because Customer is an aggregate with its own per-instance event log.

Conceptually, the events flowing through the shared Ordering DCB log are:

// Illustrative union — assembled from the slices, not a file you write.
@schema
type event =
| OrderPlaced({orderId: @s.matches(DcbTag.string) string, productIds: array<string>, /* … */})
| OrderShipped({orderId: @s.matches(DcbTag.string) string})
// … OrderCancelled
| CatalogProductSynced({productId: @s.matches(DcbTag.string) string, name: string, price: float})
| CatalogProductPriceChanged({productId: @s.matches(DcbTag.string) string, price: float})

Compare this with the pure DCB implementation, whose Ordering log also carries CustomerRegistered, EmailChanged, AddressChanged, and CustomerDeactivated. In the hybrid approach those events live in the Customer aggregate's own event log instead.

Extension Point: OrdersExtensionPoint

Outbound API from Ordering to Catalog.

EP EventTriggered By
ItemOrderedOrderPlaced
ItemOrderCancelledOrderCancelled

Extension: ProductsExtension

Inbound subscription to Catalog's ProductsExtensionPoint.

EP Event ReceivedCommand Dispatched
ProductBecameAvailableSyncNewProduct
ProductPriceChangedChangeSyncedPrice

Cross-Plugin Integration

Cross-plugin communication is identical to the other two implementations. Extension Points abstract away whether the source entity uses an aggregate or DCB internally — the EP contract is the same. Neither Plugin knows or cares how the other models its entities.


Plugin composition

You do not hand-write the plugin composition root. A prebuild step runs generate-plugin src/, which scans the plugin's folders by name and emits src/Plugin.res. Adding a component is a matter of dropping a file into the right folder — the generator wires it.

// catalog/package.json
"scripts": {
"generate": "generate-plugin src/",
"prebuild": "pnpm run generate",
"build": "rescript build"
}

The generator maps each folder to a functor and a Plugin.make argument:

FolderGenerated asPlugin.make argument
Aggregate/Platform.Aggregate.Make(Spec, Behavior, …)~aggregates
StateChangeSlice/Platform.StateChangeSlice.Make(Spec, Behavior)~stateChangeSlices
StateViewSliceStream/Platform.StateViewSliceStream.Make(Spec, Projection)~stateViewSlices
ReadModel/Platform.ReadModel.Make(Spec, Projections)~readModels
ReadModelStream/Platform.ReadModelStream.Make(Spec, Projections)~readModels
InboundTranslationSlice/Platform.InboundTranslationSlice.Make(Spec, Translation)~inboundTranslationSlices
AutomationSlice/Platform.AutomationSlice.Make(Spec, Automation)~automationSlices
OutboundTranslationSlice/Platform.OutboundTranslationSlice.Make(Spec, Translation)~outboundTranslationSlices
Task/Platform.Task.Make(Spec)~tasks
ExtensionPoint/Platform.ExtensionPoint.Make(Mapping)~extensionPoints
Extension/Platform.Extension.Make(Mapping)~extensions

The "hybrid" is invisible in your source: because Catalog has both an Aggregate/ folder (Category) and StateChangeSlice/ folders (Product, ProductDemand), the generated Plugin.make call simply receives both ~aggregates and the DCB slice arrays. The framework routes aggregate commands to per-instance event logs and DCB commands to the shared (implied) DCB log.

The generated catalog/src/Plugin.res

This file is committed to git (CI compiles it directly) but is regenerated on every build — never edit it by hand:

// AUTO-GENERATED — do not edit. Run `npm run generate` to update.
module Make = (Platform: ReventlessInfra.Platform.T) => {
// StateChangeSlices (Product + ProductDemand — the DCB entities)
module AddProductSlice = Platform.StateChangeSlice.Make(AddProduct, AddProduct_Behavior)
module ChangeProductNameSlice = Platform.StateChangeSlice.Make(ChangeProductName, ChangeProductName_Behavior)
// … ChangeProductDescription, ChangeProductPrice, RecordProductDemand

// StateViewSliceStreams (live-updating views)
module ProductsStreamSlice = Platform.StateViewSliceStream.Make(Products, Products_Projection)
module ProductDemandStreamSlice = Platform.StateViewSliceStream.Make(ProductDemand, ProductDemand_Projection)

// InboundTranslationSlices
module ImportProductSlice = Platform.InboundTranslationSlice.Make(ImportProduct, ImportProduct_Translation)

// Aggregates (Category — the independent entity)
module CategoryAggregate = Platform.Aggregate.Make(
Category, Category_Behavior, ReventlessInfra.NoEventMappings.Make(Category),
)

// ReadModels (non-stream)
module CatalogActivityReadModel = Platform.ReadModel.Make(CatalogActivity, CatalogActivity_Projections)
module CategoriesReadModel = Platform.ReadModel.Make(Categories, Categories_Projections)

// Tasks
module ImportProductsTask = Platform.Task.Make(ImportProducts)

// ExtensionPoint (outbound) + Extension (inbound)
module Products_ExtensionPoint = Platform.ExtensionPoint.Make(Products_ExtensionPointMapping)
module Orders_Extension = Platform.Extension.Make(Orders_Extension.Mapping)

let make = (~uiBundleUrl=?) =>
Platform.Plugin.make(
~name="Catalog",
~heartbeatInterval=5,
~aggregates=[module(CategoryAggregate)], // ← aggregate entity
~readModels=[module(CatalogActivityReadModel), module(CategoriesReadModel)],
~tasks=[module(ImportProductsTask)],
~stateChangeSlices=[module(AddProductSlice), /* … */ module(RecordProductDemandSlice)], // ← DCB entities
~stateViewSlices=[module(ProductsStreamSlice), module(ProductDemandStreamSlice)],
~inboundTranslationSlices=[module(ImportProductSlice)],
~extensionPoints=[module(Products_ExtensionPoint)],
~extensions=[module(Orders_Extension)],
// …a pluginStructure definition and an Auto UI manifest are also generated
)
}

The key point: one generated Plugin.make receives both ~aggregates (for Category) and the DCB slice arrays (for Product/ProductDemand). The framework handles the routing — aggregate commands go to per-instance event logs, DCB commands go to the shared event log.

The generated ordering/src/Plugin.res

Ordering is generated the same way. Its make wires:

  • the Customer aggregate, with a Customers ReadModelStream (the live-updating read-model variant — Platform.ReadModelStream.Make);
  • the Order and CatalogProduct DCB slices (PlaceOrder, ShipOrder, CancelOrder, RefundOrder, SyncCatalogProduct);
  • the AutoShipOrder automation slice and the SendOrderConfirmation outbound-translation slice;
  • the Orders and AvailableProducts StateViewSliceStream views;
  • the Orders extension point (outbound) and Products extension (inbound).

Same hybrid pattern: one generated Plugin.make receives ~aggregates for Customer and the DCB slice arrays for Order/CatalogProduct.


When to Choose Hybrid

The hybrid boundary must be clean: entities that need cross-entity consistency must share the same DCB event log; independent entities should be aggregates, since adding them to the DCB log adds noise without benefit. For the per-entity decision procedure, see Choosing an approach.


Next: Run it locally → — start the whole shop on your machine with the local platform.