
Swap Out a Vulnerable NuGet Package Without Touching Your Domain Code in .NET
Author - Abdul Rahman (Bhai)
NuGet
6 Articles
Table of Contents
What we gonna do?
A security alert lands in your inbox. A NuGet package your team added six months ago just received a high-severity CVE (Common Vulnerabilities and Exposures). Your first instinct is to update the version — but the package is referenced in twelve different classes, the API changed between versions, and your tests cover maybe half the usage. Sound familiar?
The problem is not the vulnerability itself. The problem is that the NuGet dependency has leaked directly into your domain code. Every file that imports the package's namespace is a file you have to touch when you need to change that dependency. Without a seam to swap implementations, every replacement becomes a big-bang refactoring.
There are three structured strategies for safely dealing with problematic NuGet dependencies: Wrap, Replace, and Rewrite. Together they form a progressive risk ladder — each building on the last — that lets you isolate, swap, and eliminate any NuGet dependency without ever touching your domain code again. In this article, you will learn how each strategy works, when to apply it, and how to implement all three using a concrete .NET example.
Why we gonna do?
When a NuGet Package Leaks Into Your Domain, Every Change Becomes Risky
Consider this production code found in an order processing service. The ShippingCalculator type from a third-party package is injected directly into the domain service:
// ❌ The NuGet type leaks directly into your domain
using LegacyShippingLib; // the vulnerable package
public class OrderService
{
private readonly ShippingCalculator _calculator; // NuGet type in domain
public OrderService(ShippingCalculator calculator)
{
_calculator = calculator;
}
public decimal GetShippingCost(Order order)
=> _calculator.Calculate(order.WeightKg, order.DestinationPostcode);
}
To replace LegacyShippingLib, you must change OrderService — and QuoteService, CheckoutService, and every other class that holds the same direct reference. In a mature codebase, that can mean dozens of files, all touching production-critical domain logic simultaneously.
The Cost of Inaction Compounds Too
Not acting on a vulnerability carries its own cost. A high-severity CVE left unaddressed exposes your application to active exploits — and when an incident does occur, an emergency 2 a.m. patch typically costs three to five times more than the same work done as a planned sprint task. There are also slower-burning costs: packages with active CVEs draw scrutiny during compliance audits, stall enterprise procurement deals, and signal technical debt to candidates who inspect your stack before they accept an offer.
Beyond security, NuGet packages become liabilities for other reasons too — a maintainer abandons the project with no further security patches, licensing terms shift from open source to commercial, or the package falls so far behind its own dependencies that transitive vulnerabilities begin to accumulate. In every case, having a structured exit strategy costs far less than reacting to a crisis without one.
Three Strategies, Ordered by Risk and Effort
The three strategies scale with the severity of the problem and the investment available:
- Wrap — Create an abstraction (an Anti-Corruption Layer) around the existing dependency. The problematic package is not upgraded or removed — it is isolated behind an interface so the problem stops spreading and you gain a seam for future replacement. Use this when the vulnerability is low to medium severity, when no drop-in replacement exists yet, or when you are on a constrained timeline.
- Replace — With the abstraction already in place, swap in a new adapter backed by a safer NuGet package. Your domain code does not change — only one line in your DI registration. Use this when a suitable replacement exists and the vulnerability is high or critical.
- Rewrite — Implement the behavior yourself, entirely without a third-party package. Use this when the package has been abandoned with no successor, the feature scope is small and well-understood, or the licensing risk outweighs the implementation cost.
Each strategy depends on the one before it. Replace requires a Wrap already in place. Rewrite benefits from the same abstraction. You can always start with Wrap, buy time, and move to Replace or Rewrite later — without going back to touch domain code a second time.
How we gonna do?
The Scenario: A Vulnerable Shipping Cost Calculator
Your OrderService, QuoteService, and CheckoutService all calculate shipping costs by directly consuming LegacyShippingLib, a third-party NuGet package just flagged with a high-severity CVE. Your goal: isolate the package, then replace it — without touching any of those three services.
Step 1 — Wrap: Define the Abstraction and Create an Adapter
Create an IShippingCostCalculator interface in a project that has no NuGet references at all. The interface is defined entirely in your application's own language — no types from the external package appear here:
// Project: OrderManagement.Application.Abstractions
// No NuGet references — pure domain contract
namespace OrderManagement.Application.Abstractions;
public interface IShippingCostCalculator
{
decimal Calculate(decimal weightKg, string destinationPostcode);
}
Next, create a dedicated adapter project. This project is the only one allowed to reference LegacyShippingLib. Everything else in your solution depends on the interface — not on this project:
// Project: OrderManagement.Infrastructure.Shipping.Legacy
// Only project with a <PackageReference> to LegacyShippingLib
using LegacyShippingLib;
using OrderManagement.Application.Abstractions;
namespace OrderManagement.Infrastructure.Shipping.Legacy;
// The adapter instantiates the third-party type internally.
// Nothing outside this project ever sees ShippingCalculator.
public sealed class LegacyShippingAdapter : IShippingCostCalculator
{
private readonly ShippingCalculator _legacyCalculator = new();
public decimal Calculate(decimal weightKg, string destinationPostcode)
=> _legacyCalculator.Calculate(weightKg, destinationPostcode);
}
Update every service that previously referenced ShippingCalculator directly to inject IShippingCostCalculator instead. This is a one-time change — after it, your domain code never mentions the NuGet package again:
// ✅ Domain code now depends only on the abstraction
using OrderManagement.Application.Abstractions;
public class OrderService
{
private readonly IShippingCostCalculator _shippingCalculator;
public OrderService(IShippingCostCalculator shippingCalculator)
{
_shippingCalculator = shippingCalculator;
}
public decimal GetShippingCost(Order order)
=> _shippingCalculator.Calculate(order.WeightKg, order.DestinationPostcode);
}
Register the adapter in your DI container:
// Program.cs — Wrap: register the legacy adapter
builder.Services.AddScoped<IShippingCostCalculator, LegacyShippingAdapter>();
The CVE is still present in LegacyShippingLib, but it is now contained to a single adapter project. It cannot spread further. You have bought yourself time to find and validate a replacement without breaking anything.
Step 2 — Replace: Swap in a Safer Package
With the abstraction in place, replacing the underlying package requires zero changes to OrderService, QuoteService, or CheckoutService. Create a new adapter project — isolated the same way — backed by the replacement NuGet package:
// Project: OrderManagement.Infrastructure.Shipping.Modern
// Only project with a <PackageReference> to ModernShippingLib
using ModernShippingLib;
using OrderManagement.Application.Abstractions;
namespace OrderManagement.Infrastructure.Shipping.Modern;
// The adapter instantiates the third-party type internally.
// The cast to double adapts this package's API shape to our interface —
// your domain contract uses decimal; you should not have to change it.
public sealed class ModernShippingAdapter : IShippingCostCalculator
{
private readonly ShipmentRateEngine _rateEngine = new();
public decimal Calculate(decimal weightKg, string destinationPostcode)
=> _rateEngine.ComputeRate(destinationPostcode, (double)weightKg);
}
To switch the entire application to the new package, change exactly one line in Program.cs:
// Program.cs — Replace: swap to the safe adapter (one-line change)
// Before: builder.Services.AddScoped<IShippingCostCalculator, LegacyShippingAdapter>();
builder.Services.AddScoped<IShippingCostCalculator, ModernShippingAdapter>();
Before making this change in production, write characterization tests (tests that record the exact input-output pairs the legacy code produces today, making observed behavior the contract rather than assumed correctness) against IShippingCostCalculator using real data. Run those same tests against the new adapter to prove the outputs match before you deploy.
Step 3 — Rewrite: Remove the External Dependency Entirely
If the package has been abandoned, or the shipping calculation logic is straightforward enough to own directly, you can eliminate the external dependency altogether. Implement IShippingCostCalculator using only built-in .NET types:
// Project: OrderManagement.Application.Shipping
// No NuGet references — pure .NET implementation
using OrderManagement.Application.Abstractions;
namespace OrderManagement.Application.Shipping;
public sealed class FlatRateShippingCalculator : IShippingCostCalculator
{
private const decimal BaseRatePounds = 3.99m;
private const decimal RatePerKgPounds = 1.25m;
// Extend with additional postcode areas and zone surcharges as needed
private static readonly Dictionary<string, decimal> ZoneSurcharges =
new(StringComparer.OrdinalIgnoreCase)
{
["BT"] = 5.00m, // Northern Ireland
["GY"] = 7.50m, // Guernsey
["JE"] = 7.50m, // Jersey
};
public decimal Calculate(decimal weightKg, string destinationPostcode)
{
var areaCode = destinationPostcode.Length >= 2
? destinationPostcode[..2].Trim()
: destinationPostcode;
var surcharge = ZoneSurcharges.GetValueOrDefault(areaCode, defaultValue: 0m);
return BaseRatePounds + (weightKg * RatePerKgPounds) + surcharge;
}
}
Register it with one line — the same pattern as before:
// Program.cs — Rewrite: no external NuGet package needed
builder.Services.AddScoped<IShippingCostCalculator, FlatRateShippingCalculator>();
At this point, the PackageReference to LegacyShippingLib can be removed from your project entirely. The vulnerability is no longer present in your dependency tree.
How the Three Strategies Relate
The consistent thread across all three strategies is the abstraction → adapter → DI registration pattern. The domain code always depends on IShippingCostCalculator. The implementation behind it is interchangeable:
┌──────────────────────────────────────────────────────────────────┐
│ Domain Code │
│ OrderService, QuoteService, CheckoutService │
│ depend on: IShippingCostCalculator │
│ know nothing about: LegacyShippingLib │
└────────────────────┬─────────────────────────────────────────────┘
│ IShippingCostCalculator
┌──────────▼────────────────────────────────┐
│ DI Container — one registration per env │
└──────────┬────────────────────────────────┘
│
┌────────────┼───────────────────┐
│ │ │
┌─────▼──────┐ ┌───▼────────────┐ ┌───▼──────────────────────┐
│ Wrap │ │ Replace │ │ Rewrite │
│ Legacy │ │ Modern │ │ FlatRate │
│ Adapter │ │ Adapter │ │ Calculator │
│ (CVE pkg) │ │ (safe pkg) │ │ (zero NuGet deps) │
└────────────┘ └────────────────┘ └──────────────────────────┘
Moving between strategies is a single-line change in Program.cs. You can ship the Wrap this sprint, run the Replace next sprint after characterization tests pass, and schedule the Rewrite for a later quarter when you have the capacity — all without touching domain code again.
Quick Reference: When to Apply Each Strategy
Strategy | Best Applied When | Prerequisites
----------|-------------------------------------------------|-----------------------------
Wrap | CVE is low or medium severity | None
| No replacement package available yet |
| Constrained timeline; no ACL in place |
----------|-------------------------------------------------|-----------------------------
Replace | CVE is high or critical | Anti-Corruption Layer (ACL)
| Drop-in replacement package is available | Characterization tests
----------|-------------------------------------------------|-----------------------------
Rewrite | Package is abandoned (no more security patches) | Anti-Corruption Layer (ACL)
| Feature scope is small and fully understood | Behavior fully specified
| Licensing risk outweighs implementation cost |
Summary
Vulnerable NuGet dependencies are inevitable in long-lived .NET applications. The key is not to panic — and not to ignore them. The Wrap-Replace-Rewrite pattern gives you a structured ladder to work through any dependency problem at a pace that matches both the severity of the risk and the capacity of your team.
- When a NuGet type leaks directly into domain code, every replacement becomes a risky, wide-impact refactoring across many files.
- Wrap: create a domain interface (such as IShippingCostCalculator) and an adapter project that is the only project allowed to reference the problematic package. This creates an Anti-Corruption Layer and stops the problem from spreading — even before you have a replacement.
- Replace: with the abstraction in place, create a new adapter backed by a safer package. Swap implementations by changing one DI registration line — domain code is untouched.
- Rewrite: if no suitable replacement exists, implement the behavior yourself using only built-in .NET types. The interface makes this just as swappable as any external package.
- Cover the interface with characterization tests before swapping implementations — they are the contract that the new behavior matches the old.
To identify which of your NuGet packages have known CVEs before applying these strategies, read How to Find NuGet Dependency Vulnerabilities Before They Find You in .NET — it covers dotnet list package --vulnerable, NuGet Audit, and CI pipeline configuration to catch dependency risks automatically before they reach production.