Taming Code Sprawl with Partial Classes
If you've ever opened a 2000-line class file and spent minutes scrolling to find the method you need, you know the pain of poor code organization. Large classes become unmaintainable quickly, especially when code generators mix their output with your hand-written logic. Partial classes solve this by letting you split a single type across multiple files without changing how it compiles or runs.
This approach keeps generated code isolated from your business logic, prevents merge conflicts when tools regenerate files, and lets you organize related functionality into focused files. Entity Framework, Windows Forms designers, and modern source generators all rely on partial classes to work cleanly with your code.
You'll learn when to use partial classes, how partial methods enable extensibility hooks, and patterns that keep your codebase maintainable. We'll build examples showing separation of concerns, integration with source generators, and techniques that scale to real projects.
How Partial Classes Work
A partial class splits one type definition across multiple files. Each file uses the partial keyword, and the compiler merges them into a single type at build time. All parts must share the same namespace, assembly, and access modifier. This isn't inheritance or composition; it's literally one class in multiple files.
The primary benefit is separation. You can put generated code in one file and your custom logic in another. When the generator runs again, it overwrites its file without touching yours. This prevents the classic problem of losing custom code after regeneration.
namespace Store.Models;
public partial class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public bool IsValidEmail()
{
return Email?.Contains("@") == true && Email.Contains(".");
}
}
namespace Store.Models;
public partial class Customer
{
public List<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(Name))
errors.Add("Name is required");
if (!IsValidEmail())
errors.Add("Valid email is required");
return errors;
}
public bool IsValid() => Validate().Count == 0;
}
Both files define the Customer class. The compiler treats them as one type with all members from both files. You can call IsValid from anywhere, and it can access IsValidEmail even though they're in different files. The namespace and access level must match exactly or compilation fails.
Separating Generated Code
Tools like Entity Framework Core generate partial classes for database entities. You extend these by creating your own partial file. The generated file typically has a .g.cs or .Generated.cs suffix, signaling that developers shouldn't edit it manually.
This pattern protects your customizations. When you update your database schema and regenerate entities, only the generated file changes. Your business logic remains untouched, and source control diffs clearly show what the tool modified versus what you wrote.
// Auto-generated by EF Core - Do not modify
namespace Store.Data;
public partial class Product
{
public int ProductId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public DateTime CreatedAt { get; set; }
public virtual Category Category { get; set; }
public virtual ICollection<OrderItem> OrderItems { get; set; }
}
namespace Store.Data;
public partial class Product
{
public bool IsInStock() => StockQuantity > 0;
public decimal CalculateDiscount(decimal percentage)
{
if (percentage < 0 || percentage > 100)
throw new ArgumentException("Invalid discount percentage");
return Price * (percentage / 100);
}
public void AdjustStock(int quantity)
{
StockQuantity += quantity;
if (StockQuantity < 0)
throw new InvalidOperationException("Stock cannot be negative");
}
}
The generated file contains only what EF Core needs. Your custom file holds business methods that use the generated properties. When you scaffold changes, Product.Generated.cs gets replaced, but Product.cs stays exactly as you wrote it. This workflow scales to dozens of entities without conflicts.
Using Partial Methods for Hooks
Partial methods let you declare a method signature in one file and optionally implement it in another. If you don't provide an implementation, the compiler removes the method and all calls to it, creating zero runtime overhead. This pattern works perfectly for extensibility hooks in generated code.
Generators can include calls to partial methods at key points. You implement only the hooks you need. Unused hooks disappear completely, unlike virtual methods that always exist even when empty.
namespace Store.Data;
public partial class Entity
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
partial void OnCreating();
partial void OnCreated();
public void Save()
{
OnCreating();
CreatedAt = DateTime.UtcNow;
// Database save logic
OnCreated();
}
}
namespace Store.Data;
public partial class Entity
{
partial void OnCreating()
{
Console.WriteLine($"Creating entity: {Id}");
ValidateBeforeSave();
}
// OnCreated not implemented - compiler removes it
private void ValidateBeforeSave()
{
if (Id <= 0)
throw new InvalidOperationException("Invalid entity ID");
}
}
The Save method calls both hooks, but since OnCreated has no implementation, the compiler strips it out entirely. OnCreating runs your validation. This pattern gives generators a way to offer extension points without forcing you to implement methods you don't need.
Modern Partial Methods with Return Values
C# 9 enhanced partial methods to support return types, out parameters, and access modifiers, but with a requirement: if a partial method has any of these, it must have an implementation. The compiler won't remove it automatically. This enables more powerful patterns while keeping the optional behavior for simple hooks.
namespace Store.Services;
// File 1: Declaration
public partial class OrderProcessor
{
public partial bool ValidateOrder(Order order, out string error);
partial void OnOrderProcessed(); // Traditional, optional
public ProcessResult Process(Order order)
{
if (!ValidateOrder(order, out var error))
return ProcessResult.Failed(error);
// Process order
OnOrderProcessed(); // Might be removed if not implemented
return ProcessResult.Success();
}
}
// File 2: Implementation
public partial class OrderProcessor
{
public partial bool ValidateOrder(Order order, out string error)
{
if (order.Items.Count == 0)
{
error = "Order has no items";
return false;
}
error = null;
return true;
}
// OnOrderProcessed not implemented - removed by compiler
}
public record ProcessResult(bool Success, string Error)
{
public static ProcessResult Success() => new(true, null);
public static ProcessResult Failed(string error) => new(false, error);
}
public class Order
{
public List<object> Items { get; set; } = new();
}
ValidateOrder must be implemented because it returns bool and has an out parameter. OnOrderProcessed remains optional. This dual approach lets you mix required contract methods with optional hooks in the same class.
Try It Yourself
Build a console app demonstrating partial classes and methods. This example shows how to organize code across files and use optional hooks.
Steps
- Create a new project:
dotnet new console -n PartialDemo
- Move into the directory:
cd PartialDemo
- Replace Program.cs with the code below
- Add a second file Person.Validation.cs as shown
- Execute:
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
// Partial class demonstration
public partial class Person
{
public string Name { get; set; }
public int Age { get; set; }
partial void OnValidating();
public void Display()
{
OnValidating();
Console.WriteLine($"Name: {Name}, Age: {Age}");
}
}
var person = new Person { Name = "Alice", Age = 30 };
person.Display();
Console.WriteLine($"Is valid: {person.IsValid()}");
public partial class Person
{
partial void OnValidating()
{
Console.WriteLine("Validating person data...");
}
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Name) && Age > 0 && Age < 150;
}
}
Console Output
Validating person data...
Name: Alice, Age: 30
Is valid: True
Avoiding Common Mistakes
Developers new to partial classes often misuse them as a substitute for proper class decomposition. If your class has too many responsibilities, splitting it into partial files doesn't fix the design problem. It just spreads the mess across multiple files.
Another mistake is mixing namespaces. All partial definitions must be in the exact same namespace. A class in MyApp.Models cannot have a partial in MyApp.Data. The compiler treats them as completely separate types, and you'll get confusing errors about duplicate definitions or missing members.
Access modifiers must match across all parts. You can't have one part marked public and another internal. The compiler enforces consistency because the final merged class has one access level. Mismatched modifiers cause build failures with error CS0262.
Partial methods have constraints that trip people up. Traditional partial methods cannot have return types, access modifiers, or out/ref parameters unless implemented. If you declare a partial method with these features but forget the implementation, you get a compiler error. Modern partial methods require implementations precisely because they break the optional pattern.
Don't use partials to hide circular dependencies or tight coupling between components. If two parts of your partial class depend heavily on each other's internals, that's a code smell suggesting the class is doing too much. Refactor into separate, focused types instead of papering over the problem with file splits.
File naming conventions matter for maintainability. Use descriptive suffixes like Customer.Validation.cs or Product.Generated.cs so developers immediately understand what each file contains. Random or generic names like Customer2.cs make navigation harder and confuse teams about which file holds what logic.