Skip to main content

Command Palette

Search for a command to run...

One Rule, 10+ Packages: How I Designed the PowerCSharp Package Topology

Modular NuGet packages are not free. Here is exactly what they cost, why I paid it, and the single rule that keeps 13 packages coherent.

Updated
6 min read
One Rule, 10+ Packages: How I Designed the PowerCSharp Package Topology

The Problem

A NuGet package is a unit of deployment and versioning. When you publish a package, you make a promise: the public API in this package is stable until the major version changes. Every package you publish multiplies the surface area of that promise.

The question I had to answer before writing a line of code for PowerCSharp: how many packages, and where are the boundaries?

The wrong answer in both directions is obvious. One mega-package (PowerCSharp) means every consumer inherits every dependency — including the BitFaster.Caching reference they don't need if they only use string extensions. Fifty packages means fifty version vectors to manage, fifty CI pipelines, fifty sets of release notes.

I settled on thirteen (for now?!). Here is why that number, and why those boundaries.

The Dependency Direction Rule

Before I explain the package count (that, actually can be 9), I need to explain the rule that makes it manageable.

The rule: dependency direction is unidirectional and upward. Higher-layer packages may depend on lower-layer packages. Lower-layer packages may not depend on higher-layer packages. No exceptions.

This is enforced by project references, not by convention. If you attempt to add a downward reference, the build fails — circular dependency error. The compiler enforces the architecture.

Layer 3 (Pluggable Features)
    ↓ depends on
Layer 2 (Features Framework)
    ↓ depends on
Layer 1 (Core Library)

FORBIDDEN: Layer 1 → Layer 2
FORBIDDEN: Layer 2 → Layer 3
FORBIDDEN: Layer 1 → Layer 3 (skipping Layer 2)

This rule has one significant implication: you can replace any layer without touching the layers below it. You can remove the entire Features framework from your project, and the Core layer is unaffected. You can add a new cache provider without changing the Features engine. The layers are truly independent.

The Three Version Families

Thirteen packages, but three version vectors:

┌─────────────────────────────────────┐
│ Family: core          PowerCSharpVersion = 2.0.2        │
│                                                         │
│   PowerCSharp.Core                                      │
│   PowerCSharp.Extensions                                │
│   PowerCSharp.Extensions.AspNetCore                     │
│   PowerCSharp.Utilities                                 │
│   PowerCSharp.Helpers                                   │
│   PowerCSharp.Compatibility                             │
├─────────────────────────────────────┤
│ Family: features      PowerCSharpFeaturesVersion = 1.0.1│
│                                                         │
│   PowerCSharp.Features.Abstractions                     │
│   PowerCSharp.Features                                  │
│   PowerCSharp.BuiltInFeatures                           │
├─────────────────────────────────────┤
│ Family: cache         PowerCSharpFeatureCacheVersion    │
│                       = 1.3.1                           │
│                                                         │
│   PowerCSharp.Feature.Cache.Abstractions                │
│   PowerCSharp.Feature.Cache                             │
│   PowerCSharp.Feature.Cache.BitFaster                   │
│   PowerCSharp.Feature.Cache.Disk                        │
└─────────────────────────────────────┘

Version families are not an organizational convenience — they are a semantic commitment. Packages within a family move together because they have coupled interfaces. A change to ICacheService in Feature.Cache.Abstractions requires a corresponding version bump in Feature.Cache, Feature.Cache.BitFaster, and Feature.Cache.Disk simultaneously. Grouping them into one version variable (PowerCSharpFeatureCacheVersion) in Directory.Build.props ensures this is a single mechanical change, not a coordination problem across four separate version files.

The feature family and the cache family version independently because a change to the Features engine (PowerCSharp.Features) does not change the cache contracts (ICacheService), and vice versa. Breaking them into two version families means a cache provider update does not force consumers to update their features engine dependency.

The Package Boundaries — And Why Each Exists

@startuml PowerCSharp Package Dependencies
!theme plain
skinparam packageStyle rectangle
skinparam ArrowColor #444444
skinparam PackageBorderColor #888888

package "Core Library (v2.0.2)" #E8F4FD {
  [Core] #BDE3FA
  [Extensions] #BDE3FA
  [Utilities] #BDE3FA
  [Helpers] #BDE3FA
  [Compatibility] #BDE3FA
  [Extensions.AspNetCore] #BDE3FA
}

package "Features Framework (v1.0.1)" #FEF9E7 {
  [Features.Abstractions] #FDF2C0
  [Features] #FDF2C0
  [BuiltInFeatures] #FDF2C0
}

package "Cache Feature Family (v1.3.1)" #E9F7EF {
  [Feature.Cache.Abstractions] #A9DFBF
  [Feature.Cache] #A9DFBF
  [Feature.Cache.BitFaster] #A9DFBF
  [Feature.Cache.Disk] #A9DFBF
}

[Extensions] --> [Core]
[Utilities] --> [Core]
[Helpers] --> [Core]
[Compatibility] --> [Core]
[Extensions.AspNetCore] --> [Extensions]
[Features] --> [Features.Abstractions]
[BuiltInFeatures] --> [Features]
[Feature.Cache] --> [Feature.Cache.Abstractions]
[Feature.Cache] --> [Features.Abstractions]
[Feature.Cache.BitFaster] --> [Feature.Cache.Abstractions]
[Feature.Cache.Disk] --> [Feature.Cache.Abstractions]
[Feature.Cache.Disk] --> [Core]

@enduml

Why Core is its own package:
PowerCSharp.Core defines the contracts that every other package builds on: IDynamicFilter<T>, IOrderProvider<T>, centralized models. It has zero NuGet dependencies. If you are building a library that needs to implement one of these contracts, you reference only Core — not the implementation packages. Core is the stable foundation that never moves unnecessarily.

Why Extensions and Utilities and Helpers are separate packages:
These three packages have different dependency profiles. Extensions adds no external dependencies. Utilities adds lightweight validation. Helpers adds JSON serialization and cryptography. A consumer who only needs string extension methods should not carry a JSON serialization library. Separating them at the package level enforces this.

Why Compatibility is its own package:
The compatibility package bridges .NET Framework 4.6.2+ to modern .NET. It carries System.Web references. No modern .NET application should reference System.Web. Isolating it in a separate package means modern consumers never see it in their dependency graph.

Why Features.Abstractions is separate from Features:
PowerCSharp.Features.Abstractions is the contract package. If you are building a feature module (your own IFeatureModule implementation), you reference only the abstractions — not the engine. This is the isolation pattern: implementors of an interface should not be forced to reference the framework that uses the interface.

Why the Cache providers are separate packages:
PowerCSharp.Feature.Cache.BitFaster references BitFaster.Caching. PowerCSharp.Feature.Cache.Disk references nothing. If they were in one package, every consumer would carry BitFaster.Caching regardless of whether they use the in-memory provider. Provider isolation is not optional — it is the reason the package boundary exists.

The Cost

Thirteen packages cost something. The CI/CD pipeline must version, build, pack, and publish all thirteen packages. The Directory.Build.props file must define three version variables and map them to the right packages. The solution files must include all projects.

More significantly: semantic versioning promises multiply by the number of packages. Thirteen packages means thirteen public API surfaces to maintain. A breaking change to ICacheService requires a major version bump to the entire cache family (four packages simultaneously), which requires every consumer to update four package references.

This is the price of modularity. It is worth paying when the alternative is consumers carrying dependencies they do not need and cannot remove. It is not worth paying for every system — a single-team internal library with no public consumers has no reason to be thirteen packages.

For a public NuGet ecosystem that will be consumed by teams with different needs, different .NET versions, and different dependency constraints, the price is correct.

The Single Decision That Simplifies Everything

If I had to reduce the thirteen-package topology to one principle, it is this: packages are boundaries around dependency groups, not around features.

A feature (caching, CORS, dynamic LINQ) can span packages: abstractions, implementation, and provider. A package contains exactly the code that shares a dependency profile. If two modules need the same set of external dependencies, they can be in the same package. If they have different dependency profiles, they must be in different packages.

This principle means the package count is not arbitrary — it is determined by the dependency graph, not by the feature list.

Where would you draw the boundaries differently?

PowerCSharp Technical Blog

Part 1 of 2

PowerCSharp is a comprehensive library of extension methods, utilities, and helper classes designed to enhance your C# development experience. Built by a senior C# architect with 20+ years of experience, this library provides practical, well-tested solutions for common programming challenges.

Up next

Introducing PowerCSharp: An Ecosystem Built on 20 Years of C# Architecture

Not another extension method library. A layered, modular .NET ecosystem designed around one principle: your application should never be hostage to its dependencies.

More from this blog

P

PowerCSharp

2 posts

PowerCSharp is a comprehensive library of extension methods, utilities, and helper classes designed to enhance your C# development experience.

Built by a senior C# architect with 20+ years of experience, this library provides practical, well-tested solutions for common programming challenges.