This guide explains how CliInvoke works internally. It walks through the
data-flow that every invocation follows, shows how the three invocation
patterns (CliRun, IProcessInvoker, IExternalProcess) enter and exit
that data-flow, and describes the Process Invocation Pipeline — the
layered pattern CliInvoke uses to keep cross-cutting concerns (path
resolution, runner wrapping, result validation) out of the core execution
path.
If you only read one section, read The data-flow and The Process Invocation Pipeline.
This page exists to answer four questions precisely:
The following terms are used throughout this guide. They mirror the
canonical definitions in
GLOSSARY.md.
A Builder is a fluent, mutable object used to assemble a configuration
model (advanced path). Each builder is a short-lived staging area whose
only job is to produce exactly one model via Build(). The produced model
is independent of the builder — they do not share lifetime. In v3,
init construction is the default; the builder is reserved for argument
escaping, UserCredentialSpec, and resource-policy callback flows.
Examples: ProcessConfigurationBuilder, ArgumentsSpec,
EnvironmentVariablesSpec, UserCredentialSpec.
A Configuration Model is an immutable, value-bearing object that
describes one aspect of how a process should be run. Models in this
library are POCOs: they hold data, expose read-only properties, and
implement value equality. Examples: ProcessConfiguration,
ProcessExitConfiguration, UserCredential, ProcessTimeoutPolicy,
ProcessResourcePolicy.
An Invoker is the abstraction that turns a configuration model into a
running process. The default implementation, ProcessInvoker, wires
together configuration, exit behaviour, cancellation, and piping, and
returns a ProcessResult. The invoker owns nothing about the
configuration; it reads the model, runs the process, and disposes the
OS resources it allocated.
A Result is the immutable object returned by the invoker after the
process exits. Two concrete result types exist —
ProcessResult and BufferedProcessResult —
corresponding to the two execution modes (Basic, Buffered).
Results are the canonical return value of the pipeline.
A Process Invocation Pipeline is the layered interceptor pattern that
CliInvoke uses to execute cross-cutting concerns around the core process
orchestration. The pipeline wraps IExternalProcess and lets each
cross-cutting concern modify the configuration before execution or the
result after execution.
A Process Invocation Context is the conceptual state-bearing object that moves through the pipeline. It encapsulates the requested configuration, the execution mode (Basic or Buffered), the runner configuration (if any), and the resulting process output. Each cross-cutting concern reads from and writes to the context, and the context is the single source of truth that travels from the start of the pipeline to the end.
A Resource-Owning Type is any CliInvoke type that holds, directly or
transitively, an unmanaged resource (pipes, file handles, process
threads) or a sensitive managed resource (SecureString). The library
exposes exactly three; see the
Resource Disposal guide for the full list.
Every CliInvoke invocation moves through four stages, regardless of which pattern the caller uses.
┌──────────────┐ init / Build() ┌──────────────────┐ ExecuteAsync ┌──────────┐
│ Construction │ ─────────────────► │ Configuration │ ──────────────► │ Invoker │
│ (init or build)│ │ Model │ │ (executes)│
└──────────────┘ │ (immutable) │ └────┬─────┘
└──────────────────┘ │
▼
┌──────────────────┐ await ┌──────────────────┐
│ Process Result │ ◄────────── │ Pipeline + │
│ (immutable) │ │ IExternalProcess│
└──────────────────┘ └──────────────────┘
flowchart LR
A[Construction<br/>init or builder] -->|init / Build()| B[Configuration Model<br/>immutable, value-equal]
B -->|ExecuteAsync| C[Invoker<br/>ProcessInvoker]
C -->|StartAsync| D[Pipeline + IExternalProcess]
D -->|await| E[Process Result<br/>immutable]
E -->|returned| C
In v3, init construction is the default. The caller creates the
configuration either directly via init properties (the default) or via
a builder (advanced). Both paths produce an immutable ProcessConfiguration.
Direct construction (recommended default):
ProcessConfiguration config = new ProcessConfiguration("dotnet", "--version");
Builder construction (advanced — for escaping, credentials, policy):
using CliInvoke.Builders;
IProcessConfigurationBuilder builder = new ProcessConfigurationBuilder("dotnet")
.SetArguments(["--info"])
.SetOutputRedirection(true);
ProcessConfiguration config = builder.Build();
Key types in this stage:
ProcessConfiguration — directly constructible via init setters with
required TargetFilePath.ProcessConfigurationBuilder — assembles ProcessConfiguration
(advanced: escaping, UserCredentialSpec, resource-policy callbacks).ArgumentsSpec — assembles the argument list.EnvironmentVariablesSpec — assembles the environment-variable
dictionary.ProcessResourcePolicySpec — assembles ProcessResourcePolicy.UserCredentialSpec — assembles UserCredential.Invariant: the builder is single-use-per-stage. Calling Build()
produces a model; calling Build() again produces a new model
(mutations between calls do not retroactively change models already
produced). The builder is not required — every model also exposes
a public constructor — but it is the advanced path for configurations
that need argument escaping, credential staging, or resource-policy
callback flows.
Construction (Stage 1) returns an immutable, value-equal
ProcessConfiguration. The model is the single thing that travels from
the caller's code into the library's execution layer. It is read by the
invoker and by every cross-cutting concern in the pipeline; nothing in
the library is allowed to mutate it after construction.
Key types in this stage:
ProcessConfiguration — the top-level model that the invoker
consumes. Owns the executable path, arguments, working directory,
environment, resource policy, timeout policy, and optional credential.ProcessExitConfiguration — how the process should be terminated if
it does not exit on its own.UserCredential — optional credentials for processes that require
them.Invariants:
Build() returns.The invoker is the abstraction that turns a ProcessConfiguration into
a running process. The default implementation, ProcessInvoker, takes
a configuration, a ProcessExitConfiguration, and a cancellation
token; creates an IExternalProcess via the configured
IExternalProcessFactory; runs it; and returns a typed result. Three
methods exist on the interface, one per execution mode:
ExecuteAsync — returns ProcessResult (exit code only).ExecuteBufferedAsync — returns BufferedProcessResult (exit code
plus captured StandardOutput / StandardError as strings).Invariants:
IExternalProcess it created — but it does
not dispose the configuration or the result. The caller owns
those.Start and exit (that is what IExternalProcess is for).The invoker returns a result. The result is the canonical return value of an invocation. It is immutable.
The result carries the Process Invocation Context state that the
cross-cutting concerns wrote to: the runner configuration (if any), the
captured output, the exit code, and any validation outcome.
The four-stage data-flow is invariant. What varies between the three patterns is which stages the caller drives directly and which the library hides.
flowchart LR
subgraph CLIRUN["CliRun (static facade)"]
C1[Stage 1: Builder] --> C2[Stage 2: Model]
C2 --> C3[Stage 3: Invoker]
C3 --> C4[Stage 4: Result]
end
subgraph INVOKER["IProcessInvoker (DI-friendly)"]
I1[Stage 2: Model<br/>caller-built] --> I2[Stage 3: Invoker<br/>resolved from DI]
I2 --> I3[Stage 4: Result]
end
subgraph EXT["IExternalProcess (power user)"]
E1[Stage 2: Model<br/>caller-built] --> E2[Stage 3a: IExternalProcessFactory.Create]
E2 --> E3[Stage 3b: IExternalProcess.StartAsync]
E3 --> E4[Stage 3c: caller interacts]
E4 --> E5[Stage 3d: IExternalProcess.CaptureResult]
E5 --> E6[Stage 4: Result]
end
CliRun — drives every stage for the callerCliRun is a static façade. The caller hands it the executable, the
arguments, and any optional knobs; CliRun runs Stages 1–3 internally
using sensible defaults and returns the result.
// Caller writes one line. CliRun does the rest.
BufferedProcessResult result = await CliRun.RunBufferedAsync(
"dotnet", "--version");
Internally, CliRun constructs a ProcessConfiguration via init
construction from the method arguments (Stage 1 → Stage 2), constructs
a fresh ExternalProcessFactory with a default FilePathResolver
(there is no longer any shared, configurable static state — a new factory is
allocated per call), delegates to a ProcessInvoker (Stage 3), and returns the result
(Stage 4).
The caller never sees the builder, the model, or the invoker. This is the trade-off documented in Choosing your Invocation Pattern: zero boilerplate, zero testability, zero customisation.
IProcessInvoker — caller provides Stages 1–2; library runs 3–4IProcessInvoker is the abstraction for DI-centric applications. The
caller assembles the ProcessConfiguration themselves (Stage 1 →
Stage 2), resolves the invoker from the container, and hands the
configuration to the invoker (Stage 3). The invoker runs the process
and returns the result (Stage 4).
IProcessInvoker invoker = provider.GetRequiredService<IProcessInvoker>();
ProcessConfiguration config = new(
"dotnet", "--info", true);
BufferedProcessResult result = await invoker.ExecuteBufferedAsync(
config, ProcessExitConfiguration.CreateGraceful());
The caller now has control over the model — they can construct it
explicitly, share it across invocations, and inject a mock invoker for
unit tests. They have given up CliRun's one-liner simplicity, in
exchange for testability and per-call configuration.
IExternalProcess — caller provides Stages 1–2; library and caller share 3; library returns 4IExternalProcess is the power-user API. The caller still assembles
the configuration (Stages 1–2) but the "Invoker" stage is split into
multiple sub-stages that the caller drives:
factory.CreateExternalProcess(config) — Stage 3a: the library
creates the IExternalProcess.process.StartAsync() — Stage 3b: the library starts the OS
process.StandardInput, read from StandardOutput, send signals, check
status. This is the gap that no other pattern exposes.process.CaptureBufferedResultAsync() — Stage 3d: the library captures the
result and returns it.IExternalProcessFactory factory =
provider.GetRequiredService<IExternalProcessFactory>();
ProcessConfiguration config = new(
"dotnet", "--runtime", true);
using IExternalProcess process = factory.CreateExternalProcess(config);
await process.StartAsync(CancellationToken.None);
// ← The caller can pipe input, monitor progress, etc. here.
BufferedProcessResult result = await process.CaptureBufferedResultAsync(
CancellationToken.None);
This is the only pattern that lets the caller do work between
Start and Capture. The trade-off is significant: the caller owns
the full using chain, the cancellation tokens, and the disposal
contract for both the configuration and the IExternalProcess.
| Pattern | Stage 1 (Construction) | Stage 2 (Model) | Stage 3 (Invoker / Process) | Stage 4 (Result) |
|---|---|---|---|---|
CliRun |
Library (implicit) | Library (implicit) | Library (default invoker) | Library returns |
IProcessInvoker |
Caller | Caller | Library (resolved from DI) | Library returns |
IExternalProcess |
Caller | Caller | Library + caller share | Library returns |
The Process Invocation Pipeline is the layered pattern CliInvoke
uses to execute cross-cutting concerns around the core process
orchestration. The pipeline wraps the IExternalProcess invocation
and lets each concern modify the configuration before execution, or
the result after execution, without entangling those concerns with
the core Start → Run → Capture sequence.
The state that travels through the pipeline is the Process
Invocation Context: a conceptual bundle of the
ProcessConfiguration (input), the execution mode (Basic, Buffered,
or Piped), the runner configuration (if any), and the resulting
ProcessResult (output). Each cross-cutting concern reads from and
writes to this context, and the context is the single source of
truth that flows from start to finish.
┌─────────────────────────────────────────────────────────┐
│ Process Invocation Context │
│ ┌──────────────────┐ ┌────────────────────┐ │
│ │ Process │ │ Process Result │ │
│ │ Configuration │ ──────► │ (built up by │ │
│ │ (immutable) │ │ each stage) │ │
│ └──────────────────┘ └────────────────────┘ │
│ Execution mode: Basic / Buffered │
│ Runner configuration: optional │
└─────────────────────────────────────────────────────────┘
│
┌─────────────────────────┼──────────────────────────────┐
│ ▼ │
│ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Path │ │ Runner │ │ Result │ │
│ │ Resolver│→ │ Wrap │→ │ Validator │ ... → │
│ └─────────┘ └────────────┘ └──────────────┘ │
│ ▲ │
│ │ │
│ ┌──┴─────────┐ │
│ │ IExternalProcess │ ← Core execution │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────┘
CliInvoke ships three first-class cross-cutting concerns. Each is backed by a public interface so that callers can replace or extend the default implementation.
IFilePathResolverIFilePathResolver answers the question “where on disk is this
executable?”. The default implementation, FilePathResolver,
consults PATH first, then falls back to directory recursion. The
resolver is consulted by ExternalProcessFactory when the
ProcessConfiguration references a bare executable name rather than
an absolute path.
Customising the resolver is the supported way to add new lookup
strategies — for example, looking up executable paths in a vendored
toolchain directory. See
GLOSSARY.md § 1 — Resolution order rationale
for the performance contract that custom resolvers must respect.
IRunnerConfigurationFactoryIRunnerConfigurationFactory answers the question “if the process
needs to be run through another process (e.g. PowerShell, CMD, or a
custom shell), how do we build a new configuration that does that?”.
CliInvoke.Specializations provides implementations that wrap
configurations for Windows PowerShell and CMD; custom implementations
can wrap configurations for any other runner.
The runner factory is consulted by the invoker when the configuration
specifies that it must be executed through a runner. It rewrites the
ProcessConfiguration accordingly; the rest of the pipeline then
runs the rewritten configuration as if it were the original.
IProcessResultValidatorIProcessResultValidator<TProcessResult> answers the question
“is this result acceptable, or should we raise a failure?”. The
default implementation (ProcessResultValidator<TProcessResult>)
evaluates a set of self-describing ValidationRule<TProcessResult>
rules. Validate returns a bool (all rules pass), while
GetValidationFailures returns the per-rule ValidationFailure<TProcessResult>
instances so callers can surface detailed, rule-by-rule messages. The
invoker raises a ProcessNotSuccessfulException when a validator
configured for "must succeed" mode returns invalid. The post-exit
validation middleware (UsePostExitValidation) consumes the same
validator stack and throws ProcessValidationException with the
joined per-rule failure messages.
Custom validators are the supported way to add domain-specific post-execution checks — for example, asserting that captured output matches a regex, or that a specific environment variable was forwarded.
The pipeline wraps the IExternalProcess stage. The path resolver
runs before the process is created (it resolves the executable
path that ExternalProcess will use); the runner factory runs
before the process is created (it rewrites the configuration
when a runner is in play); the result validator runs after the
process exits (it inspects the result before it is returned).
sequenceDiagram
participant Caller
participant Invoker as ProcessInvoker
participant Pipeline as Pipeline (path → runner → core)
participant Process as IExternalProcess
participant Validator as IProcessResultValidator
Caller->>Invoker: ExecuteAsync(config)
Invoker->>Pipeline: Create IExternalProcess(config)
Pipeline->>Pipeline: IFilePathResolver.Resolve(config)
Pipeline->>Pipeline: IRunnerConfigurationFactory.MaybeRewrite(config)
Pipeline->>Process: new IExternalProcess(rewritten config)
Pipeline-->>Invoker: IExternalProcess
Invoker->>Process: StartAsync()
Process-->>Invoker: started
Invoker->>Process: WaitForExit / Capture
Process-->>Invoker: result
Invoker->>Validator: Validate(result)
Validator-->>Invoker: ok | throw
Invoker-->>Caller: result
The pipeline keeps the core execution path (Start → Run →
Capture → Dispose) free of cross-cutting logic. A new
cross-cutting concern — for example, distributed-tracing context
propagation, or per-call metric emission — can be added by writing
a new interceptor without modifying the invoker or the
IExternalProcess interface. Callers who need to disable a
default concern can do so by registering an alternative
implementation through the CliInvoke package's DI helpers
(namespace CliInvoke.Extensions).
The pipeline exposes three public extension points. All three are documented in the API Reference, and the cross-cutting concerns page in the API reference is the canonical place to look up method signatures.
| Concern | Public interface | Default implementation | Replace via |
|---|---|---|---|
| Path resolution | IFilePathResolver |
FilePathResolver |
UseCustomFilePathResolver<T>(ServiceLifetime) |
| Runner wrapping | IRunnerConfigurationFactory |
(none — opt-in) | RunnerConfigurationFactory (CliInvoke.Specializations) |
| Result validation | IProcessResultValidator<TProcessResult> |
ProcessResultValidator<TProcessResult> |
AddCustomResultValidators(...) |
The core execution layer — IExternalProcess, ProcessInvoker,
ExternalProcessFactory — is not intended to be replaced for
ordinary use. If you find yourself wanting to override the core
executor, treat that as a signal that you may need a new pattern
rather than a pipeline extension; open an issue describing the
scenario.
CliRun, IProcessInvoker,
and IExternalProcess.ProcessConfiguration, the builder lifecycle, and the
default-value appendix.IExternalProcess, and the result.DESIGN_PATTERNS.md — full API-level detail for
the three invocation patterns.GLOSSARY.md — the canonical glossary,
including the definitions of the Process Invocation Pipeline and
the Process Invocation Context.