Class Design Guidelines

Edit this page

A class or interface should have a single purpose (AV1000)

A class or interface should have a single purpose within the system it functions in. In general, a class either represents a primitive type like an email address or ISBN number, an abstraction of some business concept, a plain data structure, or is responsible for orchestrating the interaction between other classes. It is never a combination of those. This rule is widely known as the Single Responsibility Principle, one of the S.O.L.I.D. principles.

Tip: A class with the word And in it is an obvious violation of this rule.

Tip: Use Design Patterns to communicate the intent of a class. If you can’t assign a single design pattern to a class, chances are that it is doing more than one thing.

Tip: If you can’t find a good name for a class, or struggle to document what it does, it is probably doing too much.

Tip: If you describe what a class does to an AI assistant and it identifies multiple responsibilities, that’s a strong signal to split it up.

Note If you create a class representing a primitive type you can greatly simplify its use by making it immutable. Consider using an immutable record type for such cases.

Only create a constructor that returns a useful object (AV1001)

There should be no need to set additional properties before the object can be used for whatever purpose it was designed. However, if your constructor needs more than three parameters (which violates AV1561), your class might have too much responsibility (and violates AV1000).

Only include parameters in a constructor that most or all members need (AV1002)

A constructor exists to put the object in a valid, usable state. If a value or dependency is only needed by a single method, consider passing it as a method parameter instead. This keeps the constructor focused and avoids forcing every caller to provide something that is barely relevant to the object as a whole.

For internal types, this is straightforward to apply: since callers are within the same codebase, moving a rarely-used parameter from the constructor to a method parameter is a low-risk refactoring.

For public types, be more conservative. Removing a constructor parameter is a breaking API change, so consider whether the dependency is likely to be needed by future members before deciding to keep it out of the constructor.

Exception: Cross-cutting concerns such as logging or a clock abstraction (TimeProvider) are often needed broadly and may reasonably be injected through the constructor even if not every member uses them directly.

An interface should be small and focused (AV1003)

Interfaces should have a name that clearly explains their purpose or role in the system. Do not combine many vaguely related members on the same interface just because they were all on the same class. Separate the members based on the responsibility of those members, so that callers only need to call or implement the interface related to a particular task. This rule is more commonly known as the Interface Segregation Principle.

Tip: Avoid taking the name of the class and slapping an I in front of it. Instead, consider using role-based names like IFetchSomething or IProvideClusterwideLock that describe what the interface does rather than what class implements it.

Use an interface rather than a base class to support multiple implementations (AV1004)

If you want to expose an extension point from your class, expose it as an interface rather than as a base class. You don’t want to force users of that extension point to derive their implementations from a base class that might have an undesired behavior.

Use an interface to decouple classes from each other (AV1005)

Interfaces are a very effective mechanism for decoupling classes from each other:

  • They can prevent bidirectional associations.
  • They simplify the replacement of one implementation with another.
  • They allow the replacement of an expensive external service or resource with a temporary stub for use in a non-production environment.
  • They allow the replacement of the actual implementation with a dummy implementation or a fake object in a unit test.
  • Using a dependency injection framework you can centralize the choice of which class is used whenever a specific interface is requested.

Avoid static classes (AV1008)

With the exception of extension method containers, static classes very often lead to badly designed code. They are also very difficult, if not impossible, to test in isolation, unless you’re willing to use some very hacky tools.

Note: If you really need that static class, mark it as static so that the compiler can prevent instance members and instantiating your class. This relieves you of creating an explicit private constructor.

Don’t suppress compiler warnings using the new keyword (AV1010)

Compiler warning CS0114 is issued when breaking Polymorphism, one of the most essential object-orientation principles. The warning goes away when you add the new keyword, but it keeps sub-classes difficult to understand. Consider the following two classes:

public class Book
{
	public virtual void Print()
	{
		Console.WriteLine("Printing Book");
	}
}

public class PocketBook : Book
{
	public new void Print()
	{
		Console.WriteLine("Printing PocketBook");
	}
}

This will cause behavior that you would not normally expect from class hierarchies:

PocketBook pocketBook = new PocketBook();

pocketBook.Print(); // Outputs "Printing PocketBook "

((Book)pocketBook).Print(); // Outputs "Printing Book"

It should not make a difference whether you call Print() through a reference to the base class or through the derived class.

It should be possible to treat a derived type as if it were a base type (AV1011)

In other words, you should be able to pass an instance of a derived class wherever its base class is expected, without the callee knowing the derived class. A very notorious example of a violation of this rule is throwing a NotImplementedException when overriding methods from a base class. A less subtle example is not honoring the behavior expected by the base class.

// Wrong
public abstract class Shape { }

public class Circle : Shape
{
    public double Radius { get; set; }
}

public class Square : Shape
{
    public double Side { get; set; }
}

public class ShapeProcessor
{
    public double CalculateArea(Shape shape)
    {
        if (shape is Circle circle)
            return Math.PI * circle.Radius * circle.Radius;

        if (shape is Square square)
            return square.Side * square.Side;

        throw new NotSupportedException();
    }
}

Instead, use polymorphism:

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

public class Circle : Shape
{
    public double Radius { get; set; }

    public override double CalculateArea() => Math.PI * Radius * Radius;
}

public class Square : Shape
{
    public double Side { get; set; }

    public override double CalculateArea() => Side * Side;
}

Note: This rule is also known as the Liskov Substitution Principle, one of the S.O.L.I.D. principles.

Don’t cast a base class to one of its derived classes (AV1013)

Having dependencies from a base class to its derived classes goes against proper object-oriented design and might prevent other developers from adding new derived classes.

Avoid exposing the other objects an object depends on (AV1014)

If you find yourself writing code like this then you might be violating the Law of Demeter.

someObject.SomeProperty.GetChild().Foo()

An object should not expose any other classes it depends on because callers may misuse that exposed property or method to access the object behind it. By doing so, you allow calling code to become coupled to the class you are using, and thereby limiting the chance that you can easily replace it in a future stage.

Note: Using a class that is designed using the Fluent Interface pattern seems to violate this rule, but it is simply returning itself so that method chaining is allowed.

Exception: Inversion of Control or Dependency Injection frameworks often require you to expose a dependency as a public property. As long as this property is not used for anything other than dependency injection I would not consider it a violation.

Avoid bidirectional dependencies (AV1020)

This means that two classes know about each other’s public members or rely on each other’s internal behavior. Refactoring or replacing one of those classes requires changes on both parties and may involve a lot of unexpected work. The most obvious way of breaking that dependency is to introduce an interface for one of the classes and using Dependency Injection.

Exception: Domain models such as defined in Domain-Driven Design tend to occasionally involve bidirectional associations that model real-life associations. In those cases, make sure they are really necessary, and if they are, keep them in.

Classes should have state and behavior (AV1025)

In general, if you find a lot of data-only classes in your code base, you probably also have a few (static) classes with a lot of behavior (see AV1008). Use the principles of object-orientation explained in this section and move the logic close to the data it applies to.

Exception: The only exceptions to this rule are classes that are used to transfer data over a communication channel, also called Data Transfer Objects, or a class that wraps several parameters of a method. For DTOs, consider using an immutable record type instead of a class to make the intent explicit.

Classes should protect the consistency of their internal state (AV1026)

Validate incoming arguments from public members. For example:

public void SetAge(int years)
{
	AssertValueIsInRange(years, 0, 200, nameof(years));

	this.age = years;
}

Protect invariants on internal state. For example:

public void Render()
{
	AssertNotDisposed();

	// ...
}

Know when to use a record and when to use a class (AV1030)

Records and classes serve different purposes. Choosing the right one keeps the intent of your type clear. This rule focuses on record (record class) vs class; for value types, consider struct or record struct instead.

Use a record when:

  • The type represents data, not behavior (e.g. a DTO, a value object, or a response model).
  • Structural equality (compare by value, not by reference) is the correct semantics.
  • Immutability is desired (use init-only properties).
public record OrderSummary(Guid OrderId, decimal TotalAmount, DateTimeOffset CreatedAt);

Note: Record equality is shallow. If a record property is a collection (e.g. List<T> or an array), Equals and GetHashCode will not compare the contents, which can lead to subtle bugs.

Use a record struct when the same criteria apply, but you also want value-type semantics: stack allocation, no null, and copy-on-assignment. This is appropriate for small, frequently passed immutable data such as coordinates, colors, or date ranges. Avoid mutable record struct — mutations on a copy don’t affect the original, which leads to confusing bugs.

Use a class when:

  • The type has mutable state or significant behavior.
  • Identity matters (two instances with the same data are not necessarily equal).
  • The type manages a lifecycle (e.g. implements IDisposable).
public class OrderProcessor
{
    public void Process(Order order) { ... }
}

Mixing both in one type (e.g. a record with complex behavior and mutable state) is a sign of a design problem.

Consider a named delegate instead of an interface with a single method (AV1032)

An interface with a single method is essentially a named function signature. Unless you know that this interface will gain more members in the near future, a delegate is simpler, creates a more loosely coupled abstraction, and you don’t need mocking frameworks to provide a value in tests.

// Instead of this single-method interface...
public interface IOrderFilter
{
    bool Matches(Order order);
}

// ...consider a delegate
public delegate bool OrderFilter(Order order);

// Or simply use Func<T, bool>
public void Process(IEnumerable<Order> orders, OrderFilter filter)
{
    foreach (var order in orders.Where(filter))
    {
        // ...
    }
}

// In a test you can pass any method or lambda that matches the signature.
Process(orders, order => order.Value > 10000);

Prefer a single-method interface when:

  • Implementations need to carry state
  • The interface is supposed to provide a strong well-defined contract like IEquatable, IDisposable
  • You want to use Default Interface Methods
  • The interface is expected to gain additional members in the near future

Note: A simple lambda is technically compatible with a named delegate, but a named delegate offers better discoverability: find-usages, go-to-definition, and DI registration all work the same as with an interface.

Prefer primary constructors over regular constructors (AV1035)

C# 12 introduced primary constructors, which let you declare constructor parameters directly on the class or record declaration. This reduces boilerplate when the constructor mainly exists to assign dependencies to fields or auto-properties.

// Before primary constructors
public class OrderService
{
    private readonly IOrderRepository repository;

    public OrderService(IOrderRepository repository)
    {
        this.repository = repository;
    }
}

// With primary constructors
public class OrderService(IOrderRepository repository)
{
    public async Task<Order> GetOrderAsync(Guid id)
        => await repository.GetByIdAsync(id);
}

Prefer primary constructors when:

  • The constructor only captures dependencies or configuration values.
  • There is no complex initialization logic that would benefit from a named constructor body.

Avoid primary constructors when:

  • The constructor contains parameter validation or non-trivial initialization logic.