Data Layer at Scale — series
EF Core 10 AOT: what's broken (link TBD)
Dapper.AOT broken on .NET 10 (link TBD)
Extern alias benchmarking (this article)
Cold start vs density (link TBD)
Allocation numbers for k8s (link TBD)
The benchmark lied (link TBD)
The data layer as a line item (link TBD)
Measure, then migrate (link TBD)
When you benchmark multiple implementations of the same contract — say, three data-access providers that each expose an identically-named App or Service class — you hit a C# compiler limitation quickly: CS0433. This article shows how to resolve it with extern alias, a language feature that has been in the spec since C# 2.0 and is exactly the right tool for the job.
I hit this while benchmarking EF Core, Dapper, and ADO.NET provider assemblies that all exported the same public type names (in our case, some of them generated into each assembly by a source generator — so renaming was not an option). Solving it cleanly let all three providers run in a single BenchmarkDotNet process: no per-provider harness, no HTTP round-trip noise, a genuinely fair apples-to-apples comparison.
The Problem: CS0433
Two class libraries, same type name:
Provider.Alpha/MyService.cs
namespace Provider.Alpha;
public class MyService
{
public string Name => "Alpha";
}
Provider.Beta/MyService.cs
namespace Provider.Beta;
public class MyService
{
public string Name => "Beta";
}
Reference both from a benchmark project:
<ItemGroup>
<ProjectReference Include="..\Provider.Alpha\Provider.Alpha.csproj" />
<ProjectReference Include="..\Provider.Beta\Provider.Beta.csproj" />
</ItemGroup>
The moment you write:
using Provider.Alpha;
using Provider.Beta;
var a = new MyService(); // CS0433: The type 'MyService' exists in both assemblies
the compiler refuses. Both assemblies export the type and neither has priority. (If the colliding types are emitted by a source generator into each provider, you can't even rename your way out.)
The Fix: extern alias
extern alias assigns a distinct root name to each assembly reference; type lookups prefixed with that name resolve against that assembly only.
Step 1 — assign aliases in the .csproj:
<ItemGroup>
<ProjectReference Include="..\Provider.Alpha\Provider.Alpha.csproj">
<Aliases>alpha</Aliases>
</ProjectReference>
<ProjectReference Include="..\Provider.Beta\Provider.Beta.csproj">
<Aliases>beta</Aliases>
</ProjectReference>
</ItemGroup>
Step 2 — declare the aliases at the very top of each file that uses them (before any using; the compiler enforces this):
extern alias alpha;
extern alias beta;
Step 3 — qualify with :::
var a = new alpha::Provider.Alpha.MyService();
var b = new beta::Provider.Beta.MyService();
Console.WriteLine(a.Name); // Alpha
Console.WriteLine(b.Name); // Beta
No ambiguity, no reflection, no build hacks.
Applying It in BenchmarkDotNet
The payoff is one runner covering every variant, so results land in a single comparable table. A per-provider adapter implementing a shared contract (defined in a conflict-free assembly) keeps the alias-qualified names confined to construction:
extern alias ef;
extern alias ado;
extern alias dapper;
using BenchmarkDotNet.Attributes;
using Contracts; // IOrderBench — in a separate, conflict-free assembly
[MemoryDiagnoser]
[SimpleJob]
public class DataProviderBenchmark
{
public enum Provider { Ef, Ado, Dapper }
[Params(Provider.Ef, Provider.Ado, Provider.Dapper)]
public Provider ActiveProvider { get; set; }
private IOrderBench _bench = null!;
[GlobalSetup]
public void Setup()
{
_bench = ActiveProvider switch
{
Provider.Ef => new EfAdapter(), // internally: ef::Spark.App + its DbContext
Provider.Ado => new AdoAdapter(), // internally: ado::Spark.App + its store
Provider.Dapper => new DapperAdapter(), // internally: dapper::Spark.App + its store
_ => throw new ArgumentOutOfRangeException()
};
_bench.Initialize();
}
[Benchmark] public Task PlaceOrder() => _bench.PlaceOrderAsync();
[Benchmark] public Task GetById() => _bench.GetByIdAsync();
[Benchmark] public Task ListOrders() => _bench.ListAsync();
[GlobalCleanup]
public void Cleanup() => _bench.Dispose();
}
BenchmarkDotNet iterates the [Params] values and the output table gains a Provider column:
| Method | Provider | Mean | Allocated |
|----------- |--------- |--------:|----------:|
| PlaceOrder | Ef | 366 us | 36 KB |
| PlaceOrder | Ado | 112 us | 12 KB |
| PlaceOrder | Dapper | 171 us | 14 KB |
Three providers × three operations = nine cells, one process, one directly comparable table — and [MemoryDiagnoser] numbers that aren't polluted by process-startup or transport noise.
Common Pitfalls
extern alias must come first in the file. After a using, you get CS1529.
The alias replaces global:: for that assembly. Mixing aliased and unaliased types in one file works — qualify the unaliased side with global:: when ambiguous.
PackageReferences can be aliased too (<PackageReference Include="X"><Aliases>x</Aliases></PackageReference>), but a multi-assembly package aliases all of its assemblies under the name.
Invoke the built benchmark exe directly, not via glob. BenchmarkDotNet spawns generated child projects; a naive Get-ChildItem -Recurse *.exe in automation happily picks up a partial temp build and silently benchmarks one cell instead of nine. (Yes, that's a war story.)
Prefer [JsonExporterAttribute.Full] if you post-process results — the default compressed JSON omits the Statistics block your report generator probably wants.
Conclusion
extern alias is not a hack — it's the intended C# mechanism for referencing assemblies with conflicting type names, and it turns "N benchmark harnesses with incomparable outputs" into one process and one table. If you maintain separate benchmark projects per provider just to dodge CS0433: the csproj change is five lines, the code change is two declarations at the top of a file.
Part of the Data Layer at Scale series — research notes from building Turboservices, a .NET framework for rebuilding legacy systems onto AI-native delivery rails.
About the author — Vitalii Honcharuk is a hands-on Distinguished Engineer, Architect and CTO with 15+ years of experience building frameworks and high-reliability backend systems for enterprises and startups. His current work, Turboservices, turns engineering discipline into compile-time guarantees: unsafe states refuse to build, quality gates are code, and every architectural decision ships with measured evidence.