ExtensionPoint
For a short summary of an ExtensionPoint, see Reventless Components Overview.
This component follows the Reventless Component Structure Pattern, using separate files for interface definitions (ExtensionPoint.res), builder logic (ExtensionPoint_Builder.res), runtime operations (ExtensionPoint_Operations.res), and callbacks (ExtensionPoint_Callback.res).
Overview
An ExtensionPoint defines the external interface of a Plugin, enabling cross-Plugin communication in a Reventless application. It acts as a translation layer between internal Plugin events/commands and the external world, providing a stable API that other Plugins can consume via Extensions.
Purpose and Responsibilities
- External Interface: Defines the public API of a Plugin for cross-Plugin communication
- Event Translation: Maps internal Aggregate events to external ExtensionPoint events
- Command Reception: Receives commands from Extensions and maps them to internal Aggregate commands
- Decoupling: Isolates internal Plugin changes from external consumers
- Stable Contract: Provides a versioned, stable interface for other Plugins
ExtensionPoint Spec
An ExtensionPoint Spec defines the contract for cross-Plugin communication. It specifies the commands that can be received, events that will be published, and directives (side effects) that can be triggered.
Spec Structure
module type Spec = {
let name: string // Unique identifier: "PluginName.ExtensionPointName"
@schema
type command // Commands that can be received from Extensions
@schema
type event // Events that will be published to Extensions
@schema
type directive // Side effects the mapping can trigger (see Directives)
}
Example Spec
The spec file lives in the provider plugin's spec package and is annotated with
@@reventless.spec. The PPX auto-injects let name, module Id, and
let moduleUrl — derived from the filename and (in a *Spec namespace)
prefixed with the plugin name to form the dotted EP name. You only declare the
@schema types:
// In CustomerSpec — @@reventless.spec derives the name "Customer.Customer".
@@reventless.spec
// Commands that Extensions can send to this ExtensionPoint
@schema
type command =
| RequestCustomerInfo({customerId: string})
| UpdateCustomerPreferences({customerId: string, preferences: preferences})
// Events that this ExtensionPoint publishes to Extensions
@schema
type event =
| CustomerCreated({customerId: string, name: string, email: string})
| CustomerUpdated({customerId: string, name: string, email: string})
| CustomerDeleted({customerId: string})
// Side effects this ExtensionPoint's mapping can fire from `mapOutgoingEvent` /
// `mapIncomingCommand`. Defaults to `unit` (no side effects). For a typed
// example, see `EmitPricingUpdate` in the hybrid online-shop example at
// `examples/online-shop-hybrid/catalog-spec/src/Products_ExtensionPoint.res`.
@schema
type directive = unit
Naming Convention
The dotted EP name follows the pattern "PluginName.ExtensionPointName". You do
not write let name by hand — @@reventless.spec derives it from the filename
and, in a *Spec namespace, prefixes the plugin name automatically.
ExtensionPoint Mappings
ExtensionPointMappings define how internal Aggregate events and commands are translated to/from the ExtensionPoint's external interface.
Mapping Structure
module type Impl = {
module ExtensionPoint: Spec // The ExtensionPoint spec
module Aggregate: Aggregate.Spec // The internal Aggregate spec
// Map incoming commands to Aggregate commands
let mapIncomingCommand: mapIncomingCommand<
ExtensionPoint.command,
Aggregate.command,
ExtensionPoint.directive,
>
// Map outgoing Aggregate events to ExtensionPoint events (optional)
let mapOutgoingEvent: option<
mapOutgoingEvent<Aggregate.event, ExtensionPoint.event, ExtensionPoint.directive>,
>
}
Mapping Functions
mapIncomingCommand
Maps commands received from Extensions to internal Aggregate commands:
type mapIncomingCommand<'extensionPointCommand, 'aggregateCommand, 'directive> = (
string, // Aggregate ID
'extensionPointCommand, // Incoming ExtensionPoint command
Message.meta, // Message metadata
) => array<commandAction<'aggregateCommand, 'directive>>
mapOutgoingEvent
Maps internal Aggregate events to ExtensionPoint events for publication:
type mapOutgoingEvent<'aggregateEvent, 'extensionPointEvent, 'directive> = (
string, // Aggregate ID
'aggregateEvent, // Internal Aggregate event
Message.meta, // Message metadata
QueryEngine.operations, // Query engine for lookups
) => array<eventAction<'extensionPointEvent, 'directive>>
Command Actions
When mapping incoming commands, you can return these actions:
type commandAction<'command, 'directive> =
| PublishCommand(string, 'command) // Publish command to Aggregate
| HandleDirective(directiveHandler<'directive>, 'directive) // Run a directive side-effect
Event Actions
When mapping outgoing events, you can return these actions:
type eventAction<'event, 'directive> =
| PublishEvent(string, 'event) // Publish event to Extensions
| PublishEventAsync(promise<(string, 'event)>) // Async event publication
| HandleDirective(directiveHandler<'directive>, 'directive) // Run a directive side-effect
Example Mapping
The mapping file is <Name>_ExtensionPointMapping.res, annotated with
@@reventless.spec. It references the EP spec via module ExtensionPoint and
declares a module Delegate — the local source (an Aggregate spec, or a DCB
adapter whose name is <pluginName>DcbEventLog). The action constructors
(PublishEvent, PublishCommand, PublishEventAsync, HandleDirective) are
injected by the PPX — never open them manually:
@@reventless.spec
module ExtensionPoint = CustomerSpec.Customer_ExtensionPoint
// Delegate: the local Customer Aggregate whose events feed the EP.
module Delegate = Customer
let mapIncomingCommand = (_id, command, _meta) =>
switch command {
| RequestCustomerInfo(_) =>
// This might trigger a query or event, not a command
[]
| UpdateCustomerPreferences({customerId, preferences}) => [
PublishCommand(customerId, Customer.UpdatePreferences({preferences: preferences})),
]
}
let mapOutgoingEvent = Some((_id, event, _meta, _queryEngine) =>
switch event {
| Delegate.Created({customerId, name, email}) => [
PublishEvent(customerId, CustomerSpec.Customer_ExtensionPoint.CustomerCreated({customerId, name, email})),
]
| Delegate.Deleted({customerId}) => [
PublishEvent(customerId, CustomerSpec.Customer_ExtensionPoint.CustomerDeleted({customerId})),
]
| _ => []
}
)
Defining an ExtensionPoint
To create an ExtensionPoint, you write two files:
- Spec (
<Name>_ExtensionPoint.res, in the plugin's spec package) — declares thecommand,event, anddirective@schematypes. - Mapping (
<Name>_ExtensionPointMapping.res, in the plugin) — references the EP spec and aDelegate, and implementsmapIncomingCommand/mapOutgoingEvent.
The plugin generator wires the EP automatically into the generated Plugin.res
as Platform.ExtensionPoint.Make(<Name>_ExtensionPointMapping) — a single
argument. You never write the composition root by hand.
Complete Example
The spec (see Example Spec) plus the mapping (see Example Mapping) are all you write. The generated wiring is:
module Make = (Platform: ReventlessInfra.Platform.T) => {
// ExtensionPoints
module Customer_ExtensionPoint = Platform.ExtensionPoint.Make(Customer_ExtensionPointMapping)
let make = (~uiBundleUrl=?) =>
Platform.Plugin.make(
~name="Customer",
~extensionPoints=[module(Customer_ExtensionPoint)],
// ... other components
)
}
Runtime Behavior
Outgoing Event Flow
When an Aggregate emits an event that should be published externally:
Incoming Command Flow
When an Extension sends a command to this ExtensionPoint:
Directives (side effects)
Besides publishing events and commands — which stay inside the event-sourced
system — an ExtensionPoint mapping can perform side effects that reach outside
it (call an external API, send a notification, create a schedule). These are
modelled as directives: the directive @schema type on the Spec, returned
from a mapping as a HandleDirective(handler, directive) action. The runtime then
calls handler(directive) as a fire-and-forget side effect — nothing is
published, logged to the event store, or replayed.
For the full picture — including the difference between ExtensionPoint and Extension directives and when to prefer a translation slice — see the Directives concept guide.
// In the Spec — directives are a variant type, like events
@schema
type directive =
| SendNotification({email: string, message: string})
| CallExternalAPI({url: string})
// In the Mapping — a handler closing over the injected Schedule/QueryEngine,
// returned via HandleDirective alongside the published event
let handleDirective: directiveHandler<ExtensionPoint.directive> = async (
_createSchedule, _deleteSchedule, _queryEngine, directive,
) =>
switch directive {
| SendNotification({email, message}) => let _ = await ExternalAPI.notify(email, message)
| CallExternalAPI({url}) => let _ = await ExternalAPI.get(url)
}
let mapOutgoingEvent = Some((_id, event, _meta, _queryEngine) =>
switch event {
| Delegate.Created({customerId, name, email}) => [
PublishEvent(customerId, ExtensionPoint.CustomerCreated({customerId, name, email})),
HandleDirective(handleDirective, SendNotification({email, message: "Welcome!"})),
]
| _ => []
}
)
Directive Handler Signature
An ExtensionPoint runs on the provider plugin's command/event-dispatch path, which the framework already wires with a scheduler and query engine — so the EP component threads them into the directive handler:
type directiveHandler<'directive> = (
Schedule.create, // Create scheduled tasks
Schedule.delete, // Delete scheduled tasks
QueryEngine.operations, // Query read models
'directive, // The directive payload
) => promise<unit>
This is why the ExtensionPoint side is the practical place for infra-bound effects
such as scheduling. (An Extension directive handler is a bare
'directive => promise<unit> with no scheduler — not because extensions are "less
trusted," but because the consumer's EventCollector path isn't wired with one. See
the concept guide.)
Component Outputs
An ExtensionPoint produces the following outputs:
type outputs = {
name: string, // ExtensionPoint name
aggregateNames: array<string>, // Connected Aggregate names
commandTopic: Pulumi.Output.t<CommandTopic.outputs>,
eventTopic: Pulumi.Output.t<EventTopic.outputs>,
}
Integration with Plugin
ExtensionPoints are wired automatically into the generated Plugin.res —
the plugin generator scans the ExtensionPoint/ folder and emits the wiring. You
never hand-write the composition root:
module Make = (Platform: ReventlessInfra.Platform.T) => {
// ExtensionPoints
module Customer_ExtensionPoint = Platform.ExtensionPoint.Make(Customer_ExtensionPointMapping)
module Order_ExtensionPoint = Platform.ExtensionPoint.Make(Order_ExtensionPointMapping)
let make = (~uiBundleUrl=?) =>
Platform.Plugin.make(
~name="Customer",
~extensionPoints=[module(Customer_ExtensionPoint), module(Order_ExtensionPoint)],
// ... other components
)
}
Best Practices
Spec Design
- Stable Interface: Design ExtensionPoint specs to be stable across versions
- Minimal Surface: Only expose events and commands that external Plugins need
- Clear Naming: Use descriptive names for commands and events
- Versioning: Consider versioning your ExtensionPoint spec for breaking changes
Mapping Design
- Translation Layer: Always translate internal types to external types
- Information Hiding: Don't expose internal implementation details
- Selective Publishing: Only publish events that are relevant to external consumers
- Error Handling: Handle mapping errors gracefully
Security Considerations
- Data Filtering: Filter sensitive data before publishing events
- Command Validation: Validate incoming commands before processing
- Access Control: Consider which Plugins should have access to this ExtensionPoint
Pulumi
The ExtensionPoint's Pulumi root component is named using the pattern: {PluginName}{ExtensionPointName} (dots removed) and has a type of reventless:ExtensionPoint.
Infrastructure Components
An ExtensionPoint deploys:
- CommandTopic: Receives commands from Extensions
- EventTopic: Publishes events to Extensions
Protocol Versioning
When you evolve an ExtensionPoint's commands or events over time, you need to maintain backwards compatibility with Extensions that may be on an older version. See Extension Point Protocol Versioning for the framework's approach to versioning extension point protocols.
Related Components
- Plugin - Container for ExtensionPoints
- Extension - Consumes ExtensionPoints from other Plugins
- Aggregate - Source of events and target of commands
- CommandTopic - Command delivery mechanism
- EventTopic - Event publication mechanism