Create your own
Lesson illustration

Composing Handlers and Adapters Without a Global Service Locator

Good to see the session model taking shape. In the previous lesson, you defined immutable domain events for confirmed lifecycle facts such as attachment, recording start and stop, and detachment. Those events remain independent of Frida, files, and terminal rendering.

Now we need one deliberate place where those independent pieces become a runnable program. This lesson establishes a composition root: startup code that chooses concrete adapters, creates application handlers, registers event handlers, and exposes a small application API. The key constraint is that application code receives its collaborators explicitly. It must never reach into a global registry to retrieve them.

By the end, you should be able to assemble the Local Instrumentation slice without compromising its boundaries, while leaving room for the Frida, CLI, and recording implementations that follow.


One assembly boundary, many focused modules

Dependency injection is not primarily about a framework or a container library. It is about separating two concerns:

  1. Operational code performs a focused job: validate a command, transition a session state, attach through a port, or render a result.
  2. Assembly code decides which concrete implementations collaborate when the program starts.

For Grasp, an attach-session handler should know it needs a SessionConnector, a session store, event metadata, and an event dispatcher. It should not know whether the connector is backed by Frida, whether the store is in memory, or which event handlers are currently registered.

Watch this focused segment of Dependency Injection Finally Explained Properly! by CsMadeEz. It frames the composition root as the startup location that creates and connects concrete components, then distinguishes that idea from an automated DI framework.

Dependency Injection Finally Explained Properly!

“Dependency Injection Finally Explained Properly!” by CsMadeEz explains why a composition root belongs at startup and why a DI container is optional automation rather than the architectural goal.

Watch the composition root for the role of a single assembly location. Then watch container boundaries for the distinction between plain dependency injection, container libraries, and unnecessary abstraction.

The composition root is allowed to be unusually concrete. It can import Frida bindings, Node filesystem adapters, clock implementations, terminal adapters, configuration, and feature factories. That is not a layering violation: this is the one place that deliberately knows how the pieces are deployed.

Everywhere else should know less.


Composition root versus service locator

The supplied Dependency injection, service locator, and composition root diagram shows three superficially similar arrangements. At the top, C directly depends on A, and A directly depends on B. In the middle, components actively ask a service locator for dependencies. At the bottom, the composition root creates dependencies and supplies fully assembled components.

The diagram contrasts direct dependencies, active lookup through a service locator, and passive dependency injection. In the bottom arrangement, a DI container inside the composition root assembles components before the application begins running.

A service locator often looks convenient at first:

// Do not use this pattern in Grasp.
export const services = {
  resolve(name: string): unknown {
    // Looks up a dependency in a global registry.
    throw new Error(`Missing service: ${name}`);
  },
};

A handler can then conceal its real needs:

export async function attachSession(command: AttachSessionCommand) {
  const connector = services.resolve("sessionConnector");
  // ...
}

This is problematic even if the registry is strongly typed:

  • The function signature does not reveal that attaching requires a connector.
  • The dependency may be absent or incorrectly configured only at runtime.
  • Unit tests must mutate shared global state or bootstrap a large part of the system.
  • Any module can retrieve any registered service, making architectural boundaries porous.
  • A second Grasp application instance, such as an integration-test instance, becomes harder to construct safely.

With injection, the dependency is visible at the factory boundary:

export interface AttachSession {
  execute(command: AttachSessionCommand): Promise<AttachSessionResult>;
}

export interface AttachSessionDependencies {
  readonly sessions: InstrumentationSessionStore;
  readonly connector: SessionConnector;
  readonly eventMetadata: EventMetadataFactory;
  readonly eventDispatcher: DomainEventDispatcher;
}

export function createAttachSessionHandler(
  dependencies: AttachSessionDependencies,
): AttachSession {
  const { connector, eventDispatcher, eventMetadata, sessions } = dependencies;

  return Object.freeze({
    async execute(command: AttachSessionCommand): Promise<AttachSessionResult> {
      // Load and transition session state.
      // Use connector only through the SessionConnector port.
      // Persist the confirmed state.
      // Construct and dispatch immutable domain events.
      void connector;
      void eventDispatcher;
      void eventMetadata;
      void sessions;

      throw new Error("Implementation introduced in the attach-session slice.");
    },
  });
}

The temporary void lines only make the example self-contained; your real handler will use each dependency during orchestration. The important design point is the dependency manifest: anyone reading the factory can see the handler’s external requirements.

A DI container does not automatically make an application a service locator. A container is acceptable when it is used only during boot to assemble the graph. It becomes service location when application code receives the container and calls resolve() while doing its work.

For this course, prefer explicit factory functions and ordinary TypeScript objects. Grasp’s initial dependency graph is small enough that manual wiring is clearer, has stronger static visibility, and does not require reflection, decorators, or string registrations.


A composition root is a runtime build step

Read the sections “Composition Root (AKA Container)” and the following discussion of the boot and run phases in Dependency Injection in JS/TS – Part 1 from The Miners.

Dependency Injection in JS/TS – Part 1 - The Miners

The Miners explains why centralizing service creation separates implementation from assembly, and how an entry point starts a fully composed application.

In “Composition Root (AKA Container),” read the whole section, including the factory and container.ts examples. Focus on the import boundary: implementation modules import contracts, while the composition root selects implementations. Then continue into the immediately following boot-and-run discussion, beginning with “In JS, this might not seem like a huge benefit” and ending after the index.ts example. Pay particular attention to the entry-point handoff, where assembly ends and normal application execution begins.

For Grasp, think of startup in two phases:

PhaseResponsibilityExamples
BootValidate configuration, construct concrete adapters, create handlers, register event consumers.Create the Frida connector, clock, ID generator, dispatcher, session-lifecycle slice.
RunAccept commands and invoke already-created application capabilities.Later, the one-shot CLI and REPL invoke attachSession.execute() or getSessionStatus.execute().

Do not let boot leak into run. A REPL command should not create a new Frida connector, find a recorder from a registry, or decide which clock implementation to use. Those decisions happened once, at startup.


Compose a vertical slice without flattening the architecture

A common concern is that a single composition root may become a huge, unstructured file. The remedy is not a service locator. Instead, let each vertical slice provide a small composition function that accepts explicit dependencies and returns the capabilities it owns.

For example, the session lifecycle slice can expose its public application handlers:

// src/features/session-lifecycle/application/session-lifecycle-slice.ts

export interface SessionLifecycleSlice {
  readonly attachSession: AttachSession;
  readonly detachSession: DetachSession;
  readonly getSessionStatus: GetSessionStatus;
}

export interface SessionLifecycleDependencies {
  readonly sessions: InstrumentationSessionStore;
  readonly connector: SessionConnector;
  readonly eventMetadata: EventMetadataFactory;
  readonly eventDispatcher: DomainEventDispatcher;
}

export function createSessionLifecycleSlice(
  dependencies: SessionLifecycleDependencies,
): SessionLifecycleSlice {
  return Object.freeze({
    attachSession: createAttachSessionHandler(dependencies),
    detachSession: createDetachSessionHandler(dependencies),
    getSessionStatus: createGetSessionStatusHandler({
      sessions: dependencies.sessions,
    }),
  });
}

This is not a second global composition root. It does not start the program, read environment variables, choose a Frida device, or construct arbitrary infrastructure. It only assembles closely related handlers within one feature slice.

This pattern is particularly useful for the planned Grasp features:

SlicePublic capabilitiesPrimary external ports
Session lifecycleAttach, detach, statusSessionConnector, session store
Process discoveryList and select targetProcessProvider, selected-target store
Probe managementAdd, list, remove probeInjected-agent operations, probe registry
RecordingStart and stop recordingRecorder, redaction policy, event pipeline

The root coordinates those slices, but each slice remains cohesive. Later, adding the process-discovery slice should add a focused construction block, not require modifying the internal dependencies of session-lifecycle handlers.


A concrete Grasp application factory

Place the outer assembly code in a location that no domain or application module imports:

src/
  domain/
  features/
  infrastructure/
  bootstrap/
    create-application.ts
  main.ts

The bootstrap directory may import from every architectural layer. The reverse must not happen.

Here is the intended shape of create-application.ts. The exact factory names may differ from your repository, but the ownership and order matter.

// src/bootstrap/create-application.ts

export interface GraspApplication {
  readonly attachSession: AttachSession;
  readonly detachSession: DetachSession;
  readonly getSessionStatus: GetSessionStatus;
  readonly sessionActivityLog: SessionActivityLog;
}

export async function createApplication(
  config: ValidatedGraspConfiguration,
): Promise<GraspApplication> {
  // Stable infrastructure services
  const clock = createSystemClock();
  const idGenerator = createCryptoIdGenerator();
  const eventMetadata = createEventMetadataFactory({
    clock,
    idGenerator,
  });

  // Stateful adapters
  const sessions = createInMemoryInstrumentationSessionStore();
  const activityLog = createInMemorySessionActivityLog();

  // Frida adapter: its concrete implementation arrives in Module 4.
  const connector = await createFridaSessionConnector({
    deviceSelector: config.frida.deviceSelector,
  });

  // Domain-event handlers are registered explicitly at boot.
  const eventDispatcher = createInProcessDomainEventDispatcher([
    createSessionActivityLogHandler({
      activityLog,
    }),
  ]);

  // Feature-local composition
  const sessionLifecycle = createSessionLifecycleSlice({
    sessions,
    connector,
    eventMetadata,
    eventDispatcher,
  });

  return Object.freeze({
    ...sessionLifecycle,
    sessionActivityLog: activityLog,
  });
}

Read the construction from top to bottom:

  1. Create leaf services with no application dependencies, such as the system clock and cryptographic ID generator.
  2. Create adapters that hold state or integrate with a boundary, such as the in-memory session store and Frida connector.
  3. Register application-level domain-event handlers explicitly.
  4. Build feature slices from their ports and shared collaborators.
  5. Return only the capabilities that the delivery mechanism needs.

The Frida connector factory is deliberately only named here; its implementation belongs in the later Frida host–agent module. The architectural commitment today is that it will implement the existing SessionConnector port and will be selected at the composition root. Until it exists, a development harness can supply a controlled fake or an adapter that returns a typed “Frida unavailable” application error.

Notice what is not returned:

  • the raw Frida device or Frida session;
  • the event dispatcher;
  • the system clock;
  • the ID generator;
  • the session store’s implementation details.

The eventual CLI and REPL do not need those objects. They need application capabilities such as attachSession and getSessionStatus. Returning the smallest useful application surface prevents delivery code from bypassing handlers and reaching directly into infrastructure.


The entry point stays thin

Your main.ts should validate boundary input, create the application once, and hand the assembled application to the delivery mechanism.

// src/main.ts

async function main(): Promise<void> {
  const config = await loadValidatedGraspConfiguration();

  const application = await createApplication(config);

  await runTerminalApplication({
    application,
  });
}

void main();

loadValidatedGraspConfiguration() is a boundary concern. It should use the Zod-based validation approach from the TypeScript baseline so that createApplication() receives a trusted ValidatedGraspConfiguration, not raw environment variables or unknown.

runTerminalApplication() is a delivery concern. In the next module, it will become the shared CLI and REPL boundary. It should route parsed commands to the handlers exposed by GraspApplication; it should not import Frida or rebuild application services.

A useful import-direction rule is:

Code locationMay importMust not import
DomainDomain types and pure domain modulesApplication handlers, Frida, Node terminal or filesystem APIs
Application featureDomain and application portsConcrete Frida, terminal, and storage adapters
Infrastructure adapterIts port, relevant domain types, third-party libraryBootstrap or terminal command parsing
BootstrapAll of the aboveNothing should import bootstrap
Terminal deliveryApplication capabilities and result renderersFrida objects and storage internals

The bootstrap layer is intentionally “dirty”: it knows concrete details. Its value is containment. Rather than allowing deployment details to spread throughout the codebase, it concentrates them in a small, reviewable startup module.


Event registration belongs in boot, not in the domain

The last lesson separated raising a domain event from dispatching it. Composition is where the dispatcher gains its actual handlers:

const eventDispatcher = createInProcessDomainEventDispatcher([
  createSessionActivityLogHandler({ activityLog }),
]);

This line is valuable because it makes event policy visible:

  • instrumentation-session.attached may be recorded in the in-memory activity log.
  • recording.started and recording.stopped may update that same activity log.
  • instrumentation-session.detached may be recorded with its detach reason.
  • Events may currently have no other consumers, and that is valid.

When a later slice introduces an event consumer, add it explicitly here. For example, a future recording-status projection can be registered without modifying the session state machine or attach handler.

There is one policy decision worth making now: a failure in an event consumer cannot undo an already-confirmed session transition. If attachment succeeds and the activity-log handler later fails, the session is still attached. Treat the event-handler failure as a separate diagnostic concern, not as evidence that the Frida attachment failed.


Testing: use a test composition, not global mutation

Explicit construction makes unit tests small. A unit test for createAttachSessionHandler() supplies fakes for precisely its declared ports:

const handler = createAttachSessionHandler({
  sessions: fakeSessionStore,
  connector: fakeSessionConnector,
  eventMetadata: fixedEventMetadataFactory,
  eventDispatcher: recordingEventDispatcher,
});

This test does not need:

  • a real Frida device;
  • environment variables;
  • a terminal;
  • a filesystem;
  • a global beforeEach() that reconfigures a registry.

Keep one additional class of test for composition itself: a wiring test or focused integration test. It constructs the real application with controlled infrastructure and verifies a public handler reaches the expected adapter. Such tests are where you catch accidentally omitted registrations or configuration mistakes.

Do not make every unit test call createApplication(). That would turn a focused handler test into a boot test and make failures harder to localize. Use the smallest composition that proves the behavior under test.


Key takeaways

A composition root gives Grasp an explicit and inspectable answer to: “What implementation runs when the application starts?”

  • The composition root is the startup boundary where concrete adapters, handlers, and event consumers are constructed.
  • Application handlers declare dependencies in factory parameters and receive them directly.
  • A global service locator hides dependencies, weakens test isolation, and permits architectural bypasses.
  • Manual composition is appropriate for Grasp’s initial dependency graph; a DI container remains optional tooling, not a requirement.
  • Each vertical slice can expose a local factory that returns its public capabilities, while the bootstrap layer remains the only process-wide assembly point.
  • The root is the correct place to register domain-event handlers and choose Frida, clock, storage, and terminal implementations.
  • Unit tests compose individual handlers with fakes; focused wiring tests compose a controlled application instance.

Next, the course moves into the typed CLI and interactive REPL module. The handlers assembled here will become the stable application surface shared by one-shot commands and the persistent terminal session.

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

Sign up