Skip to content

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.

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 SystemLibrary System
PersonnelRecord with 40 fieldsMember with FirstName, LastName
Employee ID as string "EMP-00247"MemberId as a domain concept
status: "ACTIVE" / "INACTIVE"MemberRegistered / MemberDeactivated events
Events owned by HREvents 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:

  1. The Translator observes the external event stream
  2. It extracts only the data the Library cares about
  3. It fires a standard Library command — RegisterMember — using that data
  4. The Library’s own command pipeline validates and records a MemberRegistered event

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.

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/.

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.cs
public 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.cs
public 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();
}

View C# snippet source on GitHub

The registration command and its event:

Members/Registration/Registration.cs
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));
}

View C# snippet source on GitHub

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"));
}

View C# snippet source on GitHub

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#: MemberId derives from EventSourceId<Guid>, so the optional Arc–Chronicle integration uses the returned identity both for the append and for the command response. An ordinary ConceptAs<Guid> response would not select the event source. Provide() creates the ID and Handle() constructs the fact.
  • TypeScript: provide() creates the ID, and handle() returns tuple(eventSourceIdResponse(id), event) to append to that ID and return it.
  • Kotlin and Java: the caller supplies the ID in the @CommandKey property; Arc appends the event under the command key and returns the MemberId half of the Pair.

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.

Now the integration side. This is the only place in the codebase that knows anything about the HR system’s shape.

Members/HRIntegration/HRIntegration.cs
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.");

View C# snippet source on GitHub

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.

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:

AspectAutomationTranslation
What triggers itA clock signal (DailyTick) checking Library reservationsAn imported HR payload (HRMemberCreated)
Who owns the trigger eventThe Library’s scheduler contractThe adapter mirrors HR’s contract
What the reactor knowsLibrary read modelsExternal payload structure
OutputLibrary domain eventsLibrary 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:

  1. Configure Arc’s Chronicle integration (C#, Kotlin and Java, TypeScript) and include the integration event and reactor in application discovery.
  2. 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.
  3. Use the configured Chronicle event log to append HRMemberCreated under a stable integration-event source identity. Check the append result before acknowledging the upstream message.
  4. 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.

Over the four tutorials in this series you have built:

TutorialPatternWhat you built
Register an authorState ChangeCommand, event, validation, constraint, React form
List authorsState ViewRead model, author query, data page
Cancel expired reservationsAutomationActive to-do list, passive decision model, command, reactor
Import members from HRTranslationExternal 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.