loader
Mastering Abstract Classes and Interfaces: Real-World Design Dilemmas

Imagine you’re organizing a workshop. You provide a detailed agenda (abstract class) for everyone to follow, but you also let guest speakers bring their unique style and content (interface). Similarly, in programming, abstract classes and interfaces give you the structure and flexibility needed to design robust and reusable systems.


Abstract Classes vs. Interfaces: What’s the Difference?

Abstract Classes

  • Purpose: Define a base for related classes.
  • Key Feature: Can have both shared logic and methods without implementation.
  • When to Use: When classes share code or state.

Example:

C#
public abstract class Notification
{
    public abstract void Send(string message);

    public void Log(string message)
    {
        Console.WriteLine($"Notification logged: {message}");
    }
}

Interfaces

  • Purpose: Define a contract for unrelated classes.
  • Key Feature: Only method signatures (before C# 8.0) or optional default implementations (from C# 8.0 onward).
  • When to Use: When enforcing a consistent API across diverse classes.

Example:

C#
public interface INotification
{
    void Send(string message);
}

Choosing Between Abstract Classes and Interfaces

Real-World Scenario: Notification System

If you’re building a notification system for email, SMS, and push alerts:

  • Use an abstract class for shared logic, like formatting messages or logging.
  • Use an interface for unrelated notifications that implement Send.

Example: Combining Both:

C#
public abstract class BaseNotification
{
    public void LogNotification(string message)
    {
        Console.WriteLine($"Logging: {message}");
    }

    public abstract void SendNotification(string message);
}

public interface INotification
{
    void SendNotification(string message);
}

Advanced Techniques for Enhanced Flexibility

1. Default Implementations in Interfaces

C# 8.0 allows default implementations in interfaces. This is useful for adding new functionality without breaking existing implementations.

C#
public interface INotification
{
    void SendNotification(string message);

    void ValidateMessage(string message)
    {
        Console.WriteLine("Validating message...");
    }
}

2. Abstract Classes with Dependency Injection

Pair abstract classes with Dependency Injection to build configurable systems. This approach lets you swap implementations at runtime without changing core code.

C#
public class NotificationService
{
    private readonly BaseNotification _notification;

    public NotificationService(BaseNotification notification)
    {
        _notification = notification;
    }

    public void Notify(string message)
    {
        _notification.SendNotification(message);
        _notification.LogNotification(message);
    }
}

3. Interface Segregation

For complex systems, divide large interfaces into smaller, focused ones to reduce unnecessary dependencies and increase modularity.

C#
public interface IMessageSender
{
    void SendMessage(string message);
}

public interface IMessageLogger
{
    void LogMessage(string message);
}

4. Combining Factories with Polymorphism

Use a factory pattern to dynamically choose implementations based on runtime context.

C#
public class NotificationFactory
{
    public static INotification GetNotification(string type)
    {
        return type switch
        {
            "Email" => new EmailNotification(),
            "SMS" => new SmsNotification(),
            _ => throw new ArgumentException("Invalid notification type")
        };
    }
}

Wrapping Up

Abstract classes and interfaces are essential tools for designing clean and scalable software. Abstract classes shine when you need shared logic, while interfaces enforce consistent behavior across diverse implementations. By combining them and leveraging advanced techniques, you can create frameworks that are flexible, maintainable, and ready for change.