Application Development Layers
Reventless organises application code into three strictly separated layers. Each layer has a clearly defined responsibility, a fixed set of package dependencies, and its own placement within the source tree.
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 1 — Domain Specification │
│ deps: reventless-spec only │
│ Pure domain logic — commands, events, state, decisions │
├─────────────────────────────────────────────────────────────────────┤
│ Layer 2 — Plugin Assembly │
│ deps: reventless-spec + reventless-infra │
│ Wires domain modules into a platform-agnostic plugin functor │
├─────────────── ──────────────────────────────────────────────────────┤
│ Layer 3 — Platform Composition Root │
│ deps: reventless-infra + reventless-aws OR reventless-local │
│ Instantiates a concrete Platform and produces deployable outputs │
└─────────────────────────────────────────────────────────────────────┘
The dependency arrow always points downward. Layer 1 knows nothing about
infrastructure. Layer 2 knows the shape of infrastructure (via Platform.T)
but not the provider. Layer 3 is the only place that names a specific
provider.
Layer 1 — Domain Specification
Purpose
Everything a domain expert would recognise: aggregate state machines, DCB
slices, read model projections, extension point contracts. No Pulumi, no AWS,
no Effect, no DynamoDB — only sury (for JSON schema derivation via the
@schema ppx) and the behavioral contracts from reventless-spec.
Package dependencies
"dependencies": [
"sury",
"@reventlessdev/reventless-spec"
]
reventless-spec provides Aggregate.Spec, Behavior.T, ReadModel.Spec,
Projection, StateChangeSlice.Spec, StateViewSlice.Spec,
DcbEventLog.Spec, SideEffect.T, Handler.handler, Id, Message.meta,
Schedule, EventMapping.action, etc. — every type that describes what
the domain does, with no infrastructure attached.
What goes here
Aggregate-style: Spec + Behavior + Projections
Aggregate Spec (Product.res)
The spec module satisfies Reventless.Aggregate.Spec. It declares the
aggregate's Id, name, and the three domain types the framework needs:
open Reventless
module Id = Id.String
let name = "Product"
@schema
type command =
| AddProduct({productId: string, name: string, description: string, price: float})
| UpdateProductName({productId: string, name: string})
@schema
type event =
| ProductAdded({productId: string, name: string, description: string, price: float})
| ProductNameUpdated({productId: string, name: string})
@schema
type error =
| ProductAlreadyExists
| ProductNotFound
The @schema ppx generates commandSchema, eventSchema, and errorSchema
— sury codecs used by the framework for serialisation, routing, and error
reporting. The app developer never writes these by hand.
Aggregate Behavior (ProductBehavior.res)
The behavior module satisfies Reventless.Behavior.T. It implements the
state machine: how to reconstruct state from events and how to process
commands:
open Reventless
open Product
module Spec = Product
@schema
type state = {name: string, description: string, price: float}
let init = event =>
switch event {
| ProductAdded({name, description, price}) => {name, description, price}
| _ => throw(Message.InvalidEvent(event->Message.encode(eventSchema)))
}
let apply = (state, event) =>
switch event {
| ProductAdded({name, description, price}) => {name, description, price}
| ProductNameUpdated({name}) => {...state, name}
| _ => state
}
let create = (command, _context, errorHandler) =>
switch command {
| AddProduct({productId, name, description, price}) =>
[ProductAdded({productId, name, description, price})]
| _ => errorHandler(ProductNotFound, command, _context)
}
let execute = (state, command, context, errorHandler) =>
switch command {
| AddProduct(_) => errorHandler(ProductAlreadyExists, command, context)
| UpdateProductName({productId, name}) => [ProductNameUpdated({productId, name})]
| _ => errorHandler(ProductNotFound, command, context)
}
create is called when the aggregate does not yet exist (no prior events).
execute is called when the aggregate already has state.
Read Model Spec (ProductsReadModel.res)
Satisfies Reventless.ReadModel.Spec. Declares the query-side state shape:
open Reventless
module Id = Id.String
let name = "Products"
@schema
type state = {
productId: string,
name: string,
description: string,
price: float,
}
open Reventless.ReadModel
let config = config()
let subIdConfig = None
Projections (ProductsProjections.res)
Maps aggregate events to read model mutations using Reventless.Projection:
open Reventless
open Reventless.Projection
module ProductMapping = Mapping.Make(
Product, // source aggregate spec
ProductsReadModel, // target read model spec
{
let map = ({Message.event: event, id, _}) =>
switch event {
| Product.ProductAdded({productId, name, description, price}) =>
Set(id, {ProductsReadModel.productId, name, description, price})
| Product.ProductNameUpdated({name}) =>
Update(id, state => {...state, name})
| _ => NoOp
}
},
)
module Mappings = Mappings.Make(ProductsReadModel)
let mappings: array<module(Mappings.Mapping)> = [module(ProductMapping)]
Set, Update, Delete, Create, and NoOp are the projection actions.
Mapping.Make enforces at compile time that the source spec and target spec
are compatible.
DCB-style: Event Log + State Change Slices + State View Slices
DCB (Dynamic Consistency Boundary) replaces per-aggregate event logs with a single shared log whose events are tagged for efficient entity-scoped queries.
Event Log Spec (CatalogEventLog.res)
Satisfies Reventless.DcbEventLog.Spec. All events for the plugin live here,
tagged with @s.matches(DcbTag.string) on entity ID fields:
open Reventless
@schema
type event =
| ProductAdded({
productId: @s.matches(DcbTag.string) string,
name: string,
description: string,
price: float,
})
| ProductNameUpdated({productId: @s.matches(DcbTag.string) string, name: string})
| CategoryAdded({categoryId: @s.matches(DcbTag.string) string, name: string})
The @s.matches(DcbTag.string) annotation marks fields that become DCB tags.
The framework uses these to scope event log queries to specific entities.
State Change Slice (AddProduct.res)
Satisfies Reventless.StateChangeSlice.Spec. Implements the decision logic
for one command type against the shared event log:
open Reventless
open CatalogEventLog
let name = "AddProduct"
module DcbEventLogSpec = CatalogEventLog
@schema
type command =
| AddProduct({
productId: @s.matches(DcbTag.string) string,
name: string,
description: string,
price: float,
})
@schema
type error = | ProductAlreadyExists
type decisionModel = {exists: bool}
let initialDecisionModel = {exists: false}
let reduce = (model, event) =>
switch event {
| ProductAdded(_) => {exists: true}
| _ => model
}
let decide = (model, command) =>
switch command {
| AddProduct({productId, name, description, price}) =>
if model.exists {Error(ProductAlreadyExists)}
else {Ok([ProductAdded({productId, name, description, price})])}
}
Each slice: (1) references the shared event log via DcbEventLogSpec; (2)
declares its own command type; (3) provides reduce to build a decision model
from relevant events; (4) provides decide to make the decision.
State View Slice (ProductsView.res)
Satisfies Reventless.StateViewSlice.Spec. Projects DCB events to a query
read model:
open Reventless
let name = "ProductsView"
module DcbEventLogSpec = CatalogEventLog
@schema
type state = {name: string, description: string, price: float}
let project = (current, event) =>
switch event {
| CatalogEventLog.ProductAdded({productId, name, description, price}) =>
[Projection.Spec.Set(productId, {name, description, price})]
| CatalogEventLog.ProductNameUpdated({productId, name}) =>
[Projection.Spec.Update(productId, s => {...s, name})]
| _ => []
}
Extension Point and Extension contracts
Cross-plugin communication is defined entirely in Layer 1. An extension point is a stable public event/command API that a plugin publishes outward. An extension is a mapping inside another plugin that subscribes to it.
Extension Point Spec (ProductsExtensionPointSpec.res)
let name = "Catalog.Products"
@schema
type command = unit // read-only EP: no inbound commands
@schema
type event =
| ProductBecameAvailable({productId: string, name: string, price: float})
| ProductPriceChanged({productId: string, price: float})
@schema
type directive = unit
The name field is the runtime identifier that must match exactly on both the
publishing and subscribing side.
Extension Point Mapping (ProductsExtensionPointMapping.res)
Maps internal aggregate events to the public EP event API. Lives in the
publishing plugin's source tree and references ReventlessInfra.ExtensionPointMapping:
open ReventlessInfra.ExtensionPointMapping
module ExtensionPoint = ProductsExtensionPointSpec
module Aggregate = Product // the source aggregate spec
let mapIncomingCommand = (_id, _command, _meta) => []
let mapOutgoingEvent = Some((_id, event, _meta, _queryEngine) =>
switch event {
| Product.ProductAdded({productId, name, price}) =>
[PublishEvent(productId, ProductsExtensionPointSpec.ProductBecameAvailable({productId, name, price}))]
| Product.ProductPriceUpdated({productId, price}) =>
[PublishEvent(productId, ProductsExtensionPointSpec.ProductPriceChanged({productId, price}))]
| _ => []
})
Extension Mapping (ProductsExtension.res)
Lives in the subscribing plugin's source tree and translates incoming EP events to aggregate commands:
open ReventlessInfra.ExtensionMapping
module Spec = ProductsExtensionPointSpec
module ProductMappingImpl = {
module ExtensionPoint = Spec
module Aggregate = CatalogProduct // target aggregate
let mapIncomingEvent = (_id, event, _meta, _pluginDef, _queryEngine) =>
switch event {
| Spec.ProductBecameAvailable({productId, name, price}) =>
[PublishAggregateCommand(productId, CatalogProduct.SyncNewProduct({productId, name, price}))]
| Spec.ProductPriceChanged({productId, price}) =>
[PublishAggregateCommand(productId, CatalogProduct.UpdateSyncedPrice({productId, price}))]
}
let mapOutgoingEvent = None
}
module ProductMappingT = Make(ProductMappingImpl)
module Mappings = {
module Spec = Spec
module type Mapping = T with module ExtensionPoint := Spec
let name = "OrderingProducts"
let mappings: array<module(Mapping)> = [module(ProductMappingT)]
}
Summary of Layer 1 module types
| Module | Satisfies | Package |
|---|---|---|
Product.res | Reventless.Aggregate.Spec | reventless-spec |
ProductBehavior.res | Reventless.Behavior.T | reventless-spec |
ProductsReadModel.res | Reventless.ReadModel.Spec | reventless-spec |
ProductsProjections.res | mappings via Reventless.Projection | reventless-spec |
CatalogEventLog.res | Reventless.DcbEventLog.Spec | reventless-spec |
AddProduct.res | Reventless.StateChangeSlice.Spec | reventless-spec |
ProductsView.res | Reventless.StateViewSlice.Spec | reventless-spec |
*ExtensionPointSpec.res | EP name + event/command/directive | reventless-spec |
*ExtensionPointMapping.res | ReventlessInfra.ExtensionPointMapping | reventless-infra |
*Extension.res | ReventlessInfra.ExtensionMapping | reventless-infra |
Note:
ExtensionPointMappingandExtensionMappingreference infra types (ReventlessInfra.ExtensionPointMapping,ReventlessInfra.ExtensionMapping) because their mapping actions (PublishEvent,PublishAggregateCommand,PublishStateChangeSliceCommand) are infrastructure concepts. They are still logically part of the domain specification layer — they just touch the layer 1/2 boundary.
Layer 2 — Plugin Assembly
Purpose
The plugin module is a functor parameterised on Platform.T. It wires
Layer 1 domain modules into infrastructure components using the abstract
factory methods on Platform. The result is a fully wired plugin that works
against any provider implementation of Platform.T — AWS, in-memory,
or a future alternative.