The code snippets can be hard to read on LinkedIn. The full, runnable code is in the translation folder of em-building-blocks.
A few years ago I worked on a system that skipped this step:
"In a mission critical highly distributed application we did not use this translation pattern and thus external events did leak into our internal implementations. We were subsequently dependent on any change of those external events. We also were "forced" to use the other system's naming conventions although they did in many cases not make sense inside our own component. What was supposed to be a loosely coupled highly distributed platform became in reality a distributed monolith. One change in one corner of the platform had ripple effects throughout the whole platform."
Nobody decided to build a distributed monolith. It happened one shortcut at a time. Every outside system speaks its own language, and it changes that language without asking you. If its words flow straight into your code, every change on their side becomes a change on yours.
This is the fourth and last building block of an Event Model. One slice, end to end showed a state change. Read models are cheap. Coupling isn't. showed state views. The work nobody clicks and The work nobody sees: automation slices in code covered automation. Translation is the one place where you decide what their words mean in your business.
Translation is for the outside world
Inside your own model, slices share one language. The events are yours, the names are yours, and you change them on your schedule. Translation is for the moment something arrives from a system you don't own.
Picture a life and health insurer. These are the kinds of outside systems it listens to:
- Lab results. A medical lab sends test results for an applicant, with its own codes and units.
- Sanctions and PEP screening. A screening provider reports a possible match against a list.
- Death registry. A public registry reports that a person has died.
- Direct-debit returns. The bank reports that a premium debit was returned, with its own reason codes.
- Broker portals. A broker submits an application in the portal's format, not yours.
- Regulator notices. A supervisor publishes a notice or a changed reporting requirement.
- The legacy admin system during a migration. The old platform keeps emitting changes to policies that haven't moved yet.
Each one has its own vocabulary. None of them will ask you before they change it.
The shape: translate first, decide later
A translation slice comes in two steps, and the split is the whole point.
external event
-> translation todo list (never closed)
-> translation automation + command (pure mapping)
-> internal event
-> worker todo list (closed by the outcome)
-> worker automation + command (all rules)
-> business event
- The outside fact is stored as it arrived. It lands on a translation todo list. In this code, the list is never closed: there's no done marker and no item is removed. That's a convention, not the only option (see the tip below).
- A translation command maps it. Codes, units and ids only. It records an internal "...Observed" event in your terms. It holds no business rules. The only check it makes is whether it has already seen this source event.
- A worker picks up the observed fact. It has its own todo list, and it runs the command that holds every business rule.
- The worker records or rejects. The outcome is a "...Recorded" event (or the case's business event) or a "...Rejected" event with a
rejectionReason. Either way, the item is deleted from the worker's todo list.
For the lab result, that reads: LabResult → TranslateLabResult → UnderwritingEvidenceObserved → RecordUnderwritingEvidence → UnderwritingEvidenceRecorded or UnderwritingEvidenceRejected.
Tip: three ways to run the translation todo list
- A list that never closes. This is the code's convention. It's the simplest option and gives you a full record of everything that arrived, but it keeps growing. The automation may see a row again, and the command's source-id check makes that harmless.
- A plain view of the external events, with no todo list. It's lighter and still works. The same source-id check stops a message from being translated twice.
- A list that closes. You gain little, because translation is pure mapping and there's nothing to finish.
This article uses the first option because it's the simplest. Redelivery protection comes from the source-id check in the command, whichever list you choose.

The lab result. The lab's LabResult lands on the translation todo list, and TranslateLabResult maps it to UnderwritingEvidenceObserved. The worker picks it up from its own todo list and runs RecordUnderwritingEvidence, which records UnderwritingEvidenceRecorded or UnderwritingEvidenceRejected.
Step one: pure mapping
The lab sends its own test codes and units. The mapping turns them into your markers and units. An unknown code returns null, records nothing and stays on the todo list.
// Pure mapping. Codes and units only. No business rules.
// Unknown code: skip (return null). The item stays on the translation todo list.
export function translate(payload) {
const mapped = MAP[`${payload.testCode}:${payload.unit}`];
if (!mapped) return null;
return {
applicationId: payload.clientOrderId,
marker: mapped.marker,
value: Number((payload.value * mapped.factor).toFixed(3)),
unit: mapped.unit,
observedAt: payload.collectedAt,
sourceEventId: payload.id,
};
}
The translation command wraps that mapping. It checks for redelivery, because outside systems send the same message twice more often than anyone would like, and then records UnderwritingEvidenceObserved. It doesn't ask whether the application exists or is still in underwriting. That isn't its job.
// Command: TranslateLabResult -> UnderwritingEvidenceObserved.
// Mapping only. Dedupe on the source event id. sourceEventId is the deterministic id.
export async function recordTranslation(store, payload) {
const mapped = translate(payload);
if (!mapped) return null;
const seenTag = sourceTag(payload.id);
const query = queryItem({ tags: [seenTag] });
const { events, position } = await store.load(query);
if (events.some((e) => e.type === 'UnderwritingEvidenceObserved')) return null; // redelivery
const [event] = await store.append(
{
type: 'UnderwritingEvidenceObserved',
data: mapped,
tags: [`application:${mapped.applicationId}`, seenTag],
},
{ failIfEventsMatch: query, after: position },
);
return event;
}
The external payload stays at the door. Downstream slices never see the lab's field names, its codes or its format.
Step two: the worker holds the rules
The worker reads UnderwritingEvidenceObserved from its own todo list and issues RecordUnderwritingEvidence. This is where the business rules live: no application means ApplicationNotFound, a closed application means ApplicationNotInUnderwriting. Otherwise the evidence is recorded.
// Command: RecordUnderwritingEvidence -> UnderwritingEvidenceRecorded or UnderwritingEvidenceRejected.
// Rules live here.
export async function issue(store, command) {
const seenTag = sourceTag(command.sourceEventId);
const query = combineQueries(
decisionQuery(command.applicationId),
queryItem({ types: ['UnderwritingEvidenceRecorded', 'UnderwritingEvidenceRejected'], tags: [seenTag] }),
);
const { events, position } = await store.load(query);
if (events.some((e) => e.tags.includes(seenTag))) return null;
const state = decisionModel(events);
const tags = [`application:${command.applicationId}`, seenTag];
let record;
if (!state.exists) {
record = { type: 'UnderwritingEvidenceRejected', data: { ...command, rejectionReason: 'ApplicationNotFound' }, tags };
} else if (!state.open) {
record = { type: 'UnderwritingEvidenceRejected', data: { ...command, rejectionReason: 'ApplicationNotInUnderwriting' }, tags };
} else {
record = { type: 'UnderwritingEvidenceRecorded', data: command, tags };
}
const [event] = await store.append(record, { failIfEventsMatch: query, after: position });
return event;
}
A rejection is an outcome too. It's recorded with its reason, and the item still leaves the worker's list, so nothing sits there forever.
The tests read like the slices on the model. Given a lab result, translating it records the observed evidence:
test('Given a LabResult, when TranslateLabResult, then UnderwritingEvidenceObserved', async () => {
await given(labResult);
await project();
await processTranslations({ pool, store });
await thenEvents(['UnderwritingEvidenceObserved'], 'application:A1', [observed]);
});
Given an application in underwriting and the observed evidence, the worker records it and its todo item closes:
test('Given an application in underwriting, when RecordUnderwritingEvidence, then UnderwritingEvidenceRecorded and the worker todo closes', async () => {
await given(ApplicationReceived('A1'), observed);
await project();
assert.equal((await listToApply(pool)).length, 1);
await processWork({ pool, store });
await thenEvents(['UnderwritingEvidenceRecorded'], 'application:A1', [recorded]);
await project();
assert.deepEqual((await pool.query('SELECT * FROM underwriting_evidence_to_record')).rows, []);
});
Copy, paste, change the mapping
Because translation is now pure mapping, every case has the same shape. The returned direct debit and the death registry notice live in their own folders, DirectDebitReturn and DeathRegistryNotice, with the same functions in the same order: store the external event, list what to translate, translate, the translation command, list the work, the worker command. Compare any two folders and what differs is the mapping and the worker's rules. The plumbing doesn't.
The direct debit runs DirectDebitReturn → TranslateDirectDebitReturn → CollectionReturnObserved → RecordCollectionAttemptFailed → PremiumCollectionAttemptFailed or CollectionReturnRejected. When the bank renames a return code, one mapping entry changes. Nothing else does.

The direct-debit return. The bank's return goes onto the translation todo list, and TranslateDirectDebitReturn maps its return code to CollectionReturnObserved. The worker runs RecordCollectionAttemptFailed, which records PremiumCollectionAttemptFailed or CollectionReturnRejected.
Why all integration goes through it
It's tempting to make an exception. One small webhook, one field read straight from the partner's payload, just this once. That's exactly how the story above began.
If every outside system enters through a translation slice, three things hold. You know where each external dependency lives, because it's a slice on the model. A change on their side has one place to land. And your own events keep names that make sense in your business, not in theirs.
Picture an insurer whose sanctions screening provider adds a new match category. With translation, someone decides what that category means in the insurer's terms and updates one slice. Without it, the new value surfaces somewhere downstream, in a screen or a rule that never expected it.
It translates, it never decides
A translation slice turns their facts into your terms. It doesn't decide anything.
The death registry says a person died on a given date. The translation turns that notice into DeathNoticeObserved, in the insurer's language, and stops there. The decision belongs to the worker. Its command, OpenDeathClaim, checks that a policy exists, is still in force and has no claim open yet. If so, it records DeathClaimOpened. If not, it records DeathClaimRejected with PolicyNotFound or PolicyNotInForce.

The death registry notice. The registry's notice goes onto the translation todo list, and TranslateDeathRegistryNotice records DeathNoticeObserved. The worker runs OpenDeathClaim, which decides: DeathClaimOpened or DeathClaimRejected.
Keeping decisions out of translation keeps it boring, and boring is the point. If business rules creep into the mapping, they hide in the one place nobody looks when the rules change.
The same holds during a migration. Picture an insurer moving policies off a legacy admin system. The old platform keeps sending changes for policies that haven't moved yet. A translation slice turns those changes into the new model's events, so the new slices don't have to learn the old system's language. When the last policy has moved, the slice is deleted.
Translation and the anti-corruption layer
If this sounds like an anti-corruption layer, it's related. Eric Evans described the anti-corruption layer as a boundary that keeps another system's model from leaking into yours. Evans sketches it from facades, adapters and translators, but he leaves the shape open. Some teams build it as a mapping class, some as a gateway, some as a whole adapter service. Teams tend to build their own bespoke version, and each one has to be learned on its own terms.
Translation is deliberately simpler. It's one small, inbound-only slice with the same shape every time: an external event comes in, the slice maps its fields, and it becomes one of your events. There's no second-guessing with translation. Once you've built one, you copy it to the next case and change the mapping. The lab result, the returned direct debit and the death registry notice all look alike in code.
That visibility matters. On the model, anyone can point at the translation slices and see every outside dependency, what it sends and which of your events it becomes. A business expert can read it. So can an auditor.
Four building blocks, one model
State change records a decision. State view shows facts to a screen. Automation does the work nobody clicks. Translation lets the outside world in on your terms. Almost every business process can be built from these four.
The outside world will keep changing its language. Translation is where you decide what it means for you, once, in one place.
Originally published on LinkedIn on 4 Nov 2026: https://www.linkedin.com/pulse/words-our-facts-translation-slices-code-gabriel-n-schenker-8ga0e/