DEV Community

Muhammad Salem
Muhammad Salem

Posted on

How to leverage polymorphism to design flexible and maintainable software that adheres to SOLID principles

I'll provide a comprehensive tutorial on leveraging polymorphism to design flexible and maintainable software that adheres to SOLID principles, using C# for examples.

Tutorial: Leveraging Polymorphism for Flexible and Maintainable Software Design

  1. Introduction to Polymorphism

Polymorphism is a core concept in object-oriented programming that allows objects of different types to be treated as objects of a common base type. It enables you to write more flexible and extensible code.

There are two main types of polymorphism:

  • Compile-time polymorphism (method overloading)
  • Runtime polymorphism (method overriding)

We'll focus on runtime polymorphism as it's more relevant to designing flexible systems.

  1. SOLID Principles Overview

Before we dive into the implementation, let's briefly review the SOLID principles:

  • Single Responsibility Principle (SRP)
  • Open/Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

We'll see how polymorphism helps us adhere to these principles.

  1. Practical Example: Notification System

Let's design a notification system for an e-commerce platform. The system should be able to send notifications via email, SMS, and push notifications, with the ability to easily add new notification methods in the future.

Step 1: Define the Interface

First, we'll define an interface for our notification system:

public interface INotificationService
{
    void SendNotification(string recipient, string message);
}
Enter fullscreen mode Exit fullscreen mode

This adheres to the Interface Segregation Principle by keeping the interface focused and simple.

Step 2: Implement Concrete Classes

Now, let's implement concrete classes for each notification method:

public class EmailNotificationService : INotificationService
{
    public void SendNotification(string recipient, string message)
    {
        // Email-specific logic here
        Console.WriteLine($"Sending email to {recipient}: {message}");
    }
}

public class SmsNotificationService : INotificationService
{
    public void SendNotification(string recipient, string message)
    {
        // SMS-specific logic here
        Console.WriteLine($"Sending SMS to {recipient}: {message}");
    }
}

public class PushNotificationService : INotificationService
{
    public void SendNotification(string recipient, string message)
    {
        // Push notification-specific logic here
        Console.WriteLine($"Sending push notification to {recipient}: {message}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Each class has a single responsibility, adhering to the Single Responsibility Principle.

Step 3: Implement a Notification Manager

Now, let's create a NotificationManager class that will use these services:

public class NotificationManager
{
    private readonly INotificationService _notificationService;

    public NotificationManager(INotificationService notificationService)
    {
        _notificationService = notificationService;
    }

    public void Notify(string recipient, string message)
    {
        _notificationService.SendNotification(recipient, message);
    }
}
Enter fullscreen mode Exit fullscreen mode

This class adheres to the Dependency Inversion Principle by depending on the abstraction (INotificationService) rather than concrete implementations.

Step 4: Using the Notification System

Here's how we can use our notification system:

class Program
{
    static void Main(string[] args)
    {
        // Using email notification
        var emailManager = new NotificationManager(new EmailNotificationService());
        emailManager.Notify("user@example.com", "Your order has been shipped!");

        // Using SMS notification
        var smsManager = new NotificationManager(new SmsNotificationService());
        smsManager.Notify("+1234567890", "Your order has been shipped!");

        // Using push notification
        var pushManager = new NotificationManager(new PushNotificationService());
        pushManager.Notify("user123", "Your order has been shipped!");
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Benefits and SOLID Principles in Action
  • Open/Closed Principle: Our system is open for extension (we can add new notification services) but closed for modification (we don't need to change existing code to add new services).

  • Liskov Substitution Principle: Any INotificationService can be used interchangeably in the NotificationManager.

  • Dependency Inversion: The NotificationManager depends on the INotificationService abstraction, not on concrete implementations.

  1. Adding a New Notification Method

To demonstrate the flexibility of this design, let's add a new notification method - Slack notifications:

public class SlackNotificationService : INotificationService
{
    public void SendNotification(string recipient, string message)
    {
        // Slack-specific logic here
        Console.WriteLine($"Sending Slack message to {recipient}: {message}");
    }
}

// Usage
var slackManager = new NotificationManager(new SlackNotificationService());
slackManager.Notify("#general", "New product announcement!");
Enter fullscreen mode Exit fullscreen mode

We've added a new notification method without changing any existing code, demonstrating the power of polymorphism and adherence to SOLID principles.

  1. Further Enhancements

To make the system even more flexible, consider the following enhancements:

  • Factory Pattern: Implement a factory to create the appropriate INotificationService based on configuration or runtime parameters.
  • Composite Pattern: Create a CompositeNotificationService that can send notifications via multiple channels simultaneously.
  • Decorator Pattern: Add cross-cutting concerns like logging or retry logic without modifying existing services.

Conclusion:

By leveraging polymorphism and adhering to SOLID principles, we've created a notification system that is:

  • Flexible: Easy to add new notification methods
  • Maintainable: Each class has a single responsibility
  • Extensible: New functionality can be added without modifying existing code
  • Testable: Dependencies are easily mockable for unit testing

This approach can be applied to many different domains to create robust, flexible, and maintainable software systems.

Here's another example:

Object-Oriented Design for a Parking Lot

Based on the provided requirements, we can identify the following core concepts:

  • ParkingLot: Represents the entire parking facility with multiple floors.
  • Floor: Represents a single floor within the parking lot.
  • ParkingSpot: Represents a single parking space.
  • Vehicle: Represents a vehicle that can park.
  • Ticket: Represents a parking ticket issued to a customer.
  • Payment: Represents a payment transaction.
  • User: Represents a customer or an admin.

Core Classes and Relationships

ParkingLot

  • Attributes: floors, entryPoints, exitPoints, displayBoard
  • Methods: addFloor, removeFloor, getAvailableSpots, displayParkingStatus

Floor

  • Attributes: floorNumber, parkingSpots, displayBoard
  • Methods: getAvailableSpotsByVehicleType

ParkingSpot

  • Attributes: spotNumber, spotType, isOccupied, vehicle (if occupied)
  • Methods: isAvailable, occupy, vacate

Vehicle

  • Attributes: vehicleType, licensePlate
  • Methods: park, unpark

Ticket

  • Attributes: ticketNumber, issueTime, vehicle, totalAmount
  • Methods: calculateAmount

Payment

  • Attributes: paymentMethod, amount, ticket
  • Methods: processPayment

User

  • Attributes: userType (customer, admin, parkingAttendant)
  • Methods: generateTicket, payTicket, viewParkingStatus

Polymorphism and SOLID Principles

  • Vehicle: We can introduce an abstract Vehicle class with properties like vehicleType and methods like park and unpark. This allows us to create different vehicle types (car, truck, motorcycle, etc.) by inheriting from the Vehicle class. This adheres to the Open-Closed Principle as we can add new vehicle types without modifying existing code.
  • ParkingSpot: We can create an abstract ParkingSpot class with properties like spotNumber, isOccupied, and methods like isAvailable, occupy, and vacate. Different parking spot types (compact, large, handicapped, electric) can inherit from this base class, providing specific implementations for their unique characteristics. This adheres to the Liskov Substitution Principle as any ParkingSpot can be treated as a generic ParkingSpot.
  • Payment: We can introduce an interface IPaymentProcessor with a processPayment method. Different payment methods (cash, credit card) can implement this interface, allowing for flexible payment options. This adheres to the Interface Segregation Principle as clients only need to know about the IPaymentProcessor interface.

Additional Considerations

  • ParkingRate: A separate class to manage parking rates based on time and vehicle type.
  • DisplayBoard: An abstract class or interface for different types of display boards (LED, LCD).
  • TicketGenerator: A class responsible for generating unique ticket numbers.
  • PaymentGateway: A class to handle integration with different payment providers.

Design Patterns

  • Factory Pattern: To create different types of vehicles and parking spots.
  • Strategy Pattern: To implement different payment strategies.
  • Observer Pattern: To notify interested parties (e.g., display boards) when parking status changes.

Implementing Polymorphism and SOLID in Parking Lot System

Polymorphism in Vehicle Class

public abstract class Vehicle
{
    public string LicensePlate { get; set; }
    public VehicleType Type { get; set; }

    public abstract int CalculateParkingFee(int hoursParked);
    public abstract bool FitsInSpot(ParkingSpot spot);
}

public class Car : Vehicle
{
    public override int CalculateParkingFee(int hoursParked)
    {
        // Calculate fee based on car parking rates
        return ...;
    }

    public override bool FitsInSpot(ParkingSpot spot)
    {
        return spot.SpotType == SpotType.Compact || spot.SpotType == SpotType.Large;
    }
}

public class Truck : Vehicle
{
    public override int CalculateParkingFee(int hoursParked)
    {
        // Calculate fee based on truck parking rates
        return ...;
    }

    public override bool FitsInSpot(ParkingSpot spot)
    {
        return spot.SpotType == SpotType.Large;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Polymorphism: The Vehicle class is an abstract base class with the CalculateParkingFee and FitsInSpot methods defined as abstract. Derived classes like Car and Truck provide concrete implementations for these methods.
  • SOLID: Adheres to the Open-Closed Principle as new vehicle types can be added without modifying existing code.

Polymorphism in ParkingSpot Class

public abstract class ParkingSpot
{
    public int SpotNumber { get; set; }
    public bool IsOccupied { get; set; }
    public SpotType SpotType { get; set; }

    public abstract bool CanPark(Vehicle vehicle);
}

public class CompactSpot : ParkingSpot
{
    public override bool CanPark(Vehicle vehicle)
    {
        return vehicle.Type == VehicleType.Car || vehicle.Type == VehicleType.Motorcycle;
    }
}

public class LargeSpot : ParkingSpot
{
    public override bool CanPark(Vehicle vehicle)
    {
        return true; // Can accommodate any vehicle
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Polymorphism: The ParkingSpot class is an abstract base class with the CanPark method. Derived classes like CompactSpot and LargeSpot provide specific implementations based on the vehicle type.
  • SOLID: Adheres to the Liskov Substitution Principle as any ParkingSpot can be treated as a base ParkingSpot.

Payment Processor Interface

public interface IPaymentProcessor
{
    void ProcessPayment(decimal amount, Ticket ticket);
}
Enter fullscreen mode Exit fullscreen mode
  • SOLID: Adheres to the Interface Segregation Principle by defining a specific interface for payment processing.

Payment Processor Implementations

public class CreditCardPaymentProcessor : IPaymentProcessor
{
    public void ProcessPayment(decimal amount, Ticket ticket)
    {
        // Process payment using credit card details
    }
}

public class CashPaymentProcessor : IPaymentProcessor
{
    public void ProcessPayment(decimal amount, Ticket ticket)
    {
        // Process cash payment
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Polymorphism: Different payment processors implement the IPaymentProcessor interface.
  • SOLID: Adheres to the Open-Closed Principle as new payment methods can be added without modifying existing code.

Additional Considerations

  • Dependency Injection: Use dependency injection to inject payment processors into the parking lot system, promoting loose coupling.
  • Factory Pattern: Create a ParkingSpotFactory to create different types of parking spots based on configuration.
  • Strategy Pattern: Use the strategy pattern for different parking fee calculation strategies.

By applying these principles and patterns, we can create a flexible and maintainable parking lot system that can easily accommodate changes in requirements and new features.

Implementing the Factory Pattern in the Parking Lot System

Understanding the Need for a Factory

In our parking lot system, we have various types of parking spots (Compact, Large, Handicapped, etc.) and vehicles (Car, Truck, Motorcycle, etc.). To decouple the creation of these objects from the client code and make the system more flexible, we can introduce a Factory pattern.

Creating the Factory

public interface ISpotFactory
{
    ParkingSpot CreateParkingSpot(SpotType spotType);
}

public class ParkingSpotFactory : ISpotFactory
{
    public ParkingSpot CreateParkingSpot(SpotType spotType)
    {
        switch (spotType)
        {
            case SpotType.Compact:
                return new CompactSpot();
            case SpotType.Large:
                return new LargeSpot();
            // ... other spot types
            default:
                throw new ArgumentException("Invalid spot type");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Using the Factory

// In ParkingLot class
private readonly ISpotFactory _spotFactory;

public ParkingLot(ISpotFactory spotFactory)
{
    _spotFactory = spotFactory;
}

public void AddParkingSpot(SpotType spotType)
{
    var parkingSpot = _spotFactory.CreateParkingSpot(spotType);
    // ... add parking spot to the floor
}
Enter fullscreen mode Exit fullscreen mode

Benefits of Using the Factory Pattern

  • Decoupling: The client code (ParkingLot) is decoupled from the concrete implementations of parking spots.
  • Flexibility: New spot types can be added without modifying the ParkingLot class.
  • Extensibility: The factory can be customized or replaced with different implementations.

Additional Considerations

  • Dependency Injection: Use dependency injection to inject the ISpotFactory into the ParkingLot class, promoting loose coupling.
  • Factory Method Pattern: If you need more flexibility, consider using the Factory Method pattern where the creation logic is moved to subclasses of a factory.
  • Abstract Factory Pattern: For creating families of related objects, like different types of vehicles and parking spots together, the Abstract Factory pattern might be suitable.

By applying the Factory pattern, we've improved the flexibility and maintainability of our parking lot system. It's now easier to introduce new types of parking spots without affecting the existing code.

Top comments (1)

Collapse
 
mahmoudshrif5 profile image
mahmoud shrif

Great Content, thx for sharing. I learned A LOT❤️❤️❤️