C# Class Design and SOLID Principles in Real Projects — Clean Code

2026/03/293 min read
bookmark this

Introduction

Clean methods help locally; clean class design helps globally.

When classes are too large or tightly coupled, every change becomes risky. This post shows practical class design rules based on SOLID.

1) Single Responsibility Principle (SRP)

A class should have one reason to change.

Bad: one class does everything

public class OrderService
{
    public void PlaceOrder(Order order)
    {
        Validate(order);
        Save(order);
        SendNotification(order);
    }
}

Better: split by responsibility

public class OrderValidator
{
    public void Validate(Order order) { /* ... */ }
}

public class OrderRepository
{
    public Task SaveAsync(Order order, CancellationToken ct) => Task.CompletedTask;
}

public class OrderNotifier
{
    public Task NotifyAsync(Order order, CancellationToken ct) => Task.CompletedTask;
}

public class OrderService(
    OrderValidator validator,
    OrderRepository repository,
    OrderNotifier notifier)
{
    public async Task PlaceOrderAsync(Order order, CancellationToken ct)
    {
        validator.Validate(order);
        await repository.SaveAsync(order, ct);
        await notifier.NotifyAsync(order, ct);
    }
}

2) Depend on abstractions, not concretions

High-level classes should not instantiate low-level dependencies directly.

// BAD
public class SqlReportRepository
{
    public async Task<ReportData> GetDataAsync(int id, CancellationToken ct)
    {
        return new ReportData("Monthly Sales");
    }
}

public class ReportGenerator
{
    private readonly SqlReportRepository _repository;

    public ReportGenerator()
    {
        _repository = new SqlReportRepository();
    }

    public async Task<Report> GenerateAsync(int id, CancellationToken ct)
    {
        var data = await _repository.GetDataAsync(id, ct);
        return BuildReport(data);
    }

    private static Report BuildReport(ReportData data) => new(data.Title);
}

// GOOD
public interface IReportRepository
{
    Task<ReportData> GetDataAsync(int id, CancellationToken ct);
}

public class ReportGenerator(IReportRepository repository)
{
    public async Task<Report> GenerateAsync(int id, CancellationToken ct)
    {
        var data = await repository.GetDataAsync(id, ct);
        return BuildReport(data);
    }

    private static Report BuildReport(ReportData data) => new(data.Title);
}

This makes the class easier to test and swap implementations.

3) Prefer composition over inheritance

Inheritance can create brittle hierarchies.

// BAD
public class FlyMovement
{
    public void Move() => Console.WriteLine("Flying");
}

public class WalkMovement
{
    public void Move() => Console.WriteLine("Walking");
}

public class Animal
{
    private readonly FlyMovement _flyMovement;
    private readonly WalkMovement _walkMovement;
    private readonly string _type;

    public Animal(string type)
    {
        _type = type;
        _flyMovement = new FlyMovement();
        _walkMovement = new WalkMovement();
    }

    public void Move()
    {
        if (_type == "Bird")
        {
            _flyMovement.Move();
        }
        else if (_type == "Dog")
        {
            _walkMovement.Move();
        }
    }
}


// GOOD
public interface IMovementStrategy
{
    void Move();
}

public class FlyMovement : IMovementStrategy
{
    public void Move() => Console.WriteLine("Flying");
}

public class WalkMovement : IMovementStrategy
{
    public void Move() => Console.WriteLine("Walking");
}

public class Animal(IMovementStrategy movement)
{
    public void Move() => movement.Move();
}

You can change behavior by changing strategy, not by rewriting class trees.

4) Keep classes cohesive

A cohesive class has members that all support one purpose.

Warning signs:

  • class has multiple groups of unrelated fields
  • methods only use a small subset of fields
  • class keeps growing with #region blocks

When these appear, extract classes.

5) Open/Closed principle in practice

Avoid repeated if/switch chains that must be edited for each new type.

public abstract class Shape
{
    public abstract double CalculateArea();
}

public class Circle(double radius) : Shape
{
    public override double CalculateArea() => Math.PI * radius * radius;
}

public class Rectangle(double width, double height) : Shape
{
    public override double CalculateArea() => width * height;
}

New behavior is added by extension, not by changing old code.

Summary

Strong class design reduces coupling and change risk. Start with SRP and dependency inversion; these two practices usually deliver immediate maintainability gains.