en

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 INuGetFeed to 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. No NuGet.Protocol dependency. We chose raw HTTP because NuGet.Protocol is 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 a Materialize method 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 so pcpm.Core can 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:

  1. Put(bytes, id, version) → hash — store the bytes, return the SHA-256. Idempotent: re-storing the same bytes is a no-op.
  2. Has(hash) → bool — is this hash present in the store?
  3. Materialize(hash, targetPath) → void — hardlink the file tree rooted at <hash>/extracted/<id>/<version>/ into targetPath. On Windows this is CreateHardLinkW. 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.