Organizing Code with Nested Types and Inner Classes in C#

Understanding Nested Types

Nested types let you define classes, structs, interfaces, or enums inside another type. This creates a logical grouping where the inner type belongs exclusively to the outer type. You'll use them when a helper class makes sense only in the context of its container.

The key benefit is encapsulation. Nested types can access private members of their containing type, creating a tight coupling that's sometimes exactly what you need. This access works both ways, the outer type can also access private members of instances it creates of the nested type.

You'll learn when nested types improve your design versus when they create unnecessary complexity. We'll cover visibility rules, common use cases like state machines and builders, and practical examples you can use immediately.

Creating Nested Types

You define a nested type by placing its declaration inside another type's body. The nested type behaves like a regular type except it can access private members of the containing type. This creates an intimate relationship where the inner type is part of the outer type's implementation.

Nested types follow the same access modifiers as other members. A private nested type stays completely hidden, visible only to the containing type. A public nested type becomes accessible from outside using the syntax OuterType.InnerType, making the relationship explicit to callers.

LinkedList.cs
public class LinkedList<T>
{
    private Node? _head;
    private int _count;

    // Private nested type - only LinkedList can use it
    private class Node
    {
        public T Value { get; set; } = default!;
        public Node? Next { get; set; }

        public Node(T value)
        {
            Value = value;
        }
    }

    public void Add(T value)
    {
        var newNode = new Node(value);
        if (_head == null)
        {
            _head = newNode;
        }
        else
        {
            var current = _head;
            while (current.Next != null)
            {
                current = current.Next;
            }
            current.Next = newNode;
        }
        _count++;
    }

    public int Count => _count;
}

The Node class exists purely to support LinkedList's implementation. Making it private prevents other code from depending on this implementation detail. If you later change how the list stores items, no external code breaks because Node was never part of your public API.

Modeling State with Nested Types

Nested types excel at representing states in state machines. Each state becomes a nested class that encapsulates state-specific behavior. This pattern keeps all related states together and makes the state machine's structure clear from reading the outer class.

Using nested types for states makes it obvious these classes exist only to support the state machine. They access the machine's private fields directly, avoiding the need for callbacks or dependency injection patterns that would complicate simpler scenarios.

Connection.cs
public class Connection
{
    private State _currentState;
    private string _connectionString = "";

    public Connection()
    {
        _currentState = new DisconnectedState(this);
    }

    public void Open(string connectionString)
    {
        _currentState.Open(connectionString);
    }

    public void Close()
    {
        _currentState.Close();
    }

    public void ExecuteQuery(string query)
    {
        _currentState.ExecuteQuery(query);
    }

    // Base state class
    private abstract class State
    {
        protected Connection Connection { get; }

        protected State(Connection connection)
        {
            Connection = connection;
        }

        public abstract void Open(string connectionString);
        public abstract void Close();
        public abstract void ExecuteQuery(string query);
    }

    private class DisconnectedState : State
    {
        public DisconnectedState(Connection connection) : base(connection) { }

        public override void Open(string connectionString)
        {
            Connection._connectionString = connectionString;
            Connection._currentState = new ConnectedState(Connection);
            Console.WriteLine("Connection opened");
        }

        public override void Close()
        {
            Console.WriteLine("Already disconnected");
        }

        public override void ExecuteQuery(string query)
        {
            throw new InvalidOperationException("Not connected");
        }
    }

    private class ConnectedState : State
    {
        public ConnectedState(Connection connection) : base(connection) { }

        public override void Open(string connectionString)
        {
            Console.WriteLine("Already connected");
        }

        public override void Close()
        {
            Connection._currentState = new DisconnectedState(Connection);
            Console.WriteLine("Connection closed");
        }

        public override void ExecuteQuery(string query)
        {
            Console.WriteLine($"Executing: {query}");
        }
    }
}

Each state class manipulates the Connection's private fields directly. This tight coupling is acceptable because these states exist solely to implement the connection's behavior. The pattern clearly separates state-specific logic while keeping everything together in one file.

Exposing Public Nested Types

Sometimes you want users to know about nested types. Public nested types let you organize related types hierarchically while making their relationship explicit. The outer type acts as a namespace, grouping related types together.

You'll commonly see this pattern in configuration classes or when a class exposes options that only make sense in its context. The nested type's name becomes OuterClass.InnerClass, which clearly communicates that the inner type relates to the outer one.

HttpClient.cs
public class HttpClient
{
    private readonly Options _options;

    public HttpClient(Options options)
    {
        _options = options;
    }

    public async Task<string> GetAsync(string url)
    {
        Console.WriteLine($"Timeout: {_options.Timeout}");
        Console.WriteLine($"Retry: {_options.RetryCount} times");
        await Task.Delay(100);
        return "Response data";
    }

    // Public nested type for configuration
    public class Options
    {
        public int Timeout { get; set; } = 30;
        public int RetryCount { get; set; } = 3;
        public bool AllowRedirects { get; set; } = true;

        public void Validate()
        {
            if (Timeout <= 0)
                throw new ArgumentException("Timeout must be positive");
            if (RetryCount < 0)
                throw new ArgumentException("RetryCount cannot be negative");
        }
    }
}

// Usage shows the relationship clearly
var options = new HttpClient.Options
{
    Timeout = 60,
    RetryCount = 5
};
options.Validate();

var client = new HttpClient(options);

The HttpClient.Options syntax immediately tells you these options belong to HttpClient. This is clearer than a separate HttpClientOptions class at namespace level, which could theoretically work with other HTTP client implementations. The nested type makes the ownership explicit.

Try It Yourself

This example shows a builder pattern using nested types. The builder is nested because it exists only to construct the outer class, and making this relationship explicit through nesting improves code clarity.

Steps:

  1. dotnet new console -n NestedTypesDemo
  2. cd NestedTypesDemo
  3. Replace Program.cs with the code below
  4. Create NestedTypesDemo.csproj as shown
  5. dotnet run
Program.cs
using System;

public class Query
{
    private readonly string _table;
    private readonly string[] _columns;
    private readonly string? _whereClause;
    private readonly int _limit;

    private Query(string table, string[] columns, string? whereClause, int limit)
    {
        _table = table;
        _columns = columns;
        _whereClause = whereClause;
        _limit = limit;
    }

    public string ToSql()
    {
        var sql = $"SELECT {string.Join(", ", _columns)} FROM {_table}";
        if (_whereClause != null)
            sql += $" WHERE {_whereClause}";
        if (_limit > 0)
            sql += $" LIMIT {_limit}";
        return sql;
    }

    public class Builder
    {
        private string _table = "";
        private string[] _columns = Array.Empty<string>();
        private string? _whereClause;
        private int _limit;

        public Builder From(string table)
        {
            _table = table;
            return this;
        }

        public Builder Select(params string[] columns)
        {
            _columns = columns;
            return this;
        }

        public Builder Where(string condition)
        {
            _whereClause = condition;
            return this;
        }

        public Builder Limit(int count)
        {
            _limit = count;
            return this;
        }

        public Query Build()
        {
            if (string.IsNullOrEmpty(_table))
                throw new InvalidOperationException("Table name required");
            if (_columns.Length == 0)
                throw new InvalidOperationException("At least one column required");

            return new Query(_table, _columns, _whereClause, _limit);
        }
    }
}

// Usage
var query = new Query.Builder()
    .From("Users")
    .Select("Id", "Name", "Email")
    .Where("Active = 1")
    .Limit(10)
    .Build();

Console.WriteLine(query.ToSql());

The builder pattern benefits from nesting because Query.Builder immediately communicates that this builder constructs Query objects. The private constructor ensures the only way to create a Query is through the builder, enforcing validation at build time.

NestedTypesDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Output:

SELECT Id, Name, Email FROM Users WHERE Active = 1 LIMIT 10

The fluent interface makes query building readable while the nested type structure makes it clear the builder belongs to Query. This organizational choice helps developers discover the builder through IntelliSense when they have a Query instance.

When Not to Use Nested Types

Nested types create tight coupling that's sometimes exactly what you need, but often it's the wrong choice. If your "nested" type might be useful in other contexts, make it a separate class at namespace level instead. Testing becomes harder with nested types because you can't easily mock or stub them, unlike interface-based dependencies that you can swap out.

Avoid nesting types more than one level deep. Deeply nested types like Outer.Middle.Inner create confusing hierarchies that are hard to navigate. If you're tempted to nest multiple levels, you probably need better namespace organization or separate files instead. Two levels is the practical maximum before cognitive load becomes a problem.

Skip nested types when you need multiple classes to share the "inner" type. Nested types belong to one container, so if two different classes need to use the same helper class, it shouldn't be nested in either. Create a separate class that both can depend on, or use an interface to decouple the implementations. Nesting in one and exposing it publicly just to share creates awkward dependencies.

Public nested types should be rare. They make sense for builders, options classes, and types that truly belong to their container. If you're making a nested type public just to expose it for testing, that's a design smell. Consider whether the type should actually be separate, or whether your tests are verifying implementation details rather than behavior. Most nested types should remain private implementation details that callers never see.

Frequently Asked Questions (FAQ)

What's the difference between a nested type and a regular class?

A nested type is defined inside another type's declaration. It has access to the containing type's private members, creating a tight coupling between them. Regular classes exist at the namespace level and can only access public members of other types. Use nested types when the inner type truly belongs only to the outer type.

Can nested types be public or must they be private?

Nested types can have any accessibility level including public, private, protected, or internal. Public nested types are accessible from outside the containing class using dot notation like OuterClass.InnerClass. Private nested types remain completely hidden and only the containing class can use them.

When should I use nested types instead of separate classes?

Use nested types for implementation details that only the outer class needs, like state machine states or iterator implementations. They're also useful for helper classes that don't make sense outside their context. If the class might be useful elsewhere, make it a separate class at namespace level instead.

Back to Articles