Skip to content
English

ActorSystem

The ActorSystem is the top-level container for actors. One per logical application — sometimes one per process, sometimes a couple running side-by-side (e.g. a worker-thread-isolation setup). Every actor lives inside a system; the system owns the dispatcher (which schedules message processing), the scheduler (which runs timers), the supervisor tree (which catches actor failures), the event stream, and any extensions you’ve registered.

import { ActorSystem } from 'actor-ts';
const system = ActorSystem.create('my-app');

The string is the system name — it appears in actor paths (actor-ts://my-app/user/...), log lines, and cluster identification. Different systems can coexist with different names; same name in a clustered setup means “I’m joining the existing cluster”, different name means “I’m a separate cluster”.

create returns synchronously. The system’s root guardians are spawned eagerly; user actors don’t exist yet — you spawn them via spawn (covered below).

ActorSystem.create takes an optional settings object as the second argument:

const actorSystemOptions = ActorSystemOptions.create()
.withLogLevel(LogLevel.Info)
.withConfigFile('./application.conf');
const system = ActorSystem.create('my-app', actorSystemOptions);

The full settings shape:

FieldPurpose
loggerCustom Logger instance. Defaults to a console logger respecting logLevel.
logLevelOne of debug / info / warn / error / off.
dispatcherCustom Dispatcher. Defaults to the immediate dispatcher; swap in a microtask- or throughput-based one to tune scheduling.
schedulerCustom Scheduler. Defaults to a real-time scheduler; tests inject ManualScheduler to control time.
configEither a prebuilt Config or a plain object of HOCON overrides. Layered on top of reference defaults + any application.conf.
configFileExplicit path to an application.conf file. Overrides the ACTOR_TS_CONFIG env var and the CWD lookup.

Constructor settings always win over anything in config — they’re the explicit code-level overrides.

For larger applications, prefer a application.conf file at the project root:

actor-ts {
logger {
level = "info"
}
dispatcher {
throughput = 100
}
cluster {
gossip-interval = 500ms
failure-detector.unreachable-after = 1500ms
}
}

The framework loads it automatically when present. ENV substitution (${?ENV_NAME}) works as the HOCON spec defines — values pulled from the environment fall back to the default when unset. See Configuration for every key the framework reads.

Top-level actors are spawned via system.spawn:

const root = system.spawn(
MyRootActor,
'root', // optional name; framework picks one if omitted
);

The returned ActorRef is a handle, not the instance. Pass it around, store it, hand it to other actors.

Inside an actor, child actors are spawned via context.spawn, not system.spawn:

class Parent extends Actor<...> {
override onReceive(message) {
const child = this.context.spawn(Child, 'worker');
}
}

Children are tied to the parent’s lifecycle — when the parent stops, all children stop first. Children’s failures escalate to the parent’s supervisor strategy. Top-level actors (from system.spawn) escalate to the system’s root guardian instead.

Every actor has a path under the system root. Three top-level “guardian” actors sit just below the root:

actor-ts://my-app/

/user

your application's actors

/system

framework-internal actors

/deadLetters

messages to dead refs

/system/cluster

sharding, singleton, pubsub, …

/system/devtools

hub + probes

When you call system.spawn(actor, name), the actor is created under /user. /user holds only what your code spawned; everything the framework spawns for itself lives under /system, one group per subsystem — see Actor paths for the full layout. There is no public API for spawning into /system.

On termination the two guardians go in sequence, not together: /user is fully drained first, then /system. That order is what lets a user actor’s postStop still reach the framework actors it depends on — unsubscribing from the pub-sub mediator, handing a shard back — instead of racing them into dead letters.

The /deadLetters “actor” is special — messages to a tell on a stopped ref, or to a ref that never existed, route there. By default the system logs dead letters at debug level; subscribe to the event stream if you want to react programmatically.

Extensions are the framework’s plugin system. Cluster, persistence, DistributedData, DistributedPubSub, HTTP — they’re all extensions. You register them once at the system level, then reach them via system.extension(...):

import { Cluster, ClusterOptions, DistributedDataId } from 'actor-ts';
const cluster = await Cluster.join(system, ClusterOptions.create() /* ... */);
const dd = system.extension(DistributedDataId).start(cluster);

Extensions are lazy: they don’t initialize until you reach for them. An app that never calls system.extension(DistributedDataId) never starts a DD replicator. This keeps single-process apps small; adopt features by reaching for them, drop them by stopping reaching.

import { extensionId, type Extension, type ExtensionId } from 'actor-ts';
class MetricsCollector implements Extension {
constructor(private readonly system: ActorSystem) {}
incCounter(name: string): void { /* ... */ }
}
const MetricsCollectorId: ExtensionId<MetricsCollector> = extensionId(
'MetricsCollector',
(system) => new MetricsCollector(system),
);
// Lookup is idempotent — first call creates, subsequent calls return
// the cached instance.
const metrics = system.extension(MetricsCollectorId);
metrics.incCounter('login.success');

Extensions are useful when:

  • You need cross-cutting state shared by many actors (a connection pool, a metrics collector).
  • The state is expensive to initialize and shouldn’t exist if nothing reaches for it (a cluster join, a DD replicator).
  • You want a clean way to inject test-doubles in unit tests (override the ExtensionId resolver).
await system.terminate();

terminate performs an ordered shutdown:

  1. Notify the cluster (if joined) — gossip “I’m leaving” so peers stop routing to this node.
  2. Stop /user recursively — your actors get postStop, children first. Actors with in-flight async onReceives finish their current message before stopping.
  3. Stop /system — framework internals unwind. This starts only once /user is fully drained, so a postStop in step 2 can still talk to the sharding, pub-sub or delivery actors it depends on.
  4. Close the dispatcher and scheduler — no new messages, no new timers.
  5. Resolve the returned promise.

For production apps you typically wrap this in a SIGTERM handler:

process.on('SIGTERM', async () => {
await system.terminate();
process.exit(0);
});

…but the framework provides a richer pattern for that — see Coordinated shutdown for the 12-phase ordered-shutdown DSL, which handles K8s PreStop hooks, in-flight HTTP requests, draining brokers, etc.

The common answer is one. A second system in the same process means a separate cluster, a separate dispatcher, a separate supervisor tree — typically more overhead than the use case justifies.

Two situations where a second system makes sense:

  • Worker-thread isolation: the main thread runs one system, a worker thread runs another, both spanning the same cluster via the MessageChannelTransport. This is the Worker mesh pattern — multiple systems per OS process, all participating in the same cluster.
  • Test fixtures: a TestActorSystem per test case so cleanup is guaranteed. See TestKit.
  • Actor — the class you’ll spawn into the system.
  • Coordinated shutdown — graceful-shutdown DSL beyond a plain terminate.
  • Cluster overview — when you go from one system per process to many systems in a cluster.
  • Configuration — every HOCON key the framework reads, grouped by extension.

The ActorSystem class API reference documents every public method discussed here.