Skip to content
English

From Microsoft Orleans

Microsoft Orleans is a .NET grain-based actor framework. Grains are virtual — addressed by ID, auto-spawned on first message, auto-deactivated when idle. Sharded entities in actor-ts are the closest analog.

Orleans: actor-ts:
IGreeter grain interface → TypeScript message types (Command union)
GreeterGrain : Grain → Actor extending PersistentActor (or plain Actor)
IGrainKey → string entity ID
client.GetGrain<IGreeter>(id)→ reference + tell via sharding region
// Orleans (C#):
public interface IUserGrain : IGrainWithStringKey {
Task<UserProfile> GetProfile();
Task UpdateName(string name);
}
public class UserGrain : Grain, IUserGrain {
private UserProfile _profile = new();
public Task<UserProfile> GetProfile() => Task.FromResult(_profile);
public Task UpdateName(string name) {
_profile = _profile with { Name = name };
return Task.CompletedTask;
}
}
// actor-ts:
import { match } from 'ts-pattern';
import { Actor, type ActorRef } from 'actor-ts';
type Command =
| { entityId: string; kind: 'get-profile'; replyTo: ActorRef<UserProfile> }
| { entityId: string; kind: 'update-name'; name: string };
class UserActor extends Actor<Command> {
private profile: UserProfile = { name: '', email: '' };
override onReceive(command: Command): void {
match(command)
.with({ kind: 'get-profile' }, (c) => this.onGetProfile(c))
.with({ kind: 'update-name' }, (c) => this.onUpdateName(c))
.exhaustive();
}
private onGetProfile(command: GetProfileCommand): void {
command.replyTo.tell(this.profile);
}
private onUpdateName(command: UpdateNameCommand): void {
this.profile = { ...this.profile, name: command.name };
}
}

Differences:

  • Method calls become message kinds + handlers.
  • Task<T> returns become explicit replyTo refs.
  • IGrainWithStringKey is implicit — extractEntityId(command) pulls the ID at the sharding layer.
// Orleans:
var grain = grainFactory.GetGrain<IUserGrain>("user-42");
var profile = await grain.GetProfile();
await grain.UpdateName("Alice");
// actor-ts:
import { ClusterSharding } from 'actor-ts';
const region = cluster.sharding.start<Command>({
typeName: 'user',
entityActor: UserActor,
extractEntityId: (command) => command.entityId,
});
// Get profile via ask:
const profile = await region.ask({
entityId: 'user-42',
kind: 'get-profile',
replyTo: undefined as any,
}, 5_000);
// Update via tell:
region.tell({ entityId: 'user-42', kind: 'update-name', name: 'Alice' });

The region is a single ActorRef — messages routed by extractEntityId. Each unique ID has one actor, spawned on its first message. Unlike an Orleans grain it does not deactivate on its own: there is no default idle window, so an entity stays resident until something passivates it (see below).

// Orleans (lifecycle methods):
public override Task OnActivateAsync(CancellationToken token) {
// Load state from persistence
return base.OnActivateAsync(token);
}
public override Task OnDeactivateAsync(DeactivationReason reason, CancellationToken token) {
// Cleanup
return base.OnDeactivateAsync(reason, token);
}
// actor-ts:
class UserActor extends Actor<Command> {
override preStart(): void {
// Equivalent of OnActivateAsync
}
override postStop(): void {
// Equivalent of OnDeactivateAsync
}
}

The framework’s preStart + postStop map directly.

For automatic passivation on idle:

sharding.start({
// ...
passivationIdleMs: 30_000, // passivate after 30s idle — like Orleans's default
});
// Orleans Event Sourcing:
[LogConsistencyProvider(ProviderName = "EventStore")]
public class AccountGrain : JournaledGrain<AccountState, AccountEvent>, IAccount {
public Task Deposit(decimal amount) {
RaiseEvent(new Deposited(amount));
return ConfirmEvents();
}
}
// actor-ts:
class Account extends PersistentActor<Command, Event, State> {
readonly persistenceId = `account-${this.entityId}`;
initialState(): State { return { balance: 0 }; }
onEvent(state: State, event: Event): State {
return match(event)
.with({ kind: 'deposited' }, (e) => ({ balance: state.balance + e.amount }))
.exhaustive();
}
onCommand(state: State, command: Command): void {
match(command)
.with({ kind: 'deposit' }, (c) => this.onDeposit(c))
.exhaustive();
}
private onDeposit(command: DepositCommand): void {
this.persist({ kind: 'deposited', amount: command.amount }, () => {});
}
}

Very similar pattern. Orleans uses RaiseEvent + ConfirmEvents; actor-ts uses persist with callback.

Orleansactor-ts
Virtual actors — always-existing, spawned on demand.Sharded entities are similar; need to be started as a sharded type.
Directory-based placement — silos look up grain locations.Hash-mod-region placement by default; coordinator manages.
Streams — push-based data flow.DistributedPubSub for similar fan-out semantics.
Reminders — durable timers.context.timers + persistence — manually combine.
Transactions across grains — built-in.NOT built-in — use sagas or two-phase commit patterns.
// Orleans:
await this.RegisterOrUpdateReminder("daily-check", TimeSpan.FromDays(1), TimeSpan.FromDays(1));
public Task ReceiveReminder(string reminderName, TickStatus status) {
// handle
}
// actor-ts — no direct equivalent, build with persistence + timers:
class MyActor extends PersistentActor<Command, Event, State> {
override preStart(): void {
super.preStart();
this.context.timers.startTimerWithFixedDelay(
'daily-check',
{ kind: 'daily-check' },
24 * 60 * 60_000,
);
}
}

Timers in actor-ts don’t survive restart — for true “durable reminders,” you’d persist the schedule + re-arm in onRecoveryComplete.

// Orleans Streams:
var stream = streamProvider.GetStream<Order>(streamId, "orders");
await stream.OnNextAsync(order);
await stream.SubscribeAsync((order, _) => ...);
// actor-ts equivalent — DistributedPubSub:
const ps = system.extension(DistributedPubSubId);
ps.start(cluster);
ps.mediator.tell(new Publish('orders', order));
ps.mediator.tell(new Subscribe('orders', subscriberRef));

Cluster-wide pub/sub. No grain involvement; topic-based fan-out.

// Orleans Transactions (.NET 8+):
[Transaction(TransactionOption.Required)]
public async Task Transfer(...) {
await fromAccount.Withdraw(amount);
await toAccount.Deposit(amount);
}

actor-ts doesn’t have built-in transactions across actors. Build with sagas:

class TransferSaga extends PersistentFSM<...> {
// Step 1: withdraw from source.
// Step 2: deposit to destination.
// On failure: compensate (refund source).
}

See PersistentFSM for the saga pattern.

// Orleans (with K8s):
var host = Host.CreateDefaultBuilder()
.UseOrleans(siloBuilder => siloBuilder
.UseKubernetesHosting()
.ConfigureEndpoints(siloPort: 11111, gatewayPort: 30000)
)
.Build();
// actor-ts:
import { KubernetesApiSeedProvider, KubernetesApiSeedProviderOptions } from 'actor-ts';
const kubernetesApiSeedProviderOptions = KubernetesApiSeedProviderOptions.create()
.withNamespace(process.env.K8S_NAMESPACE!)
.withServiceName('my-app')
.withSystemName('my-system')
.withPort(2552);
const nodes = await new KubernetesApiSeedProvider(
kubernetesApiSeedProviderOptions,
).lookup();
const seeds = nodes.map(String); // 'system@host:port'
const clusterOptions = ClusterOptions.create()
.withHost(process.env.POD_IP!)
.withPort(2552)
.withSeeds(seeds);
const cluster = await Cluster.join(system, clusterOptions);

Different API shape, same idea. K8s pod discovery → cluster join.

  • Method-call semantics — Orleans grain calls are typed method invocations; actor-ts has message types + handlers. More verbose; more explicit.
  • Distributed transactions across grains — not built-in.
  • Reminders as a first-class primitive — combine timers + persistence.
  • .NET ecosystem — switching to TS / JS runtime.
  • TypeScript for end-to-end typing.
  • Bun’s fast startup vs .NET’s warm-up.
  • Open-source ecosystem for runtime + persistence backends.