Why Hierarchies Need Special Handling
If you've ever tried to calculate the total size of a folder containing subfolders, you've hit the problem the Composite pattern solves. Treating individual files and folders the same way leads to messy type-checking code full of if-else chains. You need one method for files and another for folders, making your code brittle and hard to extend.
The Composite pattern lets you treat individual objects and compositions of objects uniformly. A file and a folder both implement the same interface, so your code doesn't care whether it's processing a single item or a container with hundreds of nested children. This makes tree traversal, calculations, and rendering logic dramatically simpler.
You'll build a file system model starting with simple files, then add folders that contain other files and folders. Each example will show how treating leaves and composites uniformly eliminates type-checking logic and makes your hierarchies easier to work with.
The Component Interface
Start with an interface that both leaf objects and composites implement. This interface defines operations that make sense for both individual items and collections. For a file system, this means calculating size and displaying information.
The key insight is that a folder's size is the sum of its contents, while a file's size is just its own size. Both expose the same GetSize method, but they calculate it differently.
namespace CompositeDemo;
// Component - defines interface for objects in composition
public abstract class FileSystemComponent
{
public string Name { get; protected set; }
public FileSystemComponent(string name)
{
Name = name;
}
public abstract long GetSize();
public abstract void Display(int depth = 0);
// Optional: operations only for composites
public virtual void Add(FileSystemComponent component)
{
throw new NotSupportedException("Cannot add to a leaf");
}
public virtual void Remove(FileSystemComponent component)
{
throw new NotSupportedException("Cannot remove from a leaf");
}
}
// Leaf - represents individual objects with no children
public class File : FileSystemComponent
{
private readonly long _size;
public File(string name, long size) : base(name)
{
_size = size;
}
public override long GetSize() => _size;
public override void Display(int depth = 0)
{
Console.WriteLine($"{new string(' ', depth * 2)}- {Name} ({_size} bytes)");
}
}
The File class is a leaf with no children. It knows its own size and displays itself with indentation based on depth. The Add and Remove methods throw exceptions because files can't contain other items. This is safer than silently ignoring invalid operations.
Building the Composite
The composite class holds children and delegates operations to them. When you call GetSize on a folder, it iterates through its children and sums their sizes. This works because each child also implements GetSize, whether it's a file or another folder.
namespace CompositeDemo;
// Composite - represents complex objects with children
public class Folder : FileSystemComponent
{
private readonly List<FileSystemComponent> _children = new();
public Folder(string name) : base(name) { }
public override long GetSize()
{
long total = 0;
foreach (var child in _children)
{
total += child.GetSize();
}
return total;
}
public override void Display(int depth = 0)
{
Console.WriteLine($"{new string(' ', depth * 2)}+ {Name}/");
foreach (var child in _children)
{
child.Display(depth + 1);
}
}
public override void Add(FileSystemComponent component)
{
_children.Add(component);
}
public override void Remove(FileSystemComponent component)
{
_children.Remove(component);
}
public IReadOnlyList<FileSystemComponent> GetChildren()
{
return _children.AsReadOnly();
}
}
Notice how GetSize calls GetSize on each child without caring whether the child is a file or folder. This recursive delegation is the heart of the Composite pattern. Display works the same way, incrementing depth so nested items appear indented.
Constructing Tree Structures
With the component and composite classes defined, you can build complex hierarchies. Create folders, add files to them, and nest folders inside other folders. The structure becomes self-similar at every level.
var root = new Folder("Projects");
var src = new Folder("src");
src.Add(new File("Program.cs", 2048));
src.Add(new File("Utils.cs", 1024));
var models = new Folder("Models");
models.Add(new File("User.cs", 512));
models.Add(new File("Order.cs", 768));
src.Add(models);
var tests = new Folder("tests");
tests.Add(new File("ProgramTests.cs", 1536));
tests.Add(new File("UtilsTests.cs", 896));
root.Add(src);
root.Add(tests);
root.Add(new File("README.md", 256));
// Display entire hierarchy
root.Display();
// Calculate total size
Console.WriteLine($"\nTotal size: {root.GetSize()} bytes");
Output
+ Projects/
+ src/
- Program.cs (2048 bytes)
- Utils.cs (1024 bytes)
+ Models/
- User.cs (512 bytes)
- Order.cs (768 bytes)
+ tests/
- ProgramTests.cs (1536 bytes)
- UtilsTests.cs (896 bytes)
- README.md (256 bytes)
Total size: 7040 bytes
The same Display and GetSize methods work at every level. You can call them on root, src, or models and get correct results. No type-checking or special cases needed.
Search and Filter Operations
The Composite pattern makes tree traversal operations straightforward. You can search for files by name, filter by extension, or find items matching any criteria. Each method recursively processes the tree structure.
namespace CompositeDemo;
public static class FileSystemExtensions
{
public static List<File> FindFiles(
this FileSystemComponent component,
Func<File, bool> predicate)
{
var results = new List<File>();
if (component is File file && predicate(file))
{
results.Add(file);
}
else if (component is Folder folder)
{
foreach (var child in folder.GetChildren())
{
results.AddRange(child.FindFiles(predicate));
}
}
return results;
}
public static FileSystemComponent? FindByName(
this FileSystemComponent component,
string name)
{
if (component.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
{
return component;
}
if (component is Folder folder)
{
foreach (var child in folder.GetChildren())
{
var result = child.FindByName(name);
if (result != null) return result;
}
}
return null;
}
public static int CountFiles(this FileSystemComponent component)
{
return component switch
{
File => 1,
Folder folder => folder.GetChildren()
.Sum(child => child.CountFiles()),
_ => 0
};
}
}
These extension methods show common tree operations. FindFiles uses a predicate to filter results. FindByName stops at the first match. CountFiles demonstrates pattern matching with the Composite structure. All methods handle both files and folders uniformly.
Links and Advanced Structures
You can extend the pattern to handle special cases like symbolic links or shortcuts. These reference other components without duplicating data. They delegate operations to their target while maintaining their own identity.
namespace CompositeDemo;
public class Link : FileSystemComponent
{
private readonly FileSystemComponent _target;
public Link(string name, FileSystemComponent target) : base(name)
{
_target = target;
}
public override long GetSize() => _target.GetSize();
public override void Display(int depth = 0)
{
Console.WriteLine(
$"{new string(' ', depth * 2)}@ {Name} -> {_target.Name}");
}
public FileSystemComponent GetTarget() => _target;
}
// Usage
var original = new File("data.json", 4096);
var backup = new Folder("backup");
backup.Add(new Link("data-link", original));
// Link reports same size as original
Console.WriteLine($"Original: {original.GetSize()} bytes");
Console.WriteLine($"Link: {backup.GetChildren()[0].GetSize()} bytes");
Links show how the pattern supports variations beyond simple containers. They implement the component interface but delegate behavior to their target. This keeps the tree structure intact while adding flexibility for references and shortcuts.
Try It Yourself
Build a complete file system with nested folders and search capabilities. This example combines all the concepts into a working program.
Steps
- Initialize project:
dotnet new console -n CompositeTree
- Open folder:
cd CompositeTree
- Add the code below to Program.cs
- Execute:
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
var project = new Folder("MyApp");
var src = new Folder("src");
src.Add(new File("App.cs", 3000));
src.Add(new File("Config.cs", 1200));
var controllers = new Folder("Controllers");
controllers.Add(new File("HomeController.cs", 2500));
controllers.Add(new File("ApiController.cs", 1800));
src.Add(controllers);
project.Add(src);
project.Add(new File("README.md", 500));
Console.WriteLine("=== File System Structure ===");
project.Display();
Console.WriteLine($"\nTotal: {project.GetSize()} bytes");
Console.WriteLine($"File count: {project.CountFiles()}");
var csFiles = project.FindFiles(f => f.Name.EndsWith(".cs"));
Console.WriteLine($"\n.cs files found: {csFiles.Count}");
foreach (var file in csFiles)
{
Console.WriteLine($" - {file.Name}");
}
// Include all component classes from earlier sections
What you'll see
=== File System Structure ===
+ MyApp/
+ src/
- App.cs (3000 bytes)
- Config.cs (1200 bytes)
+ Controllers/
- HomeController.cs (2500 bytes)
- ApiController.cs (1800 bytes)
- README.md (500 bytes)
Total: 9000 bytes
File count: 5
.cs files found: 4
- App.cs
- Config.cs
- HomeController.cs
- ApiController.cs
Avoiding Common Mistakes
Circular references: Adding a folder as its own child creates infinite loops. GetSize and Display will recurse forever until stack overflow. Before adding a component, check that it's not an ancestor. Keep a set of visited nodes during traversal or validate the tree structure after modifications.
Violating uniform treatment: The pattern's power comes from treating leaves and composites uniformly. Don't add type checks like "if (component is Folder)" in client code. If you need type-specific behavior, add it to the component interface or use the Visitor pattern for external operations.
Exposing internal structure: Returning the internal children list directly breaks encapsulation. Clients can modify the list without going through Add/Remove methods. Always return IReadOnlyList or a copy. This protects invariants and lets you add validation or notifications in Add/Remove.
Memory leaks with circular references: If components hold references to parents and children, you create bidirectional links. These prevent garbage collection even when the tree is no longer used. Either use weak references for parent links or rely on tree traversal instead of storing parent pointers.
Design Trade-offs
Choose the Composite pattern when your domain has natural tree structures and you want uniform treatment of leaves and composites. It shines for file systems, UI component hierarchies, organization charts, and nested data structures where recursive operations are common.
The pattern adds complexity with abstract base classes and recursive methods. If your hierarchy is shallow or operations differ significantly between leaves and composites, direct type checking might be simpler. Don't force the pattern onto data that isn't naturally tree-shaped.
For large trees, consider lazy loading children. Instead of holding all children in memory, load them on demand. This trades memory for I/O but keeps trees manageable. Implement IEnumerable on composites to stream children rather than materializing complete lists.
If you need operations that don't fit the component interface, the Visitor pattern works well with Composite. Visitors externalize operations while maintaining uniform treatment. This is useful when operations change frequently but the tree structure remains stable.