Translation — import members from HR
The Library takes its members from an HR system: each active staff member HR reports should become a Library member. HR delivers staff records, but the Library does not own those events, does not control their schema, and does not want to be coupled to how the HR system names things. A translator reactor listens for the external events, translates them into the Library’s own vocabulary, and fires commands that produce proper domain events, which makes the slice a Translation. Each step shows the C#, Kotlin, Java and TypeScript (source preview) backends, and the series’ backend table lists where they differ.
What is Translation?
Section titled “What is Translation?”In Event Modeling terms, a Translation slice answers the question: “How do we accept information from outside our boundary without letting that outside world shape our inside world?”
The two systems have different needs:
| HR System | Library System |
|---|---|
PersonnelRecord with 40 fields | Member with FirstName, LastName |
Employee ID as string "EMP-00247" | MemberId as a domain concept |
status: "ACTIVE" / "INACTIVE" | MemberRegistered / MemberDeactivated events |
| Events owned by HR | Events owned by the Library |
If Library projections consumed HR payloads directly, they would depend on HR’s schema and status codes. A rename or a change in meaning would spread through the Library code. Keep the integration event at the boundary and translate it once, so the rest of the application depends on Library facts.
Translation keeps these concerns completely separate:
- The Translator observes the external event stream
- It extracts only the data the Library cares about
- It fires a standard Library command —
RegisterMember— using that data - The Library’s own command pipeline validates and records a
MemberRegisteredevent
Library domain projections consume MemberRegistered, not HRMemberCreated. The integration event can still be stored in Chronicle for the translator to observe; storing it does not make it part of the Library’s domain vocabulary.
Folder structure
Section titled “Folder structure”Source/└── Members/ ├── MemberId.cs ← Concept: strongly-typed member identity ├── MemberName.cs ← Concept: strongly-typed member name ├── Registration/ │ └── Registration.cs ← RegisterMember command + MemberRegistered event └── HRIntegration/ └── HRIntegration.cs ← External event type + translator reactor (C# layout)Kotlin and TypeScript use the same folders with .kt or .ts files. Java puts each public type in its own file, so Registration/ and HRIntegration/ hold one file per event, command and reactor.
The integration concern lives in its own slice folder. If the HR integration is ever replaced with a different HR system, you delete or replace HRIntegration/ without touching Registration/.
Step 1 — The member registration slice
Section titled “Step 1 — The member registration slice”First, the domain side: the Library’s own vocabulary. This follows the same State Change pattern as registering an author: a strongly-typed member identity and name, with a concept validator for the nonempty-name rule.
using Cratis.Arc.Validation;using Cratis.Chronicle.Events;using Cratis.Concepts;using FluentValidation;
namespace Library.Members;
// Members/MemberId.cspublic record MemberId(Guid Value) : EventSourceId<Guid>(Value){ public static readonly MemberId NotSet = new(Guid.Empty); public static MemberId New() => new(Guid.NewGuid()); public static implicit operator MemberId(Guid value) => new(value);}
// Members/MemberName.cspublic record MemberName(string Value) : ConceptAs<string>(Value){ public static readonly MemberName NotSet = new(string.Empty); public static implicit operator MemberName(string value) => new(value);}
public class MemberNameValidator : ConceptValidator<MemberName>{ public MemberNameValidator() => RuleFor(name => name.Value).NotEmpty();}import io.cratis.arc.concepts.ConceptAs;import io.cratis.arc.results.ValidationResult;import io.cratis.arc.validation.ConceptValidator;import java.util.List;import java.util.UUID;import org.springframework.stereotype.Component;
// Members/MemberId.javapublic record MemberId(UUID value) implements ConceptAs<UUID>, io.cratis.chronicle.concepts.ConceptAs<UUID> { public static final MemberId NOT_SET = new MemberId(new UUID(0, 0));
public static MemberId newId() { return new MemberId(UUID.randomUUID()); }
@Override public UUID getValue() { return value; }}
// Members/MemberName.javapublic record MemberName(String value) implements ConceptAs<String>, io.cratis.chronicle.concepts.ConceptAs<String> { @Override public String getValue() { return value; }}
// Members/MemberNameValidator.java@Componentpublic final class MemberNameValidator implements ConceptValidator<MemberName> { @Override public Class<MemberName> getConceptType() { return MemberName.class; }
@Override public List<ValidationResult> validate(MemberName concept) { return concept.value().isBlank() ? List.of(ValidationResult.error("A name is required.")) : List.of(); }}import io.cratis.arc.concepts.ConceptAs as ArcConceptAsimport io.cratis.arc.results.ValidationResultimport io.cratis.arc.validation.ConceptValidatorimport io.cratis.chronicle.concepts.ConceptAs as ChronicleConceptAsimport java.util.UUIDimport org.springframework.stereotype.Component
// Members/MemberId.ktdata class MemberId(private val id: UUID) : ArcConceptAs<UUID>, ChronicleConceptAs<UUID> { override fun value(): UUID = id override val value: UUID get() = id
companion object { val NOT_SET = MemberId(UUID(0, 0)) fun new(): MemberId = MemberId(UUID.randomUUID()) }}
// Members/MemberName.ktdata class MemberName(private val name: String) : ArcConceptAs<String>, ChronicleConceptAs<String> { override fun value(): String = name override val value: String get() = name}
@Componentclass MemberNameValidator : ConceptValidator<MemberName> { override val conceptType: Class<MemberName> = MemberName::class.java
override fun validate(concept: MemberName): List<ValidationResult> = if (concept.value().isBlank()) listOf(ValidationResult.error("A name is required.")) else emptyList()}import { ConceptAs, Guid } from '@cratis/fundamentals';import { ConceptValidator, validator } from '@cratis/arc.core';
export class MemberId extends ConceptAs<Guid> { static readonly valueType = Guid;
static create(): MemberId { return new MemberId(Guid.create()); }}
// Members/MemberName.tsexport class MemberName extends ConceptAs<string> { static readonly valueType = String;}
@validator(MemberName)export class MemberNameValidator extends ConceptValidator<MemberName> { constructor() { super(); this.ruleFor(name => name.value).notEmpty(); }}The registration command and its event:
using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;
namespace Library.Members.Registration;
/// <summary>Records a member's registration in the Library.</summary>[EventType]public record MemberRegistered(MemberName FirstName, MemberName LastName);
[Command]public record RegisterMember(MemberName FirstName, MemberName LastName){ public MemberId Provide() => MemberId.New();
public (MemberId, MemberRegistered) Handle(MemberId memberId) => (memberId, new MemberRegistered(FirstName, LastName));}import io.cratis.arc.artifacts.Command;import io.cratis.arc.artifacts.CommandKey;import io.cratis.arc.authorization.AllowAnonymous;import io.cratis.chronicle.events.EventType;import kotlin.Pair;
// Members/Registration/MemberRegistered.java/** Records a member's registration in the Library. */@EventTypepublic record MemberRegistered(MemberName firstName, MemberName lastName) { }
// Members/Registration/RegisterMember.java@Command@AllowAnonymouspublic record RegisterMember(@CommandKey MemberId id, MemberName firstName, MemberName lastName) { public Pair<MemberId, MemberRegistered> handle() { return new Pair<>(id, new MemberRegistered(firstName, lastName)); }}import io.cratis.arc.artifacts.Commandimport io.cratis.arc.artifacts.CommandKeyimport io.cratis.arc.authorization.AllowAnonymousimport io.cratis.chronicle.events.EventType
// Members/Registration/Registration.kt
/** Records a member's registration in the Library. */@EventTypedata class MemberRegistered(val firstName: MemberName, val lastName: MemberName)
@Command@AllowAnonymousdata class RegisterMember( @CommandKey val id: MemberId, val firstName: MemberName, val lastName: MemberName) { fun handle(): Pair<MemberId, MemberRegistered> = id to MemberRegistered(firstName, lastName)}import { field } from '@cratis/fundamentals';import { command, tuple } from '@cratis/arc.core';import { eventSourceIdResponse } from '@cratis/arc.chronicle';import { eventType } from '@cratis/chronicle/events';import { MemberId } from '../MemberId.js';import { MemberName } from '../MemberName.js';
// Members/Registration/Registration.ts
/** Records a member's registration in the Library. */@eventType()export class MemberRegistered { @field(MemberName) firstName: MemberName; @field(MemberName) lastName: MemberName;
constructor(firstName = new MemberName(''), lastName = new MemberName('')) { this.firstName = firstName; this.lastName = lastName; }}
@command()export class RegisterMember { @field(MemberName) firstName: MemberName; @field(MemberName) lastName: MemberName;
constructor(firstName = new MemberName(''), lastName = new MemberName('')) { this.firstName = firstName; this.lastName = lastName; }
provide(): MemberId { return MemberId.create(); }
handle(memberId: MemberId) { return tuple( eventSourceIdResponse(memberId.value.toString()), new MemberRegistered(this.firstName, this.lastName)); }}And the Library’s uniqueness rule for member names:
// Members/Registration/Registration.cs (continued)using Cratis.Chronicle.Events.Constraints;
namespace Library.Members.Registration;
public class UniqueMemberName : IConstraint{ public void Define(IConstraintBuilder builder) => builder .Unique(_ => _ .On<MemberRegistered>(e => e.FirstName, e => e.LastName) .WithMessage("A member with that name is already registered"));}import io.cratis.chronicle.constraints.Constraint;import io.cratis.chronicle.constraints.IConstraint;import io.cratis.chronicle.constraints.IConstraintBuilder;import io.cratis.chronicle.java.UniqueConstraintBuilderJavaBridge;
// Members/Registration/UniqueMemberName.java@Constraintpublic class UniqueMemberName implements IConstraint { @Override public void define(IConstraintBuilder builder) { builder.unique(unique -> { UniqueConstraintBuilderJavaBridge .on(unique, MemberRegistered.class, "firstName", "lastName") .withMessage("A member with that name is already registered"); }); }}import io.cratis.chronicle.constraints.Constraintimport io.cratis.chronicle.constraints.IConstraintimport io.cratis.chronicle.constraints.IConstraintBuilder
// Members/Registration/Registration.kt (continued)@Constraintclass UniqueMemberName : IConstraint { override fun define(builder: IConstraintBuilder) { builder.unique { unique -> unique .on(MemberRegistered::class, MemberRegistered::firstName, MemberRegistered::lastName) .withMessage("A member with that name is already registered") } }}import { constraint, type IConstraint, type IConstraintBuilder } from '@cratis/chronicle/events';
// Members/Registration/Registration.ts (continued)@constraint()export class UniqueMemberName implements IConstraint { define(builder: IConstraintBuilder): void { builder.unique(unique => unique .on(MemberRegistered, event => event.firstName, event => event.lastName) .withMessage('A member with that name is already registered')); }}RegisterMember knows nothing about HR. It can be called from the UI, from an API, or — as here — from a reactor. MemberNameValidator applies the same nonempty-name invariant to each entry point.
The command both appends MemberRegistered under the new member’s identity and returns that identity as its response, in each backend’s own way:
- C#:
MemberIdderives fromEventSourceId<Guid>, so the optional Arc–Chronicle integration uses the returned identity both for the append and for the command response. An ordinaryConceptAs<Guid>response would not select the event source.Provide()creates the ID andHandle()constructs the fact. - TypeScript:
provide()creates the ID, andhandle()returnstuple(eventSourceIdResponse(id), event)to append to that ID and return it. - Kotlin and Java: the caller supplies the ID in the
@CommandKeyproperty; Arc appends the event under the command key and returns theMemberIdhalf of thePair.
Every backend keeps the Library’s exact first-name/last-name uniqueness rule; Kotlin and Java need Chronicle.Kotlin 6.9.0 or later for it. That rule is checked at append time, after the handler. It is a registration policy, not an HR deduplication key: two different employees can share a name.
Step 2 — The translator slice
Section titled “Step 2 — The translator slice”Now the integration side. This is the only place in the codebase that knows anything about the HR system’s shape.
using Cratis.Arc.Commands;using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;using Library.Members.Registration;
namespace Library.Members.HRIntegration;
// ─── External Event ───────────────────────────────────────────────────────────// The inbound adapter records this integration event in Chronicle.// Its string fields mirror the HR payload, not Library domain concepts.
/// <summary>Records the staff-creation payload received from HR.</summary>[EventType]public record HRMemberCreated( string EmployeeId, string GivenName, string FamilyName, string Status);
// ─── Translator Reactor ───────────────────────────────────────────────────────
public class MemberImportReactor(ICommandPipeline commandPipeline) : IReactor{ [OnceOnly] public async Task HandleHRMemberCreated(HRMemberCreated @event) { // Only import active staff as library members if (@event.Status != "ACTIVE") { return; }
var result = await commandPipeline.Execute(new RegisterMember( FirstName: new MemberName(@event.GivenName), LastName: new MemberName(@event.FamilyName))); if (!result.IsSuccess) { throw new MemberImportFailed(); } }}
public class MemberImportFailed() : Exception("Library member registration failed.");import io.cratis.arc.chronicle.ChronicleCommandSideEffectHandler;import io.cratis.arc.results.CommandResult;import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.OnceOnly;import io.cratis.chronicle.observation.Reactor;import java.nio.charset.StandardCharsets;import java.util.UUID;
// ─── External Event ───────────────────────────────────────────────────────────// The inbound adapter records this integration event in Chronicle.// Its string fields mirror the HR payload, not Library domain concepts.
// Members/HRIntegration/HRMemberCreated.java/** Records the staff-creation payload received from HR. */@EventTypepublic record HRMemberCreated(String employeeId, String givenName, String familyName, String status) { }
// ─── Translator Reactor ───────────────────────────────────────────────────────
// Members/HRIntegration/MemberImportReactor.java@Reactorpublic class MemberImportReactor { private final ChronicleCommandSideEffectHandler commands;
public MemberImportReactor(ChronicleCommandSideEffectHandler commands) { this.commands = commands; }
@OnceOnly public void hrMemberCreated(HRMemberCreated event, EventContext context) { // Only import active staff as library members if (!"ACTIVE".equals(event.status())) { return; }
// The same employee always maps to the same member, so a retried import cannot mint a second one. var memberId = new MemberId(UUID.nameUUIDFromBytes( ("hr-employee:" + event.employeeId()).getBytes(StandardCharsets.UTF_8))); var command = new RegisterMember( memberId, new MemberName(event.givenName()), new MemberName(event.familyName())); CommandResult<?> result = commands .executeAsync(command, MemberImportReactor.class, context) .toCompletableFuture() .join(); if (!result.isSuccess()) { throw new MemberImportFailed(); } }}
// Members/HRIntegration/MemberImportFailed.javapublic class MemberImportFailed extends RuntimeException { public MemberImportFailed() { super("Library member registration failed."); }}import io.cratis.arc.chronicle.ChronicleCommandSideEffectHandlerimport io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.OnceOnlyimport io.cratis.chronicle.observation.Reactorimport java.util.UUID
// Members/HRIntegration/HRIntegration.kt
// ─── External Event ───────────────────────────────────────────────────────────// The inbound adapter records this integration event in Chronicle.// Its string fields mirror the HR payload, not Library domain concepts.
/** Records the staff-creation payload received from HR. */@EventTypedata class HRMemberCreated( val employeeId: String, val givenName: String, val familyName: String, val status: String)
// ─── Translator Reactor ───────────────────────────────────────────────────────
@Reactorclass MemberImportReactor(private val commands: ChronicleCommandSideEffectHandler) { @OnceOnly suspend fun hrMemberCreated(event: HRMemberCreated, context: EventContext) { // Only import active staff as library members if (event.status != "ACTIVE") { return }
// The same employee always maps to the same member, so a retried import cannot mint a second one. val memberId = MemberId(UUID.nameUUIDFromBytes("hr-employee:${event.employeeId}".toByteArray())) val result = commands.execute( RegisterMember(memberId, MemberName(event.givenName), MemberName(event.familyName)), MemberImportReactor::class.java, context ) if (!result.isSuccess) { throw MemberImportFailed() } }}
class MemberImportFailed : Exception("Library member registration failed.")import { field } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle/events';import { onceOnly, reactor } from '@cratis/chronicle/reactors';import { MemberName } from '../MemberName.js';import { RegisterMember } from '../Registration/Registration.js';
// Members/HRIntegration/HRIntegration.ts
// ─── External Event ───────────────────────────────────────────────────────────// The inbound adapter records this integration event in Chronicle.// Its string fields mirror the HR payload, not Library domain concepts.
/** Records the staff-creation payload received from HR. */@eventType()export class HRMemberCreated { @field(String) employeeId: string; @field(String) givenName: string; @field(String) familyName: string; @field(String) status: string;
constructor(employeeId = '', givenName = '', familyName = '', status = '') { this.employeeId = employeeId; this.givenName = givenName; this.familyName = familyName; this.status = status; }}
// ─── Translator Reactor ───────────────────────────────────────────────────────
@reactor()export class MemberImportReactor { // Chronicle calls the method named after the event class with its first letter lowercased. @onceOnly() hRMemberCreated(event: HRMemberCreated): RegisterMember | undefined { // Only import active staff as library members if (event.status !== 'ACTIVE') { return undefined; }
return new RegisterMember(new MemberName(event.givenName), new MemberName(event.familyName)); }}What is happening here?
Section titled “What is happening here?”The integration event HRMemberCreated is recorded by your inbound adapter. Declaring it does not connect Chronicle to HR by itself. The reactor runs after that adapter has received, validated, and appended the payload to the configured event log.
The once-only marker skips the handler during replay. Rebuilding Library views should consume existing MemberRegistered facts, not issue fresh registrations from historical HR events. It does not prevent retries after failure or deduplicate separate incoming messages.
Status filtering shows how the translator makes decisions. This import accepts only records whose status is "ACTIVE"; it makes no further distinction between employment categories. That filter lives here, at the integration boundary. The RegisterMember command never needs to know that the Library has an HR integration; it registers members.
The handler is found and runs its command the way the Automation reactor does. In TypeScript that makes the handler’s name hRMemberCreated: the event class name with its first letter lowercased.
Arc’s command pipeline runs the Library command’s validation and Chronicle append, including the name constraint. An unsuccessful result must fail the handler, so Chronicle records an observer failure instead of acknowledging an import that did not happen. The C#, Kotlin and Java reactors throw the named MemberImportFailed exception; in TypeScript, reactorCommandResultHandler fails the handler with a message naming the rejected command. Because the Kotlin and Java command key is the member identity, their reactors derive it from the employee ID, as explained below.
As in Automation, these background calls do not inherit a user’s HTTP principal; configure an execution context if registration requires authorization. The Kotlin and Java RegisterMember is @AllowAnonymous, matching the C# command, which has no authorization rules.
A repeated name causes a visible rejection, not a successful deduplication. In C# and TypeScript, RegisterMember creates a new MemberId on each attempt. If registration succeeds but the reactor fails before acknowledging the HR event, the retry arrives under a new identity and the name constraint rejects it.
In Kotlin and Java the member identity is the command key, so the reactor supplies it, and one that created a new MemberId on each attempt would meet the same rejection on a retry. Their reactors instead derive the member ID from the employee ID with a name-based UUID (UUID.nameUUIDFromBytes). A retry then appends another MemberRegistered to the same member, which the constraint accepts because the same event source may append its current name again. Two employees who share a name still get different member IDs, so the constraint rejects the second.
A production import needs a durable mapping from HR’s employee identity to MemberId, and a policy for repeated messages and name conflicts. EmployeeId is available at this boundary for that work; do not parse values such as "EMP-00247" as GUIDs or substitute name equality for employee identity.
Step 3 — Comparing to Automation
Section titled “Step 3 — Comparing to Automation”Automation and Translation look similar at a glance: both use a reactor and both send commands through Arc’s command pipeline. The difference is in who owns the events:
| Aspect | Automation | Translation |
|---|---|---|
| What triggers it | A clock signal (DailyTick) checking Library reservations | An imported HR payload (HRMemberCreated) |
| Who owns the trigger event | The Library’s scheduler contract | The adapter mirrors HR’s contract |
| What the reactor knows | Library read models | External payload structure |
| Output | Library domain events | Library domain events |
Automation carries out Library work that is due. Translation reacts to information from another system and maps it into the Library’s language. Both examples use Arc’s optional Chronicle integration; neither changes how standalone Arc commands work.
Step 4 — Wiring the external event stream
Section titled “Step 4 — Wiring the external event stream”The transport adapter and translator have separate jobs. The adapter receives HR messages; Chronicle delivers the recorded integration event to MemberImportReactor.
Wire the boundary in this order:
- Configure Arc’s Chronicle integration (C#, Kotlin and Java, TypeScript) and include the integration event and reactor in application discovery.
- Implement the inbound adapter for HR’s actual transport — for example, an authenticated webhook or a broker consumer. Validate the sender and payload before accepting it.
- Use the configured Chronicle event log to append
HRMemberCreatedunder a stable integration-event source identity. Check the append result before acknowledging the upstream message. - Keep transport message deduplication and the employee-to-member mapping durable. Test redelivery and a failure after registration succeeds, not only the first successful message.
A Chronicle namespace isolates event-store data; it is not a Kafka or Service Bus connection. Because the concrete adapter depends on HR’s delivery contract, it belongs in the host integration and stays out of RegisterMember.
To check the translator itself, write a direct handler spec: inactive staff should issue no command; active staff should pass GivenName and FamilyName as MemberName values; an unsuccessful command should fail the handler. In C#, substitute ICommandPipeline. In TypeScript, call hRMemberCreated directly and assert the command it returns. This series shows no Kotlin or Java handler spec. To write one, construct the ChronicleCommandSideEffectHandler the reactor takes from a stub CommandPipeline, a ConcurrentCommandHandlerRegistry with a handler registered for RegisterMember, a ServiceResolver and a CoroutineScope, then assert the commands the stub receives. Arc.Kotlin’s ChronicleCommandSideEffectHandlerTests shows the setup; CommandPipeline has only suspend methods, so from Java, write the stub pipeline in Kotlin. Without one, the hosted test is what covers the Kotlin and Java translator. For every backend, run a hosted test through the real adapter and observer to verify ingestion, discovery, authorization, and recovery together.
What you have built
Section titled “What you have built”Over the four tutorials in this series you have built:
| Tutorial | Pattern | What you built |
|---|---|---|
| Register an author | State Change | Command, event, validation, constraint, React form |
| List authors | State View | Read model, author query, data page |
| Cancel expired reservations | Automation | Active to-do list, passive decision model, command, reactor |
| Import members from HR | Translation | External event mirror, domain command, translator reactor |
Each slice keeps one behavior in one folder: user intent, a query’s needs, background work or an external vocabulary. Chronicle holds the event log, Arc the commands and queries, and Components the UI.