AlgoMaster Logo

Internal Access

Medium Priority23 min readUpdated June 6, 2026

The internal access modifier draws a line around an assembly. Anything marked internal is visible to every type in the same compiled DLL, and invisible to every type outside it. That single rule does most of the work of separating a library's public contract from the helpers, caches, validators, and intermediate types that exist only to make the public contract work. This lesson covers how internal behaves in practice, the comparison with the other access modifiers at the assembly boundary, the [InternalsVisibleTo] attribute that lets a test project see internals without weakening them for everyone else, and where the seams between projects sit in a typical .NET solution.

What internal Means

Every type and member in C# has an access level. public makes it visible everywhere. private keeps it inside the declaring type. internal sits between those two: visible to all code in the same assembly, hidden from code in any other assembly.

An assembly is the unit of compilation: usually one DLL or EXE. A class library project (ECommerce.Catalog.csproj) compiles to one assembly (ECommerce.Catalog.dll). A separate console app or web app references that DLL and can call into its public types. Anything internal in the catalog assembly is off-limits to the referencing project, even though the DLL is right there on disk.

CatalogCache is internal. Inside the ECommerce.Catalog assembly, any class can new CatalogCache() and call its methods. A consumer that references ECommerce.Catalog.dll cannot. Try it and the compiler emits error CS0122:

The error is not a runtime check. The C# compiler refuses to emit the call. The type is still loaded into memory at runtime, the runtime itself doesn't enforce internal, but consumer code cannot name it through normal references. Reflection can still reach internals if it tries, which is why internal is an API design tool, not a security boundary.

The default access level for top-level types (classes, structs, interfaces, enums, records declared directly inside a namespace) is internal, not public. The two declarations below produce the same type:

Many beginners assume new C# types are public by default because that is the visible default in some other languages. In C# the safer default is the more restrictive one. If you don't write a modifier, the type is part of your assembly's private implementation. You have to opt in to public to make it part of the API.

The default for class members (fields, methods, properties, events) is private, not internal. That difference is easy to overlook. class defaults are restrictive at both levels: the type defaults to internal, and its members default to private. Make types and members public only when the intent is part of the public surface.

Access Modifiers at the Assembly Boundary

Six access modifiers exist in C#, and the assembly boundary is where the differences between them stop being abstract. The table below focuses on what code outside the assembly can see, which is the question that matters for designing a library's API.

ModifierSame classSame assemblyDerived class, same assemblyDerived class, other assemblyOther assembly, not derived
publicYesYesYesYesYes
internalYesYesYesNoNo
protectedYesYes (via derivation)YesYesNo
protected internalYesYesYesYesNo
private protectedYesYes (via derivation)YesNoNo
privateYesNoNoNoNo

The two modifiers that combine words are easy to mix up. protected internal means protected OR internal, the more permissive combination: visible to anyone inside the same assembly, plus to derived classes in any assembly. private protected means protected AND internal, the more restrictive combination: visible only to derived classes that are also in the same assembly. Reading the names as "or" and "and" once usually gets them straight.

For everyday library design, the rows that matter are public versus internal. public is your contract. internal is your implementation. The other modifiers come up when inheritance enters the picture (protected) or when you genuinely need the AND/OR combinations, which is rare.

ProductService is public. CatalogCache is internal. The public class uses the internal type inside its body, and the compiler is fine with that because both types live in the same assembly. The trouble starts only if you try to expose the internal type through a public member. A public CatalogCache GetCache() => cache; would not compile, because that would let outside code refer to CatalogCache, which it can't see.

That diagnostic, "inconsistent accessibility," is the compiler enforcing the rule that a public surface can't leak internal types. The rule is what makes internal a useful tool: you can change CatalogCache's shape freely without breaking any consumer of ProductService, because no consumer ever sees CatalogCache.

Why Use internal

The point of internal is to keep the public API surface small. A library that exposes ten public types and hides forty internal ones is easier to learn, easier to document, and easier to evolve than a library that exposes all fifty.

Consider the catalog library. The consumer needs ProductService to get prices and Product as the return shape. That is the API. Everything else, the cache, the database mapper, the price formatter, the SKU validator, the in-memory index, exists to make those two types work. None of it belongs in the public contract. If a consumer could reach into CatalogCache, they would build code that depends on its shape, and you could never change CatalogCache again without breaking them.

The same argument applies to internal helpers within the public types themselves. A public class often has methods that exist purely as building blocks for its public methods. Those building blocks should be private, or internal if other types in the assembly need them, but not public.

The diagram shows the assembly boundary as a wall. Inside ECommerce.Catalog.dll, every type can see every other type because they're all in the same assembly. The web project sits in a separate DLL and can only reach across the wall to public symbols. The dashed arrow from the controller to CatalogCache is the call the compiler blocks.

Library authors get three concrete benefits from this discipline. The public API stays small enough to document and test as a contract. Internal types can change shape, name, or implementation across versions without breaking consumers. Tools like IntelliSense show users only the types they should be using, not the entire internal scaffolding.

A common pattern that falls out of this is public interface, internal implementation.

The interface is public: consumers can declare variables of type IProductRepository, accept it as a parameter, and mock it in their own tests. The implementation is internal: consumers cannot name SqlProductRepository directly. A factory method exposed by the assembly is usually how the consumer obtains an instance. If you ever swap SqlProductRepository for PostgresProductRepository, no consumer code changes because no consumer code ever named the implementation.

internal is a compile-time visibility rule. It has zero runtime overhead. The JIT does not insert extra checks, and the metadata is the same shape as for any other access level. The cost is purely in design discipline: deciding which types belong on the public surface and which don't.

The Test Project Problem

The discipline of internal runs into one practical problem: unit tests. A test project lives in a separate assembly from the code it tests. If CatalogCache is internal, the test project can't see it, can't construct it, can't call Store or Lookup directly. The test project is bound by the same wall as any other consumer.

Three options exist for that situation, and only one of them is good.

The first option is to make the type public just for tests. This defeats the entire reason you wrote internal. The test project gets access, but so does every real consumer, and now the cache is part of your public contract whether you wanted it or not.

The second option is to only test through the public API. This is the right answer for most cases. If ProductService.GetPrice calls CatalogCache, you can test GetPrice and verify that the second call returns the cached value without setting up a database. The cache is observable through the public surface even if it's not directly accessible. For most internal types, this is enough.

The third option, when the second isn't enough, is to grant the test assembly explicit access with the InternalsVisibleTo attribute. This is what the rest of the lesson covers. It is a coupling tool: by naming a specific other assembly, you let that one assembly cross the wall while keeping it solid against everyone else.

The judgement call is whether a particular internal type has behavior complex enough to warrant a direct test. A cache with explicit eviction rules, a validator with a long list of edge cases, a parser with many input formats, those benefit from direct unit tests. A simple wrapper that just forwards calls usually doesn't, the public-API test covers it adequately.

[InternalsVisibleTo]

System.Runtime.CompilerServices.InternalsVisibleToAttribute is an assembly-level attribute that names another assembly and grants it access to your internals. It goes in the producing assembly (the one with the internals), not the consuming assembly (the test project).

The attribute has been around since .NET Framework 2.0 and lived in Properties/AssemblyInfo.cs for most of that history. Modern SDK-style projects don't generate AssemblyInfo.cs by default and instead declare the attribute in the .csproj file. Both forms are valid; the modern form is the default in new projects.

Legacy form (AssemblyInfo.cs):

Modern form (in the .csproj):

The modern form is shorter, lives next to the project's other configuration, and is the recommended placement in .NET SDK projects (since .NET 5 / C# 9, the SDK generates the equivalent [assembly: InternalsVisibleTo(...)] attribute at build time). The legacy form still works if you already have AssemblyInfo.cs for other attributes.

With this attribute in place, the test project can use CatalogCache as if it were a public type within the catalog assembly:

The test project references the catalog project (via <ProjectReference Include="..\ECommerce.Catalog\ECommerce.Catalog.csproj" /> in its own csproj) and uses CatalogCache directly. The compiler accepts the call because the producing assembly's metadata advertises that ECommerce.Catalog.Tests is allowed to see internals.

The assembly name in InternalsVisibleTo is the assembly name, not the namespace, not the project file name, and not the type's containing namespace. By default, the assembly name matches the project name (so ECommerce.Catalog.Tests.csproj produces ECommerce.Catalog.Tests.dll with assembly name ECommerce.Catalog.Tests). If a project sets a different <AssemblyName> in the csproj, that's the name you use in the attribute.

The diagram shows the asymmetry the attribute creates. The test assembly gets a labeled door into the catalog's internals. The web project, which is unnamed in the attribute, still has to use the public entrance.

Strong-Named Assemblies and the Public Key Clause

InternalsVisibleTo has one wrinkle that bites people exactly once. If the producing assembly is signed with a strong name (a cryptographic key that gives the assembly a unique identity), then the consuming assembly named in the attribute must also be strong-named, and the attribute must include the consumer's public key.

For modern unsigned assemblies, the simple form works:

For strong-named assemblies, the form is:

The public key is a long hex string (typically 320+ characters) extracted from the consumer's strong-name key file with sn -Tp Consumer.dll. Both sides have to be signed, and the key in the attribute has to match the actual key the consumer was signed with. Get any of that wrong and the compiler refuses with a clearer error message than the generic CS0122: it tells you the public-key mismatch explicitly.

Most .NET projects since .NET Core have stopped using strong names. Strong-naming was a .NET Framework era pattern tied to the Global Assembly Cache (GAC), which .NET Core and later don't use. New projects today rarely need to deal with the public-key form. An older library that's still strong-named does. The error message when this matters is specific enough to point you to the fix.

When InternalsVisibleTo Is and Isn't Appropriate

The attribute is a coupling tool. Naming ECommerce.Catalog.Tests in ECommerce.Catalog.csproj ties the two assemblies together: rename one, you have to update the other. Move test methods to a different assembly, you have to grant that one too. The discipline is the point. The attribute should grant access narrowly, to assemblies you control, for reasons that aren't satisfied by the public API alone.

Reasonable uses include:

  • Unit test projects. The canonical case. Tests need to verify behaviour the public API doesn't fully expose.
  • Tightly coupled "friend" assemblies you ship together. A library split into multiple projects for build or layering reasons that nonetheless forms a single logical unit can use InternalsVisibleTo to share internals across the split, the way System.Private.CoreLib and other BCL pieces do.
  • Source-generator helper assemblies. A generator and the runtime helpers it emits code against sometimes need shared internal types.

Unreasonable uses include:

  • Granting access to consumers because the public API is missing a feature. The proper fix is to extend the public API or change the design, not to have a customer reach into the internals.
  • Granting access to too many assemblies. Each entry adds coupling. Adding a fifth InternalsVisibleTo is a signal that the design probably wants something else (a public extensibility hook, or a merged set of projects).
  • Granting access to assemblies outside the library's control. Once an external project depends on the internals, the internals have effectively become part of the contract. The freedom to change them is lost.

A simple rule covers most cases: use InternalsVisibleTo for the test project that ships in the same solution as the library, and avoid it for everything else unless the alternative is worse.

InternalsVisibleTo itself has no runtime cost. The expense is design coupling: it locks the internal shape of one assembly to the source code of another. Each entry adds friction when refactoring internals.

A Worked Example: Catalog Library with a Tested Cache

A solution with three projects shows the pieces in their normal arrangement. The directory layout:

The catalog project is the library. The tests project verifies it. The web project is a downstream consumer that should only see the public API. The csproj files set the access boundaries.

ECommerce.Catalog.csproj:

ECommerce.Catalog.Tests.csproj:

ECommerce.Web.csproj:

The catalog project grants access to one specific test assembly. The test project picks that up implicitly because both projects build into the same solution and the test assembly's name matches the attribute value. The web project has no special access; it sees only the public types.

The code inside the catalog project follows the public-API-with-internal-helpers pattern:

The test project gets to verify the cache directly because the catalog assembly named it in InternalsVisibleTo:

The web project, by contrast, can only call into the public surface:

Build the solution with dotnet build and all three projects compile. Uncomment the CatalogCache line in the web project and the build fails with CS0122 on ECommerce.Web only. The test project is unaffected.

Output (success):

Output (after uncommenting the forbidden line in the web project):

That error message is the entire point of the design. The library author can keep moving CatalogCache around: rename it, replace its Dictionary with a more sophisticated structure, add eviction, switch to a distributed cache, none of it ripples into the web project, because the web project never could refer to CatalogCache in the first place. The test project, which does refer to it, is shipped alongside the library and updated together.

Quiz

internal Access Quiz

10 quizzes