Module 1 · 1. Foundations and Pattern Selection · Lesson 2 of 12
SOLID Principles and Composition Foundations
Learning outcome
You will be able to apply SOLID principles (SRP, OCP, LSP, ISP, DIP) and prefer composition over inheritance to make pattern choices predictable, avoid over-abstraction, and make maintainable production C# services. The lesson shows how to structure small domains, where to stop abstracting, and how to compose behaviors instead of chaining fragile inheritance trees.
Intuition
SOLID gives rules of thumb that reduce coupling and increase clarity. Composition encourages assembling small, focused collaborators (objects/strategies) instead of extending behavior through inheritance. The practical payoff: predictable change points, easier testing, and fewer surprises when requirements change.
- SRP: a class does one job — formatting, sending, or domain modeling.
- DIP: higher-level modules depend on abstractions (interfaces) so swapping behavior is trivial.
- Prefer composition when behavior changes independently; use inheritance for strict "is-a" relationships only.
A common smell is the "god base class": many optional virtual methods, parameter flags controlling behavior, or deep inheritance used to share a few lines of code. Composition turns those optional behaviors into small collaborators wired at construction time.
Deep dive
Below is a compact, production-minded example showing composition and SOLID: a notifier composed from a formatter and a transport. The notifier follows SRP, depends on interfaces (DIP), and is open for extension (OCP) because you can add new formatters or transports without changing Notifier. The example intentionally uses simple deterministic console output so behavior is clear in tests.
Now the composition-first approach:
This separation makes testing trivial: assert the formatter returns expected string; assert the sender was called with that string (mock or test double). It follows DIP: Notifier depends on abstractions.
A small practical tip: stop factoring when abstractions don't help you replace or test behavior. If an interface has exactly one implementation and is never mocked or replaced, it's likely premature.
Failure modes
- Over-abstraction: an interface per class when no substitution is required. Cost: more indirection, harder onboarding.
- Leaky abstractions: composing collaborators with shared mutable state yields surprising coupling.
- Violating LSP: derived implementations change contract expectations (e.g., formatter throws for certain inputs while base doesn't).
- Interface bloat: large interfaces forcing implementers to provide no-op methods. Prefer ISP: split by role.
Misuse warning: converting every small class to an interface "just in case" produces cognitive overhead and often breaks local reasoning during maintenance. Use interfaces where you need polymorphism, testing doubles, or explicit separation points.
Interview drill
1) Given a legacy class with three boolean flags that change output format, how would you refactor? (Expected: extract formatter strategies, inject via constructor, remove flags.)
2) When is inheritance preferable to composition? (Expected: strict "is-a" taxonomies, polymorphic substitutability with shared invariants.)
3) What SOLID principle prevents a high-level module from depending on concrete implementations? (DIP)
Micro-tests (whiteboard): show how you'd test Notifier without IO using a test double for INotificationSender.
Exercises:
- Replace the Console sender with a batched sender that collects messages and flushes them only when size > N. Explain how composition makes this change small.
- Identify one interface in your current project that could be collapsed into a concrete class safely; explain why.
Revision checklist
- Are responsibilities single and focused? (SRP)
- Can you add a new behavior without modifying existing classes? (OCP)
- Are derived types substitutable? (LSP)
- Are interfaces split by role? (ISP)
- Do high-level modules depend on interfaces not concretes? (DIP)
- Is an interface actually required (replacement/testing) or premature?
Production code
The following runnable example is intentionally minimal and deterministic. It demonstrates composition: a Notifier composed from an IMessageFormatter and an INotificationSender. It also includes a conventional extension method to show small ergonomics helpers. (Note on C# extension block: newer C# versions have evolved extension member syntax; the executable example uses classic extension methods for broad compatibility. See fact-check notes.)
Misuse warning in production: do not add interfaces for every class to satisfy an artificial "DI purity" rule. Introduce abstractions where substitution, testing, or clear separation of roles is required.
Code walkthrough
The companion Program.cs demonstrates:
- Order (immutable record) as small domain model.
- JsonFormatter implements IMessageFormatter.
- ConsoleSender implements INotificationSender and writes a deterministic string.
- Notifier composes formatter and sender and performs Notify.
- An extension helper is provided to show a small ergonomic add-on (classic extension method used for portability).
Walkthrough steps: 1) Construct domain object (Order). 2) Choose formatter and sender implementations. 3) Compose Notifier and call Notify. 4) Observe deterministic console output.
Interview prompt: explain how you'd swap ConsoleSender for an asynchronous network sender with retry without changing Notifier (answer: implement INotificationSender and inject; tests remain the same).
Executable code examples
Notifier composition example
Program.cs
using System;
// Minimal domain model
public sealed record Order(int Id, decimal Total);
// Small focused interfaces (DIP)
public interface IMessageFormatter { string Format(Order order); }
public interface INotificationSender { void Send(string payload); }
// Concrete formatter implementation (OCP: add new formatters without changing Notifier)
public sealed class JsonFormatter : IMessageFormatter {
public string Format(Order order) => $"Order:{order.Id}:{order.Total:0.00}";
}
// Concrete sender for deterministic output in tests
public sealed class ConsoleSender : INotificationSender {
public void Send(string payload) {
// Deterministic, single-line output suitable for automated checks
Console.WriteLine($"SENT: {payload}");
}
}
// Composition: Notifier composes roles rather than inherits behavior
public sealed class Notifier {
private readonly INotificationSender _sender;
private readonly IMessageFormatter _formatter;
public Notifier(INotificationSender sender, IMessageFormatter formatter) {
_sender = sender ?? throw new ArgumentNullException(nameof(sender));
_formatter = formatter ?? throw new ArgumentNullException(nameof(formatter));
}
public void Notify(Order order) {
// SRP: Notifier does orchestration only
var payload = _formatter.Format(order);
_sender.Send(payload);
}
}
// Small, conventional extension helper to improve readability in call sites
// Note: Some future C# versions introduce alternate extension member syntaxes (extension(...) block).
// This example uses classic extension method syntax for portability and deterministic compilation.
public static class OrderExtensions {
public static string ShortId(this Order order) => $"#{order.Id}";
}
class Program {
static void Main() {
// Deterministic scenario
var order = new Order(42, 123.45m);
// Compose the runtime behavior from small collaborators
IMessageFormatter formatter = new JsonFormatter();
INotificationSender sender = new ConsoleSender();
var notifier = new Notifier(sender, formatter);
// Use an extension helper in a production-friendly way
Console.WriteLine($"Processing {order.ShortId()}");
notifier.Notify(order);
// Why this beats a simpler alternative (single-class notifier):
// - Test: you can assert formatter output and sender invocation separately.
// - Replace: swap ConsoleSender for network sender without touching Notifier.
// Misuse warning: don't create interfaces for types you never swap or mock; prefer concrete classes until substitution is needed.
}
}