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

OutboundTranslationSlice

For a short summary of OutboundTranslationSlice, see Reventless Components Overview.

Framework Implementation

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

d2 diagram

The OutboundTranslationSlice implements the Event Modeling Translation pattern for outbound external communication. It listens to events from its declared sources, 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.

Those sources default to the plugin's own DcbEventLog — the diagram above — but need not be. Spec.sourceNames also names Aggregates, so an Aggregate's events can trigger an outbound call; see Event sources.

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 translate function; optionally publish commands back into the domain; track status with per-item retry semantics
  • In: Events from the sources named in Spec.sourceNames — the plugin's own DcbEventLog by default, Aggregates and other DCB logs when declared (subscribed via EventCollector)
  • Out: External API calls (via translate); optional commands to CommandTopic (via publishJsons); TODO state synced to QueryDb

Comparison with SideEffectHandler

AspectSideEffectHandlerOutboundTranslationSlice
ArchitectureAggregate-based pluginsEither — DCB by default, Aggregates via sourceNames
State trackingNone -- fire-and-forgetTODO list with status (Pending/Processing/Completed/Failed/Abandoned)
RetryRelies on EventCollector retry (entire batch)Per-item retry with configurable max
IdempotencyNone -- replays cause duplicate callsDeduplication key prevents double-processing
VisibilityNo queryable stateQueryDb stores full processing history
Command emissionNeverOptional -- can publish commands back

Choose SideEffectHandler for simple fire-and-forget event reactions where a failed call needs no record.

Choose OutboundTranslationSlice when you need tracked, retryable external calls with full observability. Aggregate-based plugins are no longer a reason to prefer the other one: name the Aggregate in sourceNames and this slice consumes its events like any other source.

Component Spec

An OutboundTranslationSlice is split into two files:

  • <Name>.res — the spec (@@reventless.spec): the consumedEvent, outboundItem, and inboundCommand @schema types, the sweep config (maxRetries, heartbeatInterval), and targetName (None for fire-and-forget, or Some("<TargetSlice>") to publish a command back).
  • <Name>_Translation.res — the translation (@@reventless.translation): the collect function (event → outbound items) and the async translate function (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>
let sourceNames: array<string>
let externalSystem: option<string>
}

collect and translate live on the Translation module. There is no DcbEventLogSpec reference; the slice declares a local consumedEvent union and names its sources in sourceNames.

Spec Fields Explained

FieldTypeDescription
consumedEvent@schema typeThe local subset of event variants this slice reacts to
outboundItem@schema typeData accumulated for each pending external call
inboundCommand@schema typeCommand type optionally published back after translate. Use unit for fire-and-forget
maxRetriesintMaximum retry attempts for failed items
heartbeatIntervalintSeconds between heartbeat sweeps for pending/failed items
targetNameoption<string>None for fire-and-forget; Some("<TargetSlice>") to route the optional command back
sourceNamesarray<string>Event sources to subscribe to. [] (the default) means this plugin's own DcbEventLog — see Event sources
externalSystemoption<string>Display name of the foreign system, drawn as an external box in the Event Graph. Auto-injected as None

In the _Translation.res file:

FunctionTypeDescription
collect(consumedEvent, ~sourceId: string) => array<(string, outboundItem)>Map an event to zero or more outbound items (id + payload). ~sourceId is the entity the event was published for
translate(string, outboundItem) => promise<result<...>>Call external service; returns success with optional command, or error

Event sources

sourceNames names the topics this slice's EventCollector subscribes to:

ValueSubscribes to
[]This plugin's own DcbEventLog. The default, and what every slice written before sources existed keeps doing
["Customer"]The Customer Aggregate's EventTopic — an Aggregate's Spec.name
["OrderingDcbEventLog"]A DCB log by name, conventionally "<pluginName>DcbEventLog"

Each name is validated against the plugin-wide topic dict at deploy time; a name that matches nothing fails the build with a message listing the keys that are available.

Sources are a flat list, not the per-source Mapping modules an AutomationSlice uses. An automation needs a resolve per source — a different event completes the item depending on where it came from — whereas an outbound item is resolved by its own translate succeeding. The only thing that varies per source is the decode, and the single consumedEvent union already covers that.

The cost of the flat form: two sources that publish an event type of the same name are indistinguishable to collect. Declare only the sources whose events you actually mean.

Why collect takes ~sourceId

collect receives the envelope's id alongside the decoded event, because the event alone may not say what it is about:

let collect = (event, ~sourceId) =>
switch event {
| Registered({address}) => [(`${sourceId}:${address}`, {customerId: sourceId, address})]
}

A DCB event usually names its own subject in the payload — OrderPlaced({orderId, …}) — and can ignore the argument. An Aggregate's event generally does not: the aggregate id is what addressed the event, so it is on the envelope rather than in the payload. Without ~sourceId an outbound item built from Registered({email, address}) would have no way to say which customer it is for.

Note the deduplication key above is {id}:{address}, not {id}. Keying by the entity alone would make a later address change look like work already done, and the corrected address would never be translated.

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 to maxRetries

onExhausted: what to say when the retries run out

Error(msg) on the last permitted attempt ends the item: no later sweep will pick it up. onExhausted decides whether the domain hears about that.

let onExhausted = (_id, item, ~lastError) =>
Some((item.customerId, MarkAddressUnresolvable({
address: item.address,
reason: "the geocoder never answered after repeated attempts",
})))

Return None to stay silent. It is declared either way, because abandonment is an outcome and the framework cannot publish it for you: the command has to name a target, and which target is exactly what the slice knows and the framework does not.

Whether you answer or not, the row is marked Abandoned — the hook decides only whether anything downstream reacts. Answer it when leaving the item alone would strand a domain state: a customer stuck on "locating…" forever is waiting for an answer nobody is still fetching. Stay silent when the abandoned row is itself the record, as it is for an email that could not be sent.

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:

Order/OutboundTranslationSlice/SendTrackingEmail.res
@@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
// This plugin's own DCB event log — `OrderShipped` is a DCB event. The
// annotation is needed because a bare `[]` has no element type to infer.
let sourceNames: array<string> = []
let externalSystem = Some("EmailService")

The translation file (@@reventless.translation) holds collect and the async translate. OrderShipped carries its own orderId, so this collect ignores ~sourceId:

Order/OutboundTranslationSlice/SendTrackingEmail_Translation.res
@@reventless.translation

let collect = (event, ~sourceId as _) =>
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:

Payment/OutboundTranslationSlice/ProcessPayment.res
@@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")
let sourceNames: array<string> = []
let externalSystem = Some("PaymentGateway")
Payment/OutboundTranslationSlice/ProcessPayment_Translation.res
@@reventless.translation

let collect = (event, ~sourceId as _) =>
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):

src/Plugin.res (generated — do not edit)
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 = () =>
Platform.Plugin.make(
~name="Ordering",
~outboundTranslationSlices=[module(SendTrackingEmailSlice), module(ProcessPaymentSlice)],
// ... other components
)
}

The framework automatically wires the slice to the sources it declared and to the CommandTopic.

Example 3: An Aggregate as the source

sourceNames names the Aggregate, and ~sourceId supplies the customer id that the event payload does not carry:

Customer/OutboundTranslationSlice/GeocodeCustomerAddress.res
@@reventless.spec

@schema
type consumedEvent =
| Registered({email: string, address: string})
| AddressUpdated({address: string})

@schema
type outboundItem = {customerId: string, address: string}

@schema
type inboundCommand =
| SetLocation({location: Reventless.GeoPoint.t, resolvedFrom: string})
| MarkAddressUnresolvable({address: string, reason: string})

// Retries are for a geocoder that is *down*, not for one that has answered. An
// address the service has no match for is settled by publishing a command, not
// by asking three more times.
let maxRetries = 3
let heartbeatInterval = 60
let targetName = Some("Customer")

// The Customer Aggregate, by its `Spec.name`.
let sourceNames = ["Customer"]
let externalSystem = Some("AwsLocation")
Customer/OutboundTranslationSlice/GeocodeCustomerAddress_Translation.res
@@reventless.translation

let collect = (event, ~sourceId) =>
switch event {
| Registered({address}) => [(`${sourceId}:${address}`, {customerId: sourceId, address})]
| AddressUpdated({address}) => [(`${sourceId}:${address}`, {customerId: sourceId, address})]
}

Note that the slice publishes back into the same Aggregate it reads from. That is not a cycle: SetLocation produces an event this slice does not consume, so the loop terminates by construction rather than by a guard. Choosing which events a slice consumes is how you keep it that way.

Runtime Behavior

Two-Phase Processing

The OutboundTranslationSlice callback has two phases that execute on each event batch:

d2 diagram

Phase 1 -- Collect (runs for each event in the batch):

for each event:
for each (id, item) in collect(event, ~sourceId=envelope.id):
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

d2 diagram

Each TODO item moves through these statuses:

StatusDescription
PendingCreated by collect, waiting to be translated
Processingtranslate is being called
CompletedExternal call succeeded
FailedExternal call failed -- eligible for retry
AbandonedExternal call failed for the last time -- onExhausted was consulted, and no sweep will pick this row up again

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 | Abandoned

type todoRow = {
item: JSON.t,
status: todoStatus,
createdAt: string,
processedAt?: string,
completedAt?: string,
retryCount: int,
maxRetries?: int,
lastError?: string,
}

Failed and Abandoned are opposite instructions to whoever is reading. A Failed row will be tried again on the next sweep; an Abandoned one has spent its retry budget and no sweep will pick it up — lastError then holds the reason it stopped. The row carries maxRetries alongside retryCount so a caller can read "2 of 3" without knowing a constant that lives in the Spec. It is optional only because rows written before the field existed are rehydrated by decoding them.

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
  • collect is a pure function -- exceptions are unexpected but caught

Phase 2 Errors (translate):

  • outboundItem decoding failures are logged, item skipped
  • translate exceptions are caught and treated as Error(msg)
  • Command encoding failures mark item as Failed with incremented retryCount
  • Publishing failures mark item as Failed for retry
  • Individual item failure does not affect other items

Recovery:

  • Failed items are retried up to maxRetries times
  • 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 publishJsons for optional command-back publishing)
  • QueryDb (for TODO list persistence)
  • 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 the topics named in sourceNames
  • QueryDb -- Stores the TODO list for observability
  • Plugin -- Hosts OutboundTranslationSlice via DcbSpec