OutboundTranslationSlice
For a short summary of OutboundTranslationSlice, see Reventless Components Overview.
This component follows the Reventless Component Structure Pattern, using separate files for interface definitions (OutboundTranslationSlice.res), builder logic (OutboundTranslationSlice_Builder.res), and callback/handler logic (OutboundTranslationSlice_Callback.res).
Overview
The OutboundTranslationSlice implements the Event Modeling Translation pattern for outbound external communication. It listens to events from a shared DcbEventLog, accumulates outbound work items into a TODO list, translates each item by calling an external service, and optionally publishes a command back into the domain.
Event Modeling: The Outbound Translation Pattern
In Event Modeling, an Outbound Translation handles communication from the system to external services:
Event(s) --> TODO List --> Translator --> External System
--> Command (optional)
The key difference from AutomationSlice is that the "process" step involves an external call (async, may fail) rather than a deterministic command derivation. Each item is translated independently, allowing individual success or failure.
Purpose and Responsibilities
- Responsibility: Collect outbound items from events; call external services for each item via the
translatefunction; optionally publish commands back into the domain; track status with per-item retry semantics - In: Events from DcbEventLog (subscribed via EventCollector)
- Out: External API calls (via
translate); optional commands to CommandTopic (viapublishJsons); TODO state synced to QueryDb
Comparison with SideEffectHandler
| Aspect | SideEffectHandler | OutboundTranslationSlice |
|---|---|---|
| Architecture | Aggregate-based plugins | DCB-based plugins |
| State tracking | None -- fire-and-forget | TODO list with status (Pending/Processing/Completed/Failed) |
| Retry | Relies on EventCollector retry (entire batch) | Per-item retry with configurable max |
| Idempotency | None -- replays cause duplicate calls | Deduplication key prevents double-processing |
| Visibility | No queryable state | QueryDb stores full processing history |
| Command emission | Never | Optional -- can publish commands back |
Choose SideEffectHandler when you have plugins using Aggregates and need simple fire-and-forget event reactions.
Choose OutboundTranslationSlice when you need tracked, retryable external calls with full observability in a DCB-based plugin.
Component Spec
An OutboundTranslationSlice is split into two files:
<Name>.res— the spec (@@reventless.spec): theconsumedEvent,outboundItem, andinboundCommand@schematypes, the sweep config (maxRetries,heartbeatInterval), andtargetName(Nonefor fire-and-forget, orSome("<TargetSlice>")to publish a command back).<Name>_Translation.res— the translation (@@reventless.translation): thecollectfunction (event → outbound items) and the asynctranslatefunction (the external call).
The spec module type the framework expects:
module type Spec = {
// name and moduleUrl are injected by @@reventless.spec — you never write them
@schema type consumedEvent
@schema type outboundItem
@schema type inboundCommand
let maxRetries: int
let heartbeatInterval: int
let targetName: option<string>
}
collect and translate live on the Translation module. There is no
DcbEventLogSpec reference; the slice declares a local consumedEvent union.
Spec Fields Explained
| Field | Type | Description |
|---|---|---|
consumedEvent | @schema type | The local subset of event variants this slice reacts to |
outboundItem | @schema type | Data accumulated for each pending external call |
inboundCommand | @schema type | Command type optionally published back after translate. Use unit for fire-and-forget |
maxRetries | int | Maximum retry attempts for failed items |
heartbeatInterval | int | Seconds between heartbeat sweeps for pending/failed items |
targetName | option<string> | None for fire-and-forget; Some("<TargetSlice>") to route the optional command back |
In the _Translation.res file:
| Function | Type | Description |
|---|---|---|
collect | consumedEvent => array<(string, outboundItem)> | Map an event to zero or more outbound items (id + payload) |
translate | (string, outboundItem) => promise<result<...>> | Call external service; returns success with optional command, or error |
The translate Return Values
The translate function is the anti-corruption layer -- where user code calls external APIs:
Ok(Some((targetId, command)))-- External call succeeded; publish command back into the domain (e.g., confirm payment after calling payment gateway)Ok(None)-- External call succeeded; no command needed (fire-and-forget, e.g., send email notification)Error(msg)-- External call failed; item will be retried up tomaxRetries
Usage Pattern
Example 1: Fire-and-Forget (Send Tracking Email)
The spec file. @@reventless.spec injects name, module Id, and
moduleUrl from the filename, and inside a *Slice/ folder auto-applies DCB tags
to *Id fields — never write @s.matches(...) by hand. targetName = None
signals fire-and-forget:
@@reventless.spec
@schema
type consumedEvent =
| OrderShipped({orderId: string, email: string})
@schema
type outboundItem = {orderId: string, email: string}
@schema
type inboundCommand = unit
let maxRetries = 3
let heartbeatInterval = 60
let targetName = None
The translation file (@@reventless.translation) holds collect and the
async translate:
@@reventless.translation
let collect = event =>
switch event {
| OrderShipped({orderId, email}) => [(orderId, {orderId, email})]
}
let translate = async (_id, item) => {
await EmailService.sendTrackingEmail(item.email, ~orderId=item.orderId)
Ok(None) // fire-and-forget: no command back
}
Example 2: Command-Back (Process Payment)
Here targetName = Some("ConfirmPayment") routes the optional command back into
the domain:
@@reventless.spec
@schema
type consumedEvent =
| PaymentRequested({orderId: string, amount: float})
@schema
type outboundItem = {orderId: string, amount: float}
@schema
type inboundCommand = ConfirmPayment({
orderId: string,
transactionId: string,
})
let maxRetries = 5
let heartbeatInterval = 30
let targetName = Some("ConfirmPayment")
@@reventless.translation
let collect = event =>
switch event {
| PaymentRequested({orderId, amount}) => [(orderId, {orderId, amount})]
}
let translate = async (id, item) => {
try {
let result = await PaymentGateway.charge(item.amount, ~orderId=item.orderId)
Ok(Some((id, ConfirmPayment({orderId: item.orderId, transactionId: result.transactionId}))))
} catch {
| exn =>
let msg =
exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("payment failed")
Error(msg)
}
}
Plugin Wiring
You never register or wire OutboundTranslationSlices by hand. The plugin generator
scans the OutboundTranslationSlice/ folder and emits the wiring into the
generated Plugin.res using the two-arg factory
Platform.OutboundTranslationSlice.Make(Spec, Translation):
module Make = (Platform: ReventlessInfra.Platform.T) => {
// OutboundTranslationSlices
module SendTrackingEmailSlice = Platform.OutboundTranslationSlice.Make(SendTrackingEmail, SendTrackingEmail_Translation)
module ProcessPaymentSlice = Platform.OutboundTranslationSlice.Make(ProcessPayment, ProcessPayment_Translation)
let make = (~uiBundleUrl=?) =>
Platform.Plugin.make(
~name="Ordering",
~outboundTranslationSlices=[module(SendTrackingEmailSlice), module(ProcessPaymentSlice)],
// ... other components
)
}
The framework automatically wires the slice to the shared DcbEventLog and CommandTopic.
Runtime Behavior
Two-Phase Processing
The OutboundTranslationSlice callback has two phases that execute on each event batch:
Phase 1 -- Collect (runs for each event in the batch):
for each event:
for each (id, item) in collect(event):
if not exists in TODO list:
insert {id, item, status: Pending}
Phase 2 -- Translate (runs after Phase 1, processes each item independently):
for each item where status = Pending
OR (status = Failed AND retryCount < maxRetries):
mark status = Processing
match await translate(id, item):
Ok(Some(targetId, cmd)) -> publish command, mark Completed
Ok(None) -> mark Completed (fire-and-forget)
Error(msg) -> mark Failed, increment retryCount
TODO Item Lifecycle
Each TODO item moves through these statuses:
| Status | Description |
|---|---|
Pending | Created by collect, waiting to be translated |
Processing | translate is being called |
Completed | External call succeeded |
Failed | External call failed -- eligible for retry |
Heartbeat Handler
A periodic heartbeat (configurable via heartbeatInterval) runs Phase 2 only, catching:
- Failed items eligible for retry (
retryCount < maxRetries) - Items collected in a previous batch but not yet translated
TODO List Storage
The TODO list is stored in a QueryDb for observability. Each row:
type todoStatus = Pending | Processing | Completed | Failed
type todoRow = {
item: JSON.t,
status: todoStatus,
createdAt: string,
processedAt?: string,
completedAt?: string,
retryCount: int,
lastError?: string,
}
This QueryDb is automatically created by the builder and can be queried via the GraphQL API to inspect pending work and translation history.
Error Handling
Phase 1 Errors (collect):
- Event decoding failures are logged and skipped
collectis a pure function -- exceptions are unexpected but caught
Phase 2 Errors (translate):
outboundItemdecoding failures are logged, item skippedtranslateexceptions are caught and treated asError(msg)- Command encoding failures mark item as
Failedwith incrementedretryCount - Publishing failures mark item as
Failedfor retry - Individual item failure does not affect other items
Recovery:
- Failed items are retried up to
maxRetriestimes - Heartbeat sweeps pick up items that need retry
- All errors logged with slice name and context
Pulumi Outputs
type outputs = {
resources: array<Adapter.resource>,
queryDb: QueryDb.outputs,
}
type operations = {
enqueueEvent: EventCollector.enqueueEvent,
translatePending: unit => promise<unit>,
}
Resource Naming:
- Component type:
reventless:OutboundTranslationSlice - TODO list QueryDb:
{name}Todo - EventCollector: subscribed to DcbEventLog's EventTopic
Dependencies:
- DcbEventLog (shared event storage)
- CommandTopic (via
publishJsonsfor optional command-back publishing) - QueryDb (for TODO list persistence)
Related Components
- AutomationSlice -- Similar TODO list pattern for internal command automation (no external calls)
- InboundTranslationSlice -- Complementary component for receiving external input
- SideEffectHandler -- Simpler fire-and-forget pattern for plugins using Aggregates
- DcbEventLog -- Shared event log that OutboundTranslationSlice subscribes to
- StateChangeSlice -- Processes the commands OutboundTranslationSlice optionally produces
- CommandTopic -- Receives optional commands from the translator
- EventCollector -- Subscribes to DcbEventLog events
- QueryDb -- Stores the TODO list for observability
- Plugin -- Hosts OutboundTranslationSlice via DcbSpec