VisibleTo.Analyzer
1.0.2
dotnet add package VisibleTo.Analyzer --version 1.0.2
NuGet\Install-Package VisibleTo.Analyzer -Version 1.0.2
<PackageReference Include="VisibleTo.Analyzer" Version="1.0.2" />
<PackageVersion Include="VisibleTo.Analyzer" Version="1.0.2" />
<PackageReference Include="VisibleTo.Analyzer" />
paket add VisibleTo.Analyzer --version 1.0.2
#r "nuget: VisibleTo.Analyzer, 1.0.2"
#:package VisibleTo.Analyzer@1.0.2
#addin nuget:?package=VisibleTo.Analyzer&version=1.0.2
#tool nuget:?package=VisibleTo.Analyzer&version=1.0.2
VisibleTo
Scoped type visibility for C#. InternalsVisibleTo, but you choose who — and at namespace granularity instead of whole assemblies.
[VisibleTo("MyApp.Domain.Orders.**", "MyApp.Infrastructure.Persistence.Configurations.**")]
public class Order { ... }
Anything outside those namespaces that touches Order fails the build with VT001.
The problem
C# has four accessibility levels and none of them mean "visible to that one class over there."
That gap shows up hardest in two places:
Types that must be public for a framework, but were never meant for general use. EF Core entities, System.Text.Json DTOs, types a DI container has to construct. The framework demands public; your team reads public as "go ahead."
Large assemblies, where internal means nothing. In a million-line project, internal is visible to every one of those lines. The escape hatch — [InternalsVisibleTo("MyApp.Infrastructure")] — is a firehose: it grants every internal in the assembly to every file in the other one. There is no way to say "only the EF configuration classes."
The usual advice is to split the assembly. In a large one that means circular-reference archaeology, a build-time hit, and months of work — and you cannot do it incrementally. You can add one [VisibleTo] at a time.
A worked example: EF Core
You want a domain entity mapped to a table, and you want the mapping to be compile-checked so a rename during domain evolution can't silently break it:
public class Order
{
public List<OrderLine> Lines { get; } = new(); // needs to be reachable from the mapping
}
// MyApp.Infrastructure.Persistence.Configurations
builder.HasMany(x => x.Lines); // no magic strings; rename refactoring tracks it
The alternative — a private _lines backing field matched by EF's naming convention — hides the collection properly, but the mapping is convention-based. Rename the property and you find out when the model builds, not when you compile.
[VisibleTo] lets you keep the compile-checked mapping and keep the mutable surface away from the rest of the codebase:
[VisibleTo("MyApp.Domain.Orders.**", "MyApp.Infrastructure.Persistence.Configurations.**")]
public class Order
{
public List<OrderLine> Lines { get; } = new();
}
The EF configuration compiles. Application code that reaches for Order.Lines — or for Order at all — does not.
Install
dotnet add package VisibleTo.Analyzer
The package brings both the analyzer and the [VisibleTo] attribute. No other setup.
Pattern syntax
| Pattern | Matches |
|---|---|
MyApp.Application |
Exactly that namespace |
MyApp.Application.* |
One segment below Application — MyApp.Application.Handlers, but not MyApp.Application.Sub.Handlers |
MyApp.Application.** |
Application itself and any namespace below it, at any depth |
Multiple patterns — whether as several arguments or several attributes — are combined with OR. Access is granted if the caller matches any one of them.
[VisibleTo("MyApp.Application.**", "MyApp.Tests.**")]
public class Order { ... }
What is checked
The attribute goes on the type. Every use of that type is then restricted:
- Member access — calling a method, reading or writing a property or field
- Object creation —
new Order() - Inheritance and interface implementation —
class Sub : Order,class Repo : IOrderStore - Method signatures — parameter and return types, including constructors
- Field, property, and event types —
private Order _current; - Delegate signatures — parameter and return types of a delegate declaration
- Generic type arguments, nested to any depth, in all of the above —
List<Order>,IRepository<Order>,IHandler<Command<Order>> nameof— bothnameof(Order)andnameof(Order.Lines)- Attribute usages — applying a restricted attribute type
typeof(Order)— the front door to reflection- Casts and type tests —
(Order)o,o as Order,o is Order,o is Order line, and type patterns in aswitch - Array creation —
new Order[10] - Local declarations —
Order o = ...andvar o = ...alike, includingforeach,usingandforvariables - Generic constraints —
where T : Order, on both types and methods
Obtained through an intermediary — VT002
A restricted type can also reach a namespace that never names it, by being handed over:
// MyApp.Infrastructure is permitted to see Order, and returns one
public class OrderStore { public Order Get(int id) => ...; }
// MyApp.UI is not permitted, and never names Order
store.Get(1).Ship(); // VT002 — an Order was obtained here
var order = store.Get(1); // VT002, and VT001 for the local that now holds one
The call itself is legal, because OrderStore is permitted. Without this check, any namespace can reach a fully usable Order. The same applies to an extension method: one living in a permitted namespace would otherwise be a bypass for the type it extends.
This is reported as VT002 rather than VT001, and defaults to warning. Not because laundering a type is less harmful — it's arguably more so, since it's how a type escapes through code that looks entirely ordinary — but because switching it on is a different-sized decision. It fires on every repository and factory call in a codebase, all of which build clean today. Measure the blast radius, then raise it to error when you're ready:
dotnet_diagnostic.VT002.severity = error
Note that var is irrelevant throughout. A local of a restricted type is VT001 whether or not you spell the type, and the call that produced it is VT002 either way. What VT002 catches on its own is the transient case, where the type is obtained and used without ever landing in a local.
What is not checked
VisibleTo is a lint, not a sealed boundary. Reflection and dynamic are unreachable in principle. And because enforcement lives in an analyzer, a consumer who doesn't have the package installed sees an ordinary public type.
Treat it as something that catches accidents, not something that prevents access.
Combining with internal
The two mechanisms cover each other's weaknesses, and using both gets you closer than either alone:
// AssemblyInfo.cs — the compiler enforces this one, soundly
[assembly: InternalsVisibleTo("MyApp.Infrastructure")]
// The analyzer narrows it to specific namespaces inside those two assemblies
[VisibleTo("MyApp.Domain.Orders.**", "MyApp.Infrastructure.Persistence.Configurations.**")]
internal class Order { ... }
The compiler guarantees nothing outside those two assemblies can name the type — closing the "consumer without the analyzer" hole. VisibleTo then supplies the granularity InternalsVisibleTo can't express.
Notes and gotchas
[VisibleTo]applies to classes, structs, and enums. Not to methods, properties, or fields — member-level gating is not supported yet.- The attribute is not inherited. A subclass of a restricted type is unrestricted unless it carries its own
[VisibleTo]. - Include your own namespace. The self-reference exemption is per-type, not per-namespace. If
OrderandCustomerboth live inMyApp.DomainandOrderappears in aCustomermethod signature,Order's pattern list must include"MyApp.Domain". This is deliberate — a grant is never implicit, so the attribute stays the complete statement of who has access — but it is the one violation that doesn't look like one, so the diagnostic calls it out by name when it happens. - Patterns are strings, and rename refactoring will not update them. Moving a namespace breaks the match and produces a VT001 error — loud, at the right place, and with a code fix that adds the new namespace. The pattern left behind is never reported, but it is inert: it matches no namespace, so it grants nothing.
- VT001 is an error by default, but the severity is fully configurable — see Adjusting severity below. Adding the attribute to a widely-used type in a large codebase can produce a lot of errors in one commit; the path-scoped
.editorconfigapproach is the way to stage that.
Diagnostics
| ID | Severity | Message |
|---|---|---|
| VT001 | Error | Member '{0}' is available to '{1}', but is being accessed by '{2}'. Access denied by VisibleTo. |
| VT002 | Warning | Member '{0}' is available to '{1}', but '{2}' obtains it through an intermediary. Access denied by VisibleTo. |
The split follows blast radius, not severity of the offence: VT001 fires where the restricted type surfaces — named, or held in a local — and VT002 where it only passes through. Both are configurable independently, which is the point of giving them separate IDs.
A code fix is offered on both to add the calling namespace to the target type's pattern list.
Adjusting severity
VT001 defaults to Error, but that's a default rather than a floor. Unlike a compiler error, it carries no NotConfigurable tag, so you can move it wherever you want:
# .editorconfig
[*.cs]
dotnet_diagnostic.VT001.severity = warning
Valid values are error, warning, suggestion, silent, none, and default. No extra MSBuild properties are required — the SDK hands .editorconfig to the compiler as an analyzer config automatically.
<NoWarn>VT001</NoWarn> and #pragma warning disable VT001 also work, as does a .globalconfig or a legacy .ruleset. But those only suppress; .editorconfig is the only one that downgrades.
Staging adoption in an existing codebase
.editorconfig sections are path-scoped, which lets you attach [VisibleTo] to a heavily-referenced type without breaking the build:
# Existing code: report violations, don't block on them
[src/Legacy/**/*.cs]
dotnet_diagnostic.VT001.severity = warning
# New modules: enforced
[src/Modules/**/*.cs]
dotnet_diagnostic.VT001.severity = error
This is folder granularity, not violation granularity — a new violation inside a legacy folder is still only a warning. In practice the author sees the squiggle and is offered the code fix as they write it, and folders move from warning to error as they're cleaned up.
If VT001 arrives from a transitive dependency
The package is not marked DevelopmentDependency, so it flows through the NuGet graph: a library that uses [VisibleTo] internally brings the analyzer to its consumers, which is what makes the restrictions hold across package boundaries. If that isn't what you want in your build, the same .editorconfig setting applies — VT001 is configurable no matter where it came from.
Prior art
Worth knowing before you adopt this:
- NsDepCop — namespace dependency rules from a central config file. Better than VisibleTo for layering rules, because rules scale with the number of rules rather than the number of types.
- NetArchTest / ArchUnitNET — architecture rules as unit tests. Far more expressive; slower feedback loop.
- BannedApiAnalyzers — compile-time, but the consumer declares what's off limits.
VisibleTo differs in one way: the restriction is declared on the type itself, so it travels with the type and shows up in the editor immediately. That's a good fit for gating a handful of genuinely dangerous types. It is not a good fit for expressing "Domain must not reference Infrastructure" — that's one rule in NsDepCop and one attribute per type here.
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.