10. Consuming Contracts Without Process Implementations
Business code should be able to consume commands and observations without owning process startup or concrete parsing. The current consumer .Contracts projects make that boundary explicit.
This is also the requested separation of messages into a distinct consumer project: events live alongside the other values needed to express and observe an operation.
What belongs in a consumer contract
A tool's contracts assembly owns generated commands, typed environments and converters, output event records, observations, result records and its runner interface.
The implementation owns concrete runners, parsers, strategies, collectors, generated builders and the generated client. A result type can belong to contracts while the collector that produces it belongs to the implementation.
The shared BinaryWrapper.Contracts assembly owns ICliCommand, ICliEnvironment, OutputEvent, parser/collector contracts, execution interfaces and process values. SemanticVersion and Result remain small shared dependencies.
The dependency direction
A business project references Vagrant.Contracts and receives an IVagrantRunner. An application entry point references the implementation and supplies VagrantRunner.
A fake implementation of IVagrantRunner can test business policy without parsers or executables. A fake IProcessRunner instead tests the real parsing and execution path. These substitutions answer different questions.
A contracts-only business component
The following complete library example requires only Vagrant.Contracts and its shared dependencies. It collects observations but does not declare a machine deployment successful merely because readiness was seen.
using FrenchExDev.Net.BinaryWrapper.Lib;
using FrenchExDev.Net.Vagrant;
public sealed class ReadinessObserver(IVagrantRunner runner)
{
public async Task<IReadOnlyList<string>> ObserveAsync(
SemanticVersion version,
CancellationToken cancellationToken = default)
{
var command = new VagrantUpCommand(version)
{
MachineReadable = true,
};
var observed = new List<string>();
await foreach (var item in runner.StreamAsync(command, cancellationToken))
{
if (item is VagrantMachineBooted ready)
observed.Add(ready.MachineName);
if (item is VagrantCommandExited { ExitCode: not 0 } failed)
throw new InvalidOperationException(
$"Vagrant invocation failed: {failed.ExitCode}");
}
return observed.AsReadOnly();
}
}using FrenchExDev.Net.BinaryWrapper.Lib;
using FrenchExDev.Net.Vagrant;
public sealed class ReadinessObserver(IVagrantRunner runner)
{
public async Task<IReadOnlyList<string>> ObserveAsync(
SemanticVersion version,
CancellationToken cancellationToken = default)
{
var command = new VagrantUpCommand(version)
{
MachineReadable = true,
};
var observed = new List<string>();
await foreach (var item in runner.StreamAsync(command, cancellationToken))
{
if (item is VagrantMachineBooted ready)
observed.Add(ready.MachineName);
if (item is VagrantCommandExited { ExitCode: not 0 } failed)
throw new InvalidOperationException(
$"Vagrant invocation failed: {failed.ExitCode}");
}
return observed.AsReadOnly();
}
}This component's return value is intentionally an observation list. Distinct-machine aggregation or operation-specific result semantics can be implemented separately. An exception from execution is also distinct from a nonzero terminal observation.
For a business workflow with expected failure values, define a domain result policy around those observations instead of copying process errors directly into every domain type.
Application composition
The host can construct CommandExecutor with an IBinaryResolver and process transport, then pass that executor to VagrantRunner. The runner accepts an optional binary identifier and output-parser factory.
The index's complete example demonstrates this composition with a fake transport. A production host replaces the transport and provides a truthful binding.
Business code still constructs commands directly. Fluent builders and clients are conveniences owned by the implementation; contracts-only use does not require them.
Build-time references are not runtime dependencies
The contracts csproj supplies help JSON to the command generator and the C# environment catalogue to Design.Lib's analyzer target. These references use OutputItemType="Analyzer", ReferenceOutputAssembly="false" and PrivateAssets="all".
The analyzer contributes source to compilation. It does not become an ordinary runtime reference to the Design pipeline. The environment generator target is explicitly netstandard2.0.
After packing, inspect both assembly references and package dependencies. A contracts package must not pull in Builder, concrete parsers, process implementations or the Design executable through an unintended transitive runtime edge.
The migration guide records a package-validation snapshot, but an earlier passing snapshot is not a substitute for checking a new packaging change.
Namespace and binary compatibility
The consumer namespace remains unchanged after moving types to .Contracts. This lets source code retain familiar imports, while csproj references express the improved dependency boundary.
The implementation generator emits type-forwarding attributes for moved public contracts. Forwarders preserve reachable type identities through the former containing assembly, subject to normal assembly resolution.
They do not guarantee compatibility with every package mix. Deploy the implementation, contracts and shared dependencies at compatible versions, and test an existing compiled consumer when binary compatibility is required.
Choosing the right test boundary
Test business reactions with an injected runner double: readiness followed by failure, no readiness on a successful command, cancellation or unknown observations. This verifies policy independently of transport.
Test parser integration with a fake transport and the real concrete runner. This checks command-format selection, metadata and terminal events.
Test live acquisition or execution only when their actual dependencies are available and the operation is intentional. These checks must not be confused with contracts-only unit tests.
This separation lets an application consume several tool contracts while keeping its own logic independent of concrete process implementations.
Source snapshot
Source inspection: 2 October 2026. These implementation paths are relative to Net/FrenchExDev in the local ecosystem checkout. The v2 changes are largely uncommitted; these paths identify inspected code, without claiming that public branch links contain it.
BinaryWrapper/doc/CONTRACTS.mdBinaryWrapper/src/FrenchExDev.Net.BinaryWrapper.Contracts/CommandRunnerContracts.csVagrant/src/FrenchExDev.Net.Vagrant.Contracts/IVagrantRunner.csVagrant/src/FrenchExDev.Net.Vagrant/VagrantRunner.csBinaryWrapper/src/FrenchExDev.Net.BinaryWrapper.SourceGenerator/ContractTypeForwarderEmitter.cs
Previous · Series index · Next