No history yet

Advanced Object-Oriented Design

Architecting for Change

You know how to create classes, define properties, and make objects interact. That's the grammar of C#. Now, let's focus on the poetry: designing systems that are robust, flexible, and easy to maintain. Good architecture isn't about complex code; it's about making simple, smart decisions that prevent future headaches.

The key is to build software that welcomes change instead of breaking under its weight. This involves moving beyond monolithic scripts and thinking in terms of decoupled, specialised components. Let's start with the principles that guide this process.

The SOLID Foundation

SOLID is an acronym representing five design principles that are foundational to building good object-oriented systems. They aren't strict rules, but rather time-tested guidelines for creating software that is easier to understand, extend, and maintain.

The SOLID Principles are five principles of Object-Oriented class design.

Let's break them down with practical C# examples.

Single Responsibility Principle (SRP)

other

A class should have only one reason to change.

This principle is about focus. If a class handles user authentication, data validation, and database logging, it has three reasons to change. A change in the database schema would require modifying this class, as would a change in password validation rules. It's better to separate these concerns.

Consider a class that manages a report and also exports it:

// Violates SRP
public class Report
{
    public string Content { get; set; }

    public void GenerateReport()
    {
        // Logic to create the report content
    }

    public void ExportToPdf()
    {
        // Logic to save the content as a PDF file
    }
}

Here, Report is responsible for both generation and exportation. According to SRP, we should split this.

// Adheres to SRP
public class Report
{
    public string Content { get; set; }

    public void GenerateReport()
    {
        // Logic to create the report content
    }
}

public class ReportExporter
{
    public void ExportToPdf(Report report)
    {
        // Logic to save the report's content as a PDF
    }
}

Now, the Report class only cares about its content. The ReportExporter handles the separate concern of exporting. If we need to add a CSV export option, we only modify ReportExporter (or add a new class), leaving Report untouched.

Open/Closed Principle (OCP): Software entities should be open for extension but closed for modification.

This means you should be able to add new functionality without changing existing, working code. Let's imagine a payment processing system. A naive approach might use a big switch statement.

// Violates OCP
public class PaymentProcessor
{
    public void ProcessPayment(decimal amount, string paymentType)
    {
        switch (paymentType)
        {
            case "CreditCard":
                // Process credit card
                break;
            case "PayPal":
                // Process PayPal
                break;
            // Adding a new payment type requires modifying this class!
        }
    }
}

To add a new payment method like Apple Pay, you have to modify the PaymentProcessor class, which risks introducing bugs. A better approach uses polymorphism. We define a contract (an interface) that all payment methods must follow.

// Adheres to OCP
public interface IPaymentMethod
{
    void Process(decimal amount);
}

public class CreditCardPayment : IPaymentMethod
{
    public void Process(decimal amount) { /* ... */ }
}

public class PayPalPayment : IPaymentMethod
{
    public void Process(decimal amount) { /* ... */ }
}

// To add a new method, just create a new class:
public class ApplePayPayment : IPaymentMethod
{
    public void Process(decimal amount) { /* ... */ }
}

// The processor is now simple and stable.
public class PaymentProcessor
{
    public void ProcessPayment(IPaymentMethod paymentMethod, decimal amount)
    {
        paymentMethod.Process(amount);
    }
}

Now, our PaymentProcessor is closed for modification but open for extension. We can add endless payment types without ever touching its code.

Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types without altering the correctness of the program.

In simple terms, if you have a method that accepts a base class Animal, you should be able to pass it an instance of a derived class, like Dog or Cat, and everything should still work as expected. The derived class shouldn't break the base class's contract.

A classic example is the square-rectangle problem. Mathematically, a square is a rectangle. But in code, inheriting Square from Rectangle can cause trouble.

If a Rectangle class has separate Width and Height properties, a Square subclass would have to override the setters to keep width and height equal. A method that takes a Rectangle and sets its width to 10 and height to 20 would get an unexpected result if passed a Square – it might end up with a 20x20 square, breaking the method's logic.

Interface Segregation Principle (ISP): No client should be forced to depend on methods it does not use.

This is SRP for interfaces. Instead of one large, general-purpose interface, prefer several smaller, specific ones. Imagine an interface for managing documents:

// Violates ISP
public interface IDocumentManager
{
    void Open();
    void Save();
    void Print();
    void Fax();
}

If you create a ReadOnlyDocument class, it has no need for Save(), Print(), or Fax(). It would be forced to implement these methods, perhaps by throwing an exception, which is messy. ISP suggests we split the interface.

// Adheres to ISP
public interface IReadable
{
    void Open();
}

public interface IWritable
{
    void Save();
}

public interface IPrintable
{
    void Print();
}

public class ReadOnlyDocument : IReadable
{
    public void Open() { /* ... */ }
}

public class FullAccessDocument : IReadable, IWritable, IPrintable
{
    public void Open() { /* ... */ }
    public void Save() { /* ... */ }
    public void Print() { /* ... */ }
}

Now, classes only implement the functionality they actually provide.

Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.

This sounds academic, but it's about decoupling. Your high-level business logic shouldn't be directly tied to low-level implementation details like a specific database or a particular email service. Instead, both should depend on an interface.

For example, a NotificationService shouldn't directly create an EmailSender.

// Violates DIP
public class NotificationService
{
    private readonly EmailSender _emailSender = new EmailSender();

    public void NotifyUser(string message)
    {
        _emailSender.Send(message);
    }
}

This code is tightly coupled. The NotificationService knows it's using an EmailSender. What if you want to switch to SMS notifications? You'd have to change NotificationService. Instead, we "invert" the dependency.

// Adheres to DIP
public interface IMessageSender
{
    void Send(string message);
}

public class EmailSender : IMessageSender
{
    public void Send(string message) { /* ... */ }
}

public class SmsSender : IMessageSender
{
    public void Send(string message) { /* ... */ }
}

public class NotificationService
{
    private readonly IMessageSender _sender;

    // The dependency is "injected" via the constructor
    public NotificationService(IMessageSender sender)
    {
        _sender = sender;
    }

    public void NotifyUser(string message)
    {
        _sender.Send(message);
    }
}

Now, NotificationService depends only on the IMessageSender abstraction. It doesn't know or care about the concrete implementation. This practice of providing dependencies from an external source is called (DI), and it's a cornerstone of modern software design.

Choosing the Right Contract

Both abstract classes and interfaces define contracts that other classes can adhere to. They enable polymorphism, but they serve different purposes. Choosing the right one is crucial for a clean architecture.

FeatureInterfaceAbstract Class
Multiple InheritanceYes (a class can implement many interfaces)No (a class can inherit from only one base class)
ImplementationNo (methods are just signatures)Yes (can provide default method implementations)
Fields/StateNo (can only have properties)Yes (can have fields to store state)
ConstructorNoYes (can have constructors)
Access ModifiersAll members are publicMembers can have any access modifier
Use CaseDefines a capability or role (e.g., IEquatable, IDisposable)Defines a common identity or base behaviour (e.g., Shape, Animal)

Rule of thumb: Favour interfaces for defining what a class can do. Use an abstract class to define what a class is and to provide shared, common functionality that derived classes shouldn't have to re-implement.

For instance, in a system with different types of database connections (SQL, Oracle, etc.), you might have an abstract base class DbConnection that handles common logic like managing connection strings and timeouts. Concrete classes like SqlConnection and OracleConnection would inherit from it and implement the database-specific details. This is a good use case for an because all connections share a fundamental identity and some common code.

Building Flexible Systems

Composition over Inheritance

Inheritance is a powerful tool, but it can create rigid, fragile hierarchies. If a base class changes, all its descendants are affected. An alternative is composition: building complex objects by combining simpler ones.

Instead of a Monster class that inherits from a FlyingCreature which inherits from a Creature, you could have a Monster class that has a MovementBehaviour component. This component could be an instance of FlyingMovement, SwimmingMovement, or WalkingMovement. This makes it easy to create a flying, swimming monster by simply giving it both components, something that would be difficult with a strict inheritance tree.

This approach is more flexible. It allows you to change an object's behaviour at runtime by swapping out its component parts.

Access Modifiers for Encapsulation

Encapsulation is about hiding the internal complexity of an object and exposing only what is necessary. Access modifiers are the tools you use to enforce this.

  • public: Accessible from anywhere. Use this for the class's main API.
  • internal: Accessible only within the same project (assembly). This is great for helper classes or utility functions that you don't want to expose to external consumers of your library.
  • protected: Accessible within the class and by derived classes. Use this when you want to provide a hook for subclasses to extend functionality without making it public.
  • private: Accessible only within the class itself. This is the default and should be your go-to for all internal state and helper methods.

Using these correctly is key to creating components that are easy to use and hard to misuse. A well-encapsulated class is like a black box: you interact with its public interface without needing to know—or worry about—its internal workings.

Quiz Questions 1/6

Which SOLID principle states that a class should have only one reason to change?

Quiz Questions 2/6

A developer is designing a system to handle various payment methods (Credit Card, PayPal, etc.). To avoid a large switch statement and allow new payment methods to be added easily, they decide to create an IPaymentMethod interface that each payment class will implement. Which principle does this design primarily follow?

By applying these principles, you move from simply writing code to architecting software. You create systems that are not only functional today but are also prepared for the changes and new features of tomorrow.