Architecture
Three layers, one responsibility each — how PCPM is structured and why.
PCPM is structured as three layers, each with a single responsibility, all wired through constructor injection. This page walks through the layers and explains the design payoff.
The three layers
┌─────────────────────────────────────────────────────────────────┐
│ pcpm.Cli Spectre.Console.Cli commands (one per verb) │
│ ───────── thin orchestration only, no business logic │
└────────────────────────┬────────────────────────────────────────┘
│ DI (Microsoft.Extensions.DependencyInjection)
┌────────────────────────┴────────────────────────────────────────┐
│ pcpm.Core Domain models, abstractions, pure logic │
│ ──────── DependencyResolver, no I/O │
└────────────────────────┬────────────────────────────────────────┘
│
┌────────────────────────┴────────────────────────────────────────┐
│ pcpm.Infrastructure Implementations: │
│ ────────────────── • NuGetFeed (raw HTTP, no NuGet.Protocol) │
│ • PackageStore (content-addressable) │
│ • HardlinkCreator (Win32 P/Invoke) │
│ • CpmFileService, ProjectFileService, │
│ LockfileService (XML/JSON I/O) │
│ • PhysicalFileSystem, ProcessRunner │
└─────────────────────────────────────────────────────────────────┘
pcpm.Cli
The CLI layer is one command class per verb, plus the bootstrap
that wires up DI. Each command takes its dependencies through
constructor injection. The dotnet run … pcpm install flow is
Program → InstallCommand → (CpmFileService, ProjectFileService, ProjectDiscovery, NuGetFeed, PackageStore, LockfileService, DependencyResolver, ProcessRunner, IAnsiConsole).
The CLI layer has no business logic. If you find yourself adding
domain code to a command, push it down into pcpm.Core (if it’s
pure) or pcpm.Infrastructure (if it needs I/O).
pcpm.Core
The core layer holds domain models, abstractions, and pure logic.
The most important class here is
pcpm.Core.Services.DependencyResolver — the BFS union-of-constraints
resolver described in Dependency resolution.
pcpm.Core has zero I/O dependencies. That means:
- Every class here is unit-testable without a temp directory, a fake file system, or a network stub.
- Every dependency is a constructor parameter, expressed as an
interface (
INuGetFeed,IPackageStore,IFileSystem). - If you want to swap an implementation, you don’t touch this
layer — you provide a different
INuGetFeedto the DI container.
pcpm.Infrastructure
The infrastructure layer is the only place that touches the disk or the network. It contains:
NuGetFeed— raw HTTP client for the NuGet v3 protocol. NoNuGet.Protocoldependency. We chose raw HTTP becauseNuGet.Protocolis 4MB of transitive code and we only need three endpoints.MultiFeedNuGetFeed— aggregator that queries multiple feeds in parallel and merges the results.PackageStore— the content-addressable store. Hashes packages, dedupes by hash, exposes aMaterializemethod that hardlinks a stored package into~/.nuget/packages.HardlinkCreator— P/Invoke into the platform’s link-creation API. NTFS hardlinks on Windows, hardlinks elsewhere.CpmFileService,ProjectFileService,LockfileService— XML and JSON I/O for the manifest files.PhysicalFileSystem,ProcessRunner— thin wrappers around the BCL that exist sopcpm.Corecan be tested without the real file system or process APIs.
Why this layout
The three-layer split is not novel. It’s a fairly standard “domain in the middle, infrastructure on the outside” model. The payoff is concrete:
- Core is unit-testable in isolation. No temp directories, no file system stubs, no async I/O. The DependencyResolver tests run in milliseconds.
- Infrastructure has a small, well-understood surface. All the I/O lives in 8 classes, each with a clear interface. If something fails on Windows-only code paths, the test instrumentation is local.
- CLI is a one-verb-per-class orchestration layer. Adding a command is mechanical: register a new class in DI, write the command, done.
The bigger payoff is future-proofing. The abstractions in
pcpm.Core.Abstractions are the contract. If you want a
different store (in-memory? S3-backed? served from a corporate
artifact server?), you implement IPackageStore and inject it.
The resolver doesn’t care where the bytes come from.
The store, in detail
The content-addressable store has three operations:
- Put(bytes, id, version) → hash — store the bytes, return the SHA-256. Idempotent: re-storing the same bytes is a no-op.
- Has(hash) → bool — is this hash present in the store?
- Materialize(hash, targetPath) → void — hardlink the file
tree rooted at
<hash>/extracted/<id>/<version>/intotargetPath. On Windows this isCreateHardLinkW. On POSIX,link(2). On cross-volume, fall back to a recursive copy.
A dotnet build run does not modify the store. The store is
immutable: every entry has a content hash, and the bytes at that
hash never change. The only way to “remove” something is to
delete the directory (or, in the future, run
pcpm store prune to drop hashes that no lockfile references).
DI wiring
The DI container is configured in Program.cs:
services
.AddSingleton<IFileSystem, PhysicalFileSystem>()
.AddSingleton<IProcessRunner, ProcessRunner>()
.AddSingleton<ICpmFileService, CpmFileService>()
.AddSingleton<IProjectFileService, ProjectFileService>()
.AddSingleton<IProjectDiscovery, ProjectDiscoveryService>()
.AddSingleton<INuGetFeed, MultiFeedNuGetFeed>()
.AddSingleton<IPackageStore, PackageStore>()
.AddSingleton<IHardlinkCreator, HardlinkCreator>()
.AddSingleton<ILockfileService, LockfileService>()
.AddSingleton<IWorkspaceLocator, WorkspaceLocator>()
.AddSingleton<IDependencyResolver, DependencyResolver>()
.AddSingleton<IAnsiConsole>(_ => AnsiConsole.Console);
Everything is a singleton. PCPM is short-lived: each command runs to completion in a single process, and the lifetime is the process. Singletons are the natural choice.
The MSBuild companion
pcpm.MsBuild is a small companion project — a NuGet package
that ships a RelinkBin MSBuild task. When you install the
package into a project, the task re-hardlinks the build output
to the store after dotnet build finishes. This is a small win
on disk usage for big solutions, and an even bigger win for CI
caches: the bin/ directory doesn’t grow with each build.
It’s opt-in. The pcpm.MsBuild package is published separately
from pcpm; install it once per workspace if you want it.