Mert Özen Further With Every Line
Backend Object-Oriented Programming in C# — #12

Composition in C# and an Introduction to SOLID

Mert Özen Nov 7, 2026 10 min 2 views
Composition in C# and an Introduction to SOLID

How a cart derived from List leaks its rules, having instead of being with composition, the five SOLID principles through Bookstore examples, and the cost of overdoing them.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

We want a cart that holds at most three items. What looks like the shortest route is to derive from List<OrderItem> and put a check in Add. The fourth item is rejected with an exception. So what does the last line print?

var cart = new LimitedCart(maxItems: 3);
cart.Add(new OrderItem("978-0441013593", 1));
cart.Add(new OrderItem("978-0141439518", 1));
cart.Add(new OrderItem("978-0547928227", 1));

try
{
    cart.Add(new OrderItem("978-0061120084", 1));
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

IList<OrderItem> list = cart;
list.Add(new OrderItem("978-0061120084", 1));
cart.AddRange(new[] { new OrderItem("978-0451524935", 1), new OrderItem("978-0743273565", 1) });

Console.WriteLine(cart.Count);

public record OrderItem(string Isbn, int Quantity);

public class LimitedCart : List<OrderItem>
{
    private readonly int _maxItems;

    public LimitedCart(int maxItems) => _maxItems = maxItems;

    public new void Add(OrderItem item)
    {
        if (Count >= _maxItems)
            throw new InvalidOperationException($"A cart can hold at most {_maxItems} items.");

        base.Add(item);
    }
}

The Doors Inheritance Leaves Open

Output:

A cart can hold at most 3 items.
6

The three-item cart holds six items. List<T>.Add is not virtual, so we could not override it and hid it with new instead. As we saw in the polymorphism post, new only kicks in when the variable's type is LimitedCart. The call through IList<OrderItem> went straight to List's own Add. AddRange never called our method at all. On top of that, Insert, InsertRange and the indexer are still open.

The real problem is not technical; it is about design. When you write LimitedCart : List<OrderItem>, you tell the world "a cart is a list, and you can do with a cart everything you can do with a list". That is not true; a cart has rules. Inheritance takes on the whole public surface of the base class, and every part of that surface makes a promise. Inheriting a promise you cannot keep turns your rules into a shield full of holes.

Before choosing inheritance, run this test: "Is B an A in every situation and in every sense?" An EBook really is a Book. A cart, however, is not a list; it has a list. This "has-a" relationship is called composition.

Composition: Having One

Let's write the same cart with composition, that is, by keeping the list inside. We also move the discount calculation out of the cart:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var cart = new ShoppingCart(maxItems: 3);
cart.Add(new OrderItem("978-0441013593", 3, 12.99m));
cart.Add(new OrderItem("978-0141439518", 1, 7.50m));

var pricer = new OrderPricer(new IDiscountPolicy[]
{
    new BulkDiscount(minQuantity: 3, rate: 0.10m),
    new CouponDiscount(enteredCode: "WELCOME5", validCode: "WELCOME5", amount: 5m)
});

Console.WriteLine($"Subtotal: {cart.Subtotal}");
Console.WriteLine($"Total:    {pricer.Total(cart)}");
Console.WriteLine($"Items:    {cart.Items.Count}");

public record OrderItem(string Isbn, int Quantity, decimal UnitPrice)
{
    public decimal LineTotal => Quantity * UnitPrice;
}

public sealed class ShoppingCart
{
    private readonly List<OrderItem> _items = new();
    private readonly int _maxItems;

    public ShoppingCart(int maxItems) => _maxItems = maxItems;

    public IReadOnlyList<OrderItem> Items => _items;

    public decimal Subtotal => _items.Sum(i => i.LineTotal);

    public void Add(OrderItem item)
    {
        if (_items.Count >= _maxItems)
            throw new InvalidOperationException($"A cart can hold at most {_maxItems} items.");

        _items.Add(item);
    }
}

public interface IDiscountPolicy
{
    decimal DiscountFor(ShoppingCart cart);
}

public sealed class BulkDiscount : IDiscountPolicy
{
    private readonly int _minQuantity;
    private readonly decimal _rate;

    public BulkDiscount(int minQuantity, decimal rate)
    {
        _minQuantity = minQuantity;
        _rate = rate;
    }

    public decimal DiscountFor(ShoppingCart cart) =>
        cart.Items.Where(i => i.Quantity >= _minQuantity).Sum(i => i.LineTotal * _rate);
}

public sealed class CouponDiscount : IDiscountPolicy
{
    private readonly string _enteredCode;
    private readonly string _validCode;
    private readonly decimal _amount;

    public CouponDiscount(string enteredCode, string validCode, decimal amount)
    {
        _enteredCode = enteredCode;
        _validCode = validCode;
        _amount = amount;
    }

    public decimal DiscountFor(ShoppingCart cart) => _enteredCode == _validCode ? _amount : 0m;
}

public sealed class OrderPricer
{
    private readonly IReadOnlyList<IDiscountPolicy> _policies;

    public OrderPricer(IReadOnlyList<IDiscountPolicy> policies) => _policies = policies;

    public decimal Total(ShoppingCart cart)
    {
        decimal discount = _policies.Sum(p => p.DiscountFor(cart));
        return Math.Round(Math.Max(0m, cart.Subtotal - discount), 2, MidpointRounding.AwayFromZero);
    }
}
Subtotal: 46.47
Total:    37.57
Items:    2

ShoppingCart keeps the list in a private field and exposes only two things: an Add that enforces the rule, and a read-only Items. There is no way around the rule anymore; casting to IList does not help either, because ShoppingCart is not a list. You decide what to expose, not the base class.

Think of a car. A car is not an engine; it has an engine. You do not redesign the car to change the engine; you fit another engine that matches the same mounting points. OrderPricer carries discounts the same way: it does not know how to calculate a discount itself, it asks the IDiscountPolicy objects it holds. That is the essence of the "composition over inheritance" advice: instead of burying behavior in a class hierarchy, build it from replaceable parts.

A small detail: without Math.Round the total would come out as 37.5730; the ten percent discount produces fractions. In money calculations, where rounding happens and with which rule should always be written out explicitly.

The First Two Letters of SOLID: S and O

SOLID is made of the initials of five principles for object-oriented design collected by Robert C. Martin. It is not a rulebook but a way of looking at code to understand why it has become hard to change. We can talk about all of them by looking at the code above.

Single Responsibility: a class should have only one reason to change. Imagine putting pricing, saving the order to the database and emailing the customer into a single OrderService. When campaign rules change, when the database schema changes or when the email template changes, you touch the same class; a change for one can break another. In our code the cart's rules live in ShoppingCart, the discount logic in the policy classes and the total calculation in OrderPricer. Each one changes for a different reason.

Open/Closed: code should be open for extension and closed for modification. If a "five percent for members" campaign arrives tomorrow, you do not touch OrderPricer; you write a new class that implements IDiscountPolicy and add it to the list. The alternative is a switch inside Total that grows a branch with every campaign; each new campaign means reopening and retesting working code.

L: Liskov Substitution

Liskov Substitution: code that uses a base class should keep working without surprises when it is handed one of the derived classes. Where it breaks usually looks like this:

var items = new List<Book>
{
    new PrintedBook("978-0441013593", "Dune", stock: 2),
    new EBook("978-0141439518", "Emma")
};

Restock(items, 5);

static void Restock(IEnumerable<Book> books, int amount)
{
    foreach (var book in books)
    {
        book.Stock += amount;
        Console.WriteLine($"{book.Title}: {book.Stock}");
    }
}

public abstract class Book
{
    protected Book(string isbn, string title)
    {
        Isbn = isbn;
        Title = title;
    }

    public string Isbn { get; }
    public string Title { get; }
    public abstract int Stock { get; set; }
}

public sealed class PrintedBook : Book
{
    public PrintedBook(string isbn, string title, int stock) : base(isbn, title) => Stock = stock;

    public override int Stock { get; set; }
}

public sealed class EBook : Book
{
    public EBook(string isbn, string title) : base(isbn, title) { }

    public override int Stock
    {
        get => int.MaxValue;
        set => throw new NotSupportedException("An e-book has no stock.");
    }
}
Dune: 7
Unhandled exception. System.NotSupportedException: An e-book has no stock.

(I removed the stack trace.) Restock trusted the promise Book made: every book has a stock that can be changed. EBook cannot keep that promise. When you see an override that throws NotSupportedException, stop and think; most of the time it tells you the hierarchy is built wrong. The problem here is that stock belongs to Book. Stock is a property of physical products only:

var items = new List<Book>
{
    new PrintedBook("978-0441013593", "Dune", stock: 2),
    new EBook("978-0141439518", "Emma")
};

Restock(items.OfType<IStocked>(), 5);

static void Restock(IEnumerable<IStocked> items, int amount)
{
    foreach (var item in items)
    {
        item.Stock += amount;
        Console.WriteLine($"{item.Isbn}: {item.Stock}");
    }
}

public interface IStocked
{
    string Isbn { get; }
    int Stock { get; set; }
}

public abstract class Book
{
    protected Book(string isbn, string title)
    {
        Isbn = isbn;
        Title = title;
    }

    public string Isbn { get; }
    public string Title { get; }
}

public sealed class PrintedBook : Book, IStocked
{
    public PrintedBook(string isbn, string title, int stock) : base(isbn, title) => Stock = stock;

    public int Stock { get; set; }
}

public sealed class EBook : Book
{
    public EBook(string isbn, string title) : base(isbn, title) { }
}
978-0441013593: 7

Now handing an EBook to Restock is a compile error instead of a runtime crash. Letting the compiler catch a bug is always cheaper than letting a test or a customer catch it.

I and D: Interface Segregation and Dependency Inversion

Interface Segregation: nobody should be forced to depend on methods they do not use. For a page that lists books, a single IBookRepository with reading, writing and CSV export methods is too big; when you write a fake repository in that page's test, you have to fill in the methods you never use as well. Splitting interfaces by need solves this:

var store = new InMemoryBookStore();
store.Add(new Book("978-0441013593", "Dune"));
store.Add(new Book("978-0141439518", "Emma"));

var page = new CatalogPage(store);
page.Show("du");

public record Book(string Isbn, string Title);

public interface IBookReader
{
    Book? FindByIsbn(string isbn);
    IReadOnlyList<Book> Search(string text);
}

public interface IBookWriter
{
    void Add(Book book);
    void Remove(string isbn);
}

public sealed class InMemoryBookStore : IBookReader, IBookWriter
{
    private readonly Dictionary<string, Book> _books = new();

    public Book? FindByIsbn(string isbn) => _books.TryGetValue(isbn, out var book) ? book : null;

    public IReadOnlyList<Book> Search(string text) =>
        _books.Values.Where(b => b.Title.Contains(text, StringComparison.OrdinalIgnoreCase)).ToList();

    public void Add(Book book) => _books[book.Isbn] = book;

    public void Remove(string isbn) => _books.Remove(isbn);
}

public sealed class CatalogPage
{
    private readonly IBookReader _books;

    public CatalogPage(IBookReader books) => _books = books;

    public void Show(string text)
    {
        foreach (var book in _books.Search(text))
            Console.WriteLine($"{book.Isbn} {book.Title}");
    }
}
978-0441013593 Dune

InMemoryBookStore implements both interfaces, but CatalogPage only knows IBookReader. The page does not know how to delete books, and it is better that way.

Dependency Inversion: high-level code should depend on abstractions, not on low-level details. We did not write new InMemoryBookStore() inside CatalogPage; we asked for an IBookReader in the constructor. OrderPricer does not create the discounts itself either; it receives them from outside. Which concrete class gets used is decided at the very start of the program (here, in the first lines of Program.cs). If books come from a database tomorrow, you hand CatalogPage another class that implements IBookReader without touching it. We wire these dependencies with a container instead of by hand in the dependency injection post of the Advanced series, and see the ASP.NET Core version in the .NET Core Web API series.

Common Pitfalls

Using inheritance to avoid duplicate code. Creating a shared base class because two classes have three lines in common locks them together later if there is no real "is-a" relationship between them. Putting the shared code in a separate class that both sides use (composition again) is usually more flexible.

Creating an interface for every class. An IOrderItemFormatter with a single implementation, and no expectation of another, only adds files. Put interfaces where things need to be replaceable: the points that reach the outside world (database, email, clock, file system) and behaviors that really come in several kinds (like discount policies).

Applying SOLID like a checklist. The principles exist to reduce the pain of change. Building five layers of abstraction in a small script that will never change turns the purpose of the principles upside down. Write it simply first, and split it when the need for change really arrives.

Overdoing composition too. A structure built from ten tiny parts, where you need to open ten files to see what each one does, is unreadable as well. The goal is not to split things into parts but for each part to have a single, understandable job.

A Small Experiment

★☆☆ Write a MemberDiscount that implements IDiscountPolicy and gives five percent off the cart subtotal. Add it to the list without touching OrderPricer at all and print the new total.

★★☆ What happens if the discounts add up to more than the cart amount? Remove the Math.Max line and apply a 15 EUR coupon to a 10 EUR cart. Is it right to keep this rule in OrderPricer, or inside each policy?

★★★ Add a rule to ShoppingCart that increases the quantity of the existing line instead of adding a new one when the same ISBN is added again. Since OrderItem is a record, you will need with for this. Which letter of SOLID does this change concern?

In this post we talked about how objects connect to each other. In the next post we look at how long objects live: how the garbage collector handles memory, why we cannot leave resources such as files and connections to it, and how IDisposable and using fill that gap.