Welcome back. In the previous lesson, you divided the tool into cohesive capabilities: process discovery, instrumentation sessions, generic function interception, Windows API tracing, and recording. That slice map answers which capabilities belong together. This lesson answers a different question: how can those capabilities use Frida, the terminal, and files without becoming coupled to them?
The answer is to define application ports: narrow TypeScript contracts expressed in the vocabulary of the application. Frida implementations, CLI/REPL code, and NDJSON file writers become adapters around that core. This will let the same use cases serve both one-shot commands and the REPL later, while keeping Frida-specific work isolated to the Windows-facing edge of the system.
Ports invert the dependency direction
A common implementation path is to begin with what a library offers:
const session = await frida.attach(processId);
const script = await session.createScript(agentSource);
await script.load();
That is appropriate inside a Frida adapter. It is the wrong starting point for an application handler such as attach, add-probe, or list-processes, because it makes your application logic depend directly on Frida’s object model, lifecycle conventions, exceptions, and version-specific API surface.
Instead, start from the use case’s need:
- Process discovery needs a source of observable process snapshots.
- Attach/detach needs a capability to establish and end an instrumentation session.
- Probe management needs a capability to install or remove a declared probe.
- Recording needs a capability to append an accepted trace event durably.
- The CLI and REPL need callable use cases and typed results, not direct access to application internals.
This reflects the Dependency Inversion Principle: high-level policy depends on stable abstractions that it owns; technical details implement those abstractions.
Dependency Inversion & Ports/Adapters | Synapse Studios Standards
Read Synapse Studios’ concise explanation of dependency inversion and ports/adapters. It establishes the terminology used in this lesson and shows the basic TypeScript wiring pattern.
In “Key Principles”, read the dependency rules. Then read “Ports and Adapters”, focusing on how ports are defined. Finally, in “How It Works in Practice”, follow Steps 1 through 4 and use the wiring pattern to connect the definitions to the TypeScript examples.
A port is not simply any interface. It is a boundary contract whose language should describe what the application needs, rather than how an SDK happens to provide it.
For example:
| Too close to technology | Application-oriented port |
|---|---|
FridaService.enumerateProcesses() | ProcessProvider.listAvailable() |
FridaService.attach(pid) | SessionConnector.attach(target) |
fs.appendFile(path, json) | RecordingAppender.append(recordingId, event) |
process.stdout.write(chalk.green(...)) | Return a typed application result for a renderer to display |
readline.question(...) | An inbound use case such as ListProcesses.execute(input) |
The right-hand names are not “more abstract” merely because they avoid a vendor name. They state the operation in terms that are meaningful in Local Instrumentation.
The hexagon has two kinds of ports
Ports and Adapters is also called Hexagonal Architecture. Its geometry is less important than its boundary discipline: external technologies surround the application core, while source-code dependencies point toward the core.

There are two complementary directions.
Inbound ports: ways to invoke the application
An inbound port describes an action an outside actor can request. A use-case handler implements it.
For this tool, both of these are outside adapters:
- A one-shot command such as
grasp processes list. - A REPL command such as
processes list --name notepad.
Both should translate their input into the same application request and invoke the same inbound port. Neither should contain process-discovery policy, call Frida directly, or decide what a ProcessDescriptor means.
A minimal inbound port might look like this:
import type { Result } from "../../../shared/result.js";
import type { ProcessDescriptor } from "../../../domain/local-instrumentation/process.js";
export interface ListProcesses {
execute(
input: ListProcessesInput,
): Promise<Result<ListProcessesOutput, ListProcessesError>>;
}
export interface ListProcessesInput {
readonly nameContains?: string;
}
export interface ListProcessesOutput {
readonly processes: readonly ProcessDescriptor[];
}
export type ListProcessesError =
| {
readonly kind: "ProcessDiscoveryUnavailable";
readonly message: string;
};
The interface gives terminal adapters a stable, typed way to request work. Crucially, it contains no readline types, no process.argv, no ANSI colour codes, no stdout, and no Frida objects.
The handler implementing ListProcesses belongs to the Process Discovery slice. It can apply the deterministic filtering and sorting rules introduced in the previous lesson, then return a typed output. A later renderer is responsible for deciding whether that output becomes a table, JSON, or concise REPL text.
Outbound ports: capabilities the application needs
An outbound port describes a capability the application needs from its environment. The application defines it; an outer adapter implements it.
For example, process discovery needs process snapshots, but it does not need to know that Frida obtains them through a particular API:
import type { Result } from "../../../shared/result.js";
import type { ProcessDescriptor } from "../../../domain/local-instrumentation/process.js";
export interface ProcessProvider {
listAvailable(): Promise<
Result<readonly ProcessDescriptor[], ProcessProviderFailure>
>;
}
export type ProcessProviderFailure = {
readonly kind: "ProcessDiscoveryUnavailable";
readonly message: string;
};
A ListProcessesHandler receives a ProcessProvider in its constructor. Its unit test can receive a fake provider. A production run receives a FridaProcessProvider. The handler is unchanged in both cases.
Exploring Hexagonal Architecture with Typescript
Watch Apiumhub’s “Exploring Hexagonal Architecture with Typescript” for a compact visual explanation of ports, adapters, and the inward dependency rule.
Watch ports and adapters to reinforce the distinction between an operation’s specification and a technology-specific implementation. Continue with layer responsibilities, focusing on why the domain and application layers must remain unaware of infrastructure libraries and frameworks.
Define ports for this tool’s real boundaries
The following boundary map is sufficient for the first implementation. It deliberately avoids a large, generic FridaService interface.
| Application need | Port owned by | Production adapter | Details excluded from the port |
|---|---|---|---|
| Obtain observable process snapshots | Process Discovery | FridaProcessProvider | Frida device objects and Frida process records |
| Attach, detach, and inspect a target | Instrumentation Session | FridaSessionConnector | Frida Session, script loading, raw detach callbacks |
| Install and remove a generic probe | Function Interception | FridaProbeRuntime | Interceptor, NativePointer, agent source, script handles |
| Persist accepted events | Recording | NdjsonRecordingAppender | node:fs, file handles, paths, JSON serialization mechanics |
| Run a user-requested capability | Each feature’s application layer | CLI and REPL adapters | tokenization, prompting, terminal dimensions, ANSI control sequences |
| Display an outcome | Terminal adapter | table, plain-text, or JSON renderer | domain decisions and handler orchestration |
Two details are worth emphasizing.
First, the port belongs with the feature that needs it, not with the adapter that implements it. ProcessProvider belongs in Process Discovery’s application boundary, even though FridaProcessProvider implements it. The application owns the vocabulary and chooses the minimum contract.
Second, ports should remain small and purpose-specific. A giant interface with methods for attaching, enumerating processes, injecting scripts, installing hooks, reading memory, rendering tables, and writing recordings merely relocates the god-object problem behind an interface.
For probe installation, separate ports may be clearer than an all-purpose runtime interface:
import type { Result } from "../../../shared/result.js";
import type { InstrumentationSessionId } from "../../../domain/local-instrumentation/identifiers.js";
import type { Probe, ProbeDefinition } from "../../../domain/local-instrumentation/probe.js";
export interface ProbeInstaller {
install(
sessionId: InstrumentationSessionId,
definition: ProbeDefinition,
): Promise<Result<Probe, ProbeInstallationFailure>>;
}
export interface ProbeRemover {
remove(
sessionId: InstrumentationSessionId,
probeId: Probe["id"],
): Promise<Result<void, ProbeRemovalFailure>>;
}
export type ProbeInstallationFailure =
| {
readonly kind: "ModuleNotLoaded";
readonly moduleName: string;
}
| {
readonly kind: "ExportNotFound";
readonly exportName: string;
}
| {
readonly kind: "ProbeInstallationFailed";
readonly message: string;
};
export type ProbeRemovalFailure = {
readonly kind: "ProbeRemovalFailed";
readonly message: string;
};
This is an application boundary, not a simplified transcription of Frida’s API. The port speaks of session identity, probe definitions, and diagnostic outcomes. It does not expose NativePointer, a Frida script, JavaScript callback functions, or raw agent messages.
The Frida adapter can do all of that technical work internally. It maps low-level failures into the application’s structured error vocabulary at the boundary.
File storage is an adapter, not a recording concept
A recording is meaningful to the domain: it belongs to an instrumentation session and represents retained observations. A filesystem path, an open handle, an NDJSON line, and a node:fs exception are implementation details.
So the Recording slice can state its need this way:
import type { Result } from "../../../shared/result.js";
import type {
RecordingId,
} from "../../../domain/local-instrumentation/identifiers.js";
import type {
TraceEvent,
} from "../../../domain/local-instrumentation/trace-event.js";
export interface RecordingAppender {
append(
recordingId: RecordingId,
event: TraceEvent,
): Promise<Result<void, RecordingWriteFailure>>;
}
export type RecordingWriteFailure = {
readonly kind: "RecordingWriteFailed";
readonly message: string;
};
An NDJSON adapter later implements this port. It decides how a TraceEvent becomes a serialized line, how a configured recording directory is selected, and how filesystem errors are handled. The application handler should only decide questions such as whether the recording is active and whether an event is eligible to be recorded.
Avoid these leaky alternatives:
interface BadRecordingPort {
appendLine(filePath: string, jsonLine: string): Promise<void>;
}
interface BadFridaPort {
createScript(source: string): Promise<unknown>;
enumerateModules(): Promise<unknown[]>;
readPointer(address: string): Promise<string>;
}
The first makes every caller understand files and NDJSON. The second reproduces Frida behind a thin wrapper, so callers still need Frida knowledge to use it correctly. Both interfaces are abstractions at the wrong level.
Martin Fowler’s formulation is useful here: capture low-level dependencies in domain-relevant abstractions. Your application requires durable recording entries, not a filesystem; it requires probe installation, not a generic injected-script API.
Terminal I/O stays outside by translating at the edge
The terminal is an external delivery mechanism, just like an HTTP controller would be in a web application. It is an inbound adapter when it turns operator input into an application request, and an outer presentation component when it renders a typed response.
A healthy division of responsibility is:
| Concern | Owner |
|---|---|
| Split quoted REPL input, identify flags, provide tab completion | REPL adapter |
| Parse one-shot command arguments | CLI adapter |
| Validate a feature request and coordinate a use case | Application handler |
| Decide deterministic process filtering and sorting | Process Discovery application logic |
| Format a process list as a terminal table | Terminal renderer |
Decide whether an observation is a TraceEvent | Domain/application logic |
| Colour, wrap, or paginate text | Terminal renderer |
This means handlers should usually return data, rather than print it. For example, ListProcesses.execute() returns ListProcessesOutput; the terminal renderer decides how to display it.
There are cases where a long-running command may need progress notices. If that becomes necessary, introduce a narrow semantic output port such as OperatorNoticeSink.publish(notice), with a typed OperatorNotice. Do not make the application call console.log() or pass preformatted strings to stdout. However, do not invent that port preemptively: returning typed results is the simplest boundary until the use case demonstrably needs streaming output.
Place interfaces by ownership, implementations by technology
Vertical slices still apply. The following layout keeps a feature’s policy, port, adapter, and tests discoverable together, while preserving the inward dependency rule.
src/
domain/
local-instrumentation/
identifiers.ts
process.ts
probe.ts
trace-event.ts
recording.ts
features/
process-discovery/
application/
list-processes.ts
ports/
process-provider.ts
adapters/
frida-process-provider.ts
list-processes.test.ts
instrumentation-session/
application/
ports/
session-connector.ts
adapters/
frida-session-connector.ts
function-interception/
application/
add-probe.ts
remove-probe.ts
ports/
probe-installer.ts
probe-remover.ts
adapters/
frida-probe-runtime.ts
recording/
application/
ports/
recording-appender.ts
adapters/
ndjson-recording-appender.ts
adapters/
terminal/
cli-adapter.ts
repl-adapter.ts
process-list-renderer.ts
main.ts
The physical location of an adapter is less important than the import rule:
| Code area | May depend on |
|---|---|
domain/ | Other domain modules and small language-level utilities |
| Feature application code | Domain modules, typed result conventions, same-feature ports |
| Feature adapters | The port they implement, domain types, application errors, Frida or Node APIs |
| Terminal adapters | Inbound use-case ports and output models |
main.ts | Application handlers and concrete adapters |
The domain must never import Frida, Node filesystem modules, readline, or terminal formatting libraries. Application handlers must not import them either. Only an outer adapter may import both an external library and the application contract it implements.
Wire the system once, at the composition root
At startup, a small composition root selects real adapters and supplies them to application handlers:
const processProvider = new FridaProcessProvider(fridaDevice);
const listProcesses = new ListProcessesHandler(processProvider);
const terminal = new ReplAdapter({
listProcesses,
renderProcessList: new ProcessListRenderer(),
});
await terminal.run();
This code is intentionally allowed to know about both sides of the boundary. It is the one place where concrete adapter and application handler meet.
That is different from a global service locator. A service locator lets arbitrary code obtain global dependencies at any time, hiding a module’s real requirements. Constructor injection keeps dependencies visible: ListProcessesHandler explicitly needs a ProcessProvider; it cannot silently reach for Frida or the terminal.
For a handler test, substitute a deterministic fake:
const processProvider: ProcessProvider = {
async listAvailable() {
return {
ok: true,
value: [
{ id: processId(4242), name: "fixture.exe" },
],
};
},
};
const handler = new ListProcessesHandler(processProvider);
No Frida device, running process, terminal, or temporary file is needed to test filtering, sorting, and typed error handling. Later, Frida and filesystem adapters will receive focused integration tests of their own.
A practical port-design checklist
Before adding a port, review it with five questions:
-
Is this capability required by a specific application use case?
If not, it may be an unnecessary abstraction. -
Does the name describe a Local Instrumentation need?
ProcessProviderandRecordingAppenderpass.FridaWrapperandFileHelperdo not. -
Do the parameters and return values avoid SDK and transport types?
Do not expose Frida sessions, NodeBuffervalues, raw JSON, terminal strings, file descriptors, orunknown. -
Can a simple fake implement it in a unit test?
If a port requires complicated setup to fake, its surface is likely too wide or too technical. -
Will one implementation decision remain localized?
Replacing Frida calls, changing file serialization, or adding JSON terminal output should change an adapter and composition wiring, not feature policy.
As a short implementation checkpoint, create the port files for ProcessProvider, ProbeInstaller, ProbeRemover, and RecordingAppender. At this stage, their adapters can remain placeholders. Then enforce the boundary immediately with import discipline: application modules should compile without importing frida, node:fs, node:readline, or terminal-formatting packages.
Key takeaways
Ports and adapters give your vertical slices a disciplined way to cross technical boundaries:
- Inbound ports are application use cases invoked by the CLI, REPL, or another delivery mechanism.
- Outbound ports state capabilities the application needs, such as discovering process snapshots, installing probes, or appending recording data.
- Adapters contain Frida calls, terminal interaction, filesystem mechanics, serialization, and technical exception handling.
- A port is defined by the feature that uses it and should use application vocabulary, not library vocabulary.
- The composition root is the one explicit place where real adapters are supplied to handlers.
- Small ports and typed results make unit tests independent of real Windows processes, injected agents, files, and terminal state.
Next, you will model the valid lifecycle of an instrumentation session as an explicit state machine, so attach, probe, recording, and detach operations can only occur in meaningful states.
Can't find a good explanation? Sign up and we'll make it for you
Sign up