Create your own
Lesson illustration

Designing Immutable Domain Events for Session Actions

Welcome back. The previous lesson modeled an instrumentation session as an explicit state machine: commands and technical outcomes move the session through legal states, while invalid transitions return typed errors. That model answers whether an operation is allowed.

This lesson adds a complementary idea: when a meaningful session fact has occurred, represent that fact as an immutable domain event. A successful attachment, a recording that genuinely started, or an unexpected detach may matter to several parts of Grasp. The session lifecycle should state those facts without knowing whether another component will write an NDJSON record, refresh REPL output, update an in-memory summary, or do nothing at all.

By the end, you will have a small TypeScript event model for instrumentation sessions, clear criteria for choosing events, and a boundary between raising facts in the domain and handling their consequences elsewhere.


Facts, not requests, traces, or output

A domain event states something that already happened in the language of the domain. Its name should therefore be past tense.

Compare four categories that can all look like “events” in code:

CategoryExampleMeaningOwner
Command or transition inputattach.requestedAn operator or handler is trying to perform an action.Application and state machine
Domain eventinstrumentation-session.attachedA session has successfully attached.Domain model
Agent trace eventintercepted-callThe injected agent observed one native function invocation.Agent protocol and tracing feature
Presentation or storage output"Attached to fixture.exe (PID 1234)"A human-facing rendering of a result.Terminal adapter

The distinction matters because these categories have different lifetimes and consumers.

attach.requested from the prior state machine is an input. It might be rejected because the session is already attaching, and even if accepted, the Frida attach operation might fail. It would be incorrect to emit InstrumentationSessionAttached at that point.

Only after the connector reports success and the lifecycle transitions to attached does this fact exist:

“Instrumentation session S attached to process P.”

Likewise, an intercepted CreateFileW call is not a domain event about Grasp’s own session lifecycle. It is an observation from the target process. Later, the agent protocol and recording pipeline will model it as a typed trace event, with thread, arguments, return value, and possibly a backtrace.

Watch this short implementation-oriented overview before designing the event types. It uses C#, but its distinctions between domain facts, integration events, raising, and handling transfer directly to this TypeScript design.

Using Domain Events To Build A Decoupled System The Scales

“Using Domain Events To Build A Decoupled System The Scales” by Milan Jovanović introduces domain events as immutable facts and separates them from cross-boundary integration messages and side-effect handlers.

Watch facts and boundaries for the past-tense naming convention, immutable event data, and the difference between a domain event and an integration event. Then watch raising events to see why raising an event can mean adding it to a collection rather than immediately running code. Finish with application handlers; focus on how moving a side effect out of a command handler makes each responsibility narrower.

A useful rule is:

A domain event describes a domain fact, not a technical mechanism used to observe, store, or display that fact.

So these are poor domain-event names for Grasp:

  • FridaAttachReturned
  • ConsolePrintedAttachSuccess
  • SessionWrittenToNdjson
  • RenderRecordingStartedMessage

They expose an implementation decision or name a consumer. In contrast, InstrumentationSessionAttached remains meaningful if Frida is replaced, if the REPL becomes a GUI, or if no recording is active.


Decide which session facts are significant

Not every line of control flow deserves an event. An event has a cost: it becomes part of the vocabulary that handlers, tests, and future features may depend on.

For the current lifecycle, start with facts that mark durable, meaningful boundaries:

Domain eventWhen it is raisedUseful potential consumers
instrumentation-session.attachedA connector has established a usable session and state becomes attached.Session activity summary, audit trail, future plugins
recording.startedThe recorder has opened successfully and state becomes recording.Recording status projection, session summary
recording.stoppedThe recorder has closed successfully and state returns to attached.Summary projection, later replay indexing
instrumentation-session.detachedA normal detach completes, or a correlated transport detach is confirmed.Session cleanup policy, activity summary

Some outcomes should not become these domain events:

  • attach.requested is intent, not a completed fact.
  • attach.failed is normally a typed application failure returned to the CLI or REPL. It does not mean a session existed and then changed.
  • recording.stop.failed is handled by the state machine’s recovery rule, which conservatively keeps the state at recording.
  • A late transport.detached notification for an old session should not produce a new detach event, because it did not change the current session.

This keeps the event catalog tied to facts that other parts of the application can react to safely.

Domain events are not integration events

A domain event stays inside the Local Instrumentation bounded domain and normally inside the current Node.js process. It may be delivered to zero, one, or several application handlers.

An integration event is intended to cross a process or bounded-context boundary: for example, publishing an event to a remote service or message broker. Grasp does not need that mechanism for its first local Windows version. If it later exports session activity to another system, an application handler can translate a domain event into a separately designed integration message.

Read the following sections of Microsoft’s Domain events: Design and implementation. The examples use orders and .NET, but focus on the architectural rules rather than the framework-specific APIs.

Domain events: Design and implementation - .NET

Microsoft’s guide explains why domain events make side effects explicit, why handlers belong outside the domain model, and why an event should be immutable.

In “What is a domain event?”, read the definition and benefit. Then continue to “Domain events as a preferred way to trigger side effects across multiple aggregates within the same domain” and its discussion of application-layer handlers. In “Implement domain events,” read the naming and immutability guidance. Finally, under “Raise domain events,” read the subsection “The deferred approach to raise and dispatch events,” from deferred raising. For Grasp, retain the separation between raising a fact and dispatching it; do not carry over the database-specific transaction examples literally.

The supplied Aggregate event fan-out diagram visualizes the core decoupling. An order aggregate raises one OrderStarted fact; several application-layer handlers may independently react. Substitute Grasp’s instrumentation-session aggregate and an instrumentation-session.attached fact for the order example.

An Order aggregate in the Domain layer raises the `OrderStarted` domain event, and multiple handlers in the Application layer respond independently. In Grasp, an instrumentation-session fact such as `instrumentation-session.attached` can similarly have zero or more application-layer consumers without the session model depending on display, storage, or Frida cleanup code.

The fan-out does not mean every event must have handlers. It is valid to raise a well-defined fact that has no current subscriber. The value is that a later handler can be added without modifying the session state machine.


Model events as immutable TypeScript data

For this project, plain objects and discriminated unions are a better fit than event classes. They serialize predictably if needed later, work naturally with structural TypeScript typing, and align with the immutable state representation from the previous lesson.

Create a domain file alongside the session state model:

src/domain/local-instrumentation/instrumentation-session-events.ts

The following code assumes the branded identifiers from the TypeScript baseline, including ProcessId, InstrumentationSessionId, and RecordingId. DomainEventId and UtcTimestamp should use the same branded-scalar convention.

import type {
  DomainEventId,
  InstrumentationSessionId,
  ProcessId,
  RecordingId,
  UtcTimestamp,
} from "../identifiers.js";
import type {
  ProcessTarget,
  SessionConnection,
} from "./instrumentation-session-state.js";

export interface DomainEventMetadata {
  readonly eventId: DomainEventId;
  readonly occurredAt: UtcTimestamp;
}

export interface ProcessTargetSnapshot {
  readonly processId: ProcessId;
  readonly displayName: string;
}

export type SessionDetachedReason =
  | "operator-requested"
  | "transport-lost";

export interface InstrumentationSessionAttached {
  readonly type: "instrumentation-session.attached";
  readonly eventId: DomainEventId;
  readonly occurredAt: UtcTimestamp;
  readonly sessionId: InstrumentationSessionId;
  readonly target: ProcessTargetSnapshot;
}

export interface RecordingStarted {
  readonly type: "recording.started";
  readonly eventId: DomainEventId;
  readonly occurredAt: UtcTimestamp;
  readonly sessionId: InstrumentationSessionId;
  readonly recordingId: RecordingId;
  readonly target: ProcessTargetSnapshot;
}

export interface RecordingStopped {
  readonly type: "recording.stopped";
  readonly eventId: DomainEventId;
  readonly occurredAt: UtcTimestamp;
  readonly sessionId: InstrumentationSessionId;
  readonly recordingId: RecordingId;
  readonly target: ProcessTargetSnapshot;
}

export interface InstrumentationSessionDetached {
  readonly type: "instrumentation-session.detached";
  readonly eventId: DomainEventId;
  readonly occurredAt: UtcTimestamp;
  readonly sessionId: InstrumentationSessionId;
  readonly target: ProcessTargetSnapshot;
  readonly reason: SessionDetachedReason;
}

export type InstrumentationSessionDomainEvent =
  | InstrumentationSessionAttached
  | RecordingStarted
  | RecordingStopped
  | InstrumentationSessionDetached;

There are several intentional choices here.

Use past-tense discriminants

The type literals name completed facts:

  • instrumentation-session.attached
  • recording.started
  • recording.stopped
  • instrumentation-session.detached

They are more precise than a generic SessionChanged event plus a status field. A specific event tells a handler exactly what happened, and TypeScript can narrow its payload automatically.

Keep payloads self-contained and domain-oriented

Every event includes:

  • an eventId, useful for logs, correlation, and eventual deduplication;
  • occurredAt, representing when the domain fact was recognized;
  • the relevant session and recording identities;
  • a snapshot of the target process identity where it aids interpretation.

It deliberately excludes:

  • a Frida Session, Script, or NativePointer;
  • a terminal color, formatted string, or REPL prompt;
  • a file path, file handle, NDJSON line, or write result;
  • an agent message envelope, sequence number, or raw intercepted arguments.

Those concepts either belong to adapters or will belong to later tracing and recording slices. Putting them in this event union would cause the domain model to inherit storage and presentation concerns.

Snapshot instead of retaining a mutable object reference

An event describes the past. It should not silently change because some caller later mutates an object it received.

Use small factory functions that make a fresh process-target snapshot and freeze the returned event:

function snapshotTarget(target: ProcessTarget): ProcessTargetSnapshot {
  return Object.freeze({
    processId: target.processId,
    displayName: target.displayName,
  });
}

export function sessionAttached(
  metadata: DomainEventMetadata,
  connection: SessionConnection,
): InstrumentationSessionAttached {
  return Object.freeze({
    type: "instrumentation-session.attached",
    ...metadata,
    sessionId: connection.sessionId,
    target: snapshotTarget(connection.target),
  });
}

export function recordingStarted(
  metadata: DomainEventMetadata,
  connection: SessionConnection,
  recordingId: RecordingId,
): RecordingStarted {
  return Object.freeze({
    type: "recording.started",
    ...metadata,
    sessionId: connection.sessionId,
    recordingId,
    target: snapshotTarget(connection.target),
  });
}

export function recordingStopped(
  metadata: DomainEventMetadata,
  connection: SessionConnection,
  recordingId: RecordingId,
): RecordingStopped {
  return Object.freeze({
    type: "recording.stopped",
    ...metadata,
    sessionId: connection.sessionId,
    recordingId,
    target: snapshotTarget(connection.target),
  });
}

export function sessionDetached(
  metadata: DomainEventMetadata,
  connection: SessionConnection,
  reason: SessionDetachedReason,
): InstrumentationSessionDetached {
  return Object.freeze({
    type: "instrumentation-session.detached",
    ...metadata,
    sessionId: connection.sessionId,
    target: snapshotTarget(connection.target),
    reason,
  });
}

readonly is a compile-time restriction. Object.freeze() also prevents top-level reassignment at runtime. Freezing is only shallow, but this event model contains immutable branded strings and an independently frozen target snapshot, not a mutable array or domain entity. That is sufficient here.

If future events carry collections, declare them as readonly Item[], create fresh items, and freeze the nested collection as well. Never place a mutable Frida object, Date, Map, or Set inside an event payload.


Raise an event after the state change is confirmed

The state machine still governs validity. It should not emit recording.started merely because the operator typed start-recording.

For an attach use case, the relevant order is:

  1. Submit attach.requested to the state machine.
  2. Persist or retain the resulting attaching session state.
  3. Call the SessionConnector application port.
  4. Submit attach.succeeded only if the connector reports a usable SessionConnection.
  5. Persist or retain the resulting attached state.
  6. Raise instrumentation-session.attached as a fact about that confirmed transition.
  7. Dispatch the raised event at the application layer’s chosen commit point.

The event factory itself needs no system clock, UUID generator, Frida dependency, filesystem, or terminal. The application handler supplies already-created metadata:

const event = sessionAttached(
  eventMetadataFactory.next(),
  connection,
);

eventMetadataFactory is an application dependency. It can combine a clock and an ID generator, both injected from the composition root. This keeps time and identifier generation deterministic in tests without turning the event itself into a service.

A subtle case: unexpected transport detach

The state machine previously accepted a matching transport.detached notification and forced a live session to detached. That notification is a technical input; the domain event is the confirmed fact it produces.

If the notification’s session ID matches the active connection:

  • transition the session to detached;
  • raise instrumentation-session.detached with reason: "transport-lost".

If it is a stale notification for an older session:

  • leave the current state unchanged;
  • raise no event.

And if the session was recording when the transport vanished, do not claim recording.stopped. The recorder may not have closed cleanly. instrumentation-session.detached accurately communicates the fact you know; cleanup and reporting policies can decide how to characterize the incomplete recording.


Separate raising from handling

Raising an event means collecting or returning an immutable description of what happened. Dispatching means finding handlers and invoking them. Keep those responsibilities separate.

A lightweight application-level abstraction might be:

import type {
  InstrumentationSessionDomainEvent,
} from "../../domain/local-instrumentation/instrumentation-session-events.js";

export interface DomainEventDispatcher {
  dispatch(
    events: readonly InstrumentationSessionDomainEvent[],
  ): Promise<void>;
}

This interface belongs in the application layer, not the domain layer. The domain model only creates values from the event union. It does not import a dispatcher, register callbacks globally, print messages, or write a recording file.

Handlers also belong in the application layer because they coordinate ports and side effects. For example:

EventPossible application handlerDependency it may use
instrumentation-session.attachedUpdate a session activity projectionIn-memory projection store
recording.startedMark recording status visible to commandsQuery-side status store
recording.stoppedUpdate a recording summaryRecording-summary port
instrumentation-session.detachedTrigger idempotent cleanup coordinationProbe/session lifecycle ports

The terminal adapter is not an event payload consumer in the domain. It may later render a query result such as the current session status. This is preferable to embedding terminal formatting into the attachment operation and makes one-shot CLI commands and REPL commands behave consistently.

Avoid a static global DomainEvents singleton. It hides dependencies, makes test isolation harder, and conflicts with the explicit composition-root design you will build next. Instead, instantiate the dispatcher and its handler registrations at startup, then inject the dispatcher into the application handlers that need to flush raised events.

The dispatch boundary deserves deliberate policy. In a database-backed system, events are commonly dispatched after or around a successful transaction commit. Grasp’s initial session state is in-process rather than relational, but the same principle applies: do not publish a fact until the authoritative session update that makes it true has succeeded.


Test the contract, not a console message

At this stage, the most valuable tests are pure unit tests.

Test event construction directly:

  • each factory returns the correct literal type;
  • the event has the supplied identifiers and timestamp;
  • the target is copied into a snapshot;
  • event fields cannot be reassigned in TypeScript;
  • the returned object is frozen at runtime if that is part of your convention.

Then test lifecycle-to-event policy in the application handler:

  • a successful attach produces exactly one instrumentation-session.attached event;
  • an invalid attach transition calls neither the connector nor dispatcher;
  • a failed connector operation produces no attached event;
  • a matching transport-detach notification produces one detached event;
  • a stale notification produces no detached event.

Do not assert terminal text in these tests. A test such as “after attaching, stdout contains green Attached text” couples domain behavior to one presenter. The corresponding terminal-rendering test belongs later in the CLI/REPL slice.


Key takeaways

Domain events give Grasp a stable vocabulary for important completed session facts:

  • They are past-tense, immutable facts, not commands, raw agent trace messages, or rendered terminal output.
  • Start with a small catalog: session attached, recording started, recording stopped, and session detached.
  • Use discriminated unions, readonly fields, fresh snapshots, and runtime freezing for robust TypeScript event values.
  • Include domain identities and event metadata, but exclude Frida objects, file handles, display text, and future recording-envelope details.
  • Raise an event only after the state machine and relevant port operation confirm the fact.
  • Keep event creation in the domain and event dispatching plus side-effect handlers in the application layer.
  • A stale external detach notification changes neither session state nor the event stream.

Next, you will assemble application handlers and infrastructure adapters in an explicit composition root, including event-handler registration, without introducing a global service locator.

Can't find a good explanation? Sign up and we'll make it for you

Sign up