DEV Community

Cover image for Contract Substitution Principle (Design by Interface)
Daniel Azevedo
Daniel Azevedo

Posted on

Contract Substitution Principle (Design by Interface)

Hey devs!!

Lately, I’ve been reflecting a lot on Design by Interface, also known as the Contract Substitution Principle. It’s a concept that really stuck with me because of how it helps keep my code flexible and maintainable. I know not everyone loves abstracting things this way—some might say it's overkill for certain projects—but I’ve personally seen how it can save headaches down the road, especially in systems like payroll processing.

Interestingly, this idea is closely related to one of the SOLID principles: the Liskov Substitution Principle (LSP). If you’re familiar with SOLID, you know that LSP states that objects of a subclass should be able to replace objects of the parent class without altering the correctness of the program. In other words, derived classes should behave in a way that doesn’t break the logic or expectations set by the base class.

This is exactly what we’re doing with Design by Interface. By coding to an interface (instead of directly to a concrete class), we allow different implementations to be swapped in and out without affecting the broader system. It’s like building your system with interchangeable parts—if one part changes, the rest of the system keeps running smoothly.

What Is the Contract Substitution Principle?

In a nutshell, Design by Interface is about coding to an interface rather than directly to concrete implementations. This means you’re setting up a contract between components of your system, ensuring they can be swapped out or modified without causing a ripple effect across the entire codebase.

In salary processing, for instance, where rules might change depending on the type of employee or contract type, following this principle is really helpful. Let me show you how this plays out with a simple example.

Example: Salary Processing by Employee Type

Let’s say we need to calculate salaries for different types of workers—full-time employees, freelancers, and part-time workers. Each has a different pay structure, so instead of handling this with a bunch of messy conditional logic, we can design a system where each type implements a common interface. That way, we keep things clean and open to future changes.

First, we define the contract:

public interface ISalaryCalculator
{
    decimal CalculateSalary();
}
Enter fullscreen mode Exit fullscreen mode

Each employee type then implements this interface with its own salary calculation logic:

public class FullTimeEmployee : ISalaryCalculator
{
    private decimal _baseSalary;

    public FullTimeEmployee(decimal baseSalary)
    {
        _baseSalary = baseSalary;
    }

    public decimal CalculateSalary()
    {
        return _baseSalary;
    }
}

public class Freelancer : ISalaryCalculator
{
    private decimal _hourlyRate;
    private int _hoursWorked;

    public Freelancer(decimal hourlyRate, int hoursWorked)
    {
        _hourlyRate = hourlyRate;
        _hoursWorked = hoursWorked;
    }

    public decimal CalculateSalary()
    {
        return _hourlyRate * _hoursWorked;
    }
}

public class PartTimeEmployee : ISalaryCalculator
{
    private decimal _hourlyRate;
    private int _hoursWorked;

    public PartTimeEmployee(decimal hourlyRate, int hoursWorked)
    {
        _hourlyRate = hourlyRate;
        _hoursWorked = hoursWorked;
    }

    public decimal CalculateSalary()
    {
        return _hourlyRate * _hoursWorked;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, instead of having your salary processor class know about the specific details of each employee type, it only knows that it needs something that can calculate a salary:

public class SalaryProcessor
{
    private readonly ISalaryCalculator _salaryCalculator;

    public SalaryProcessor(ISalaryCalculator salaryCalculator)
    {
        _salaryCalculator = salaryCalculator;
    }

    public void ProcessSalary()
    {
        var salary = _salaryCalculator.CalculateSalary();
        Console.WriteLine($"Processed salary: {salary}");
    }
}
Enter fullscreen mode Exit fullscreen mode

To use it, you just pass in whatever employee type you’re working with:

var fullTimeEmployee = new FullTimeEmployee(5000);
var freelancer = new Freelancer(50, 120);
var partTimeEmployee = new PartTimeEmployee(20, 100);

var salaryProcessor = new SalaryProcessor(fullTimeEmployee);
salaryProcessor.ProcessSalary(); // Output: Processed salary: 5000

salaryProcessor = new SalaryProcessor(freelancer);
salaryProcessor.ProcessSalary(); // Output: Processed salary: 6000

salaryProcessor = new SalaryProcessor(partTimeEmployee);
salaryProcessor.ProcessSalary(); // Output: Processed salary: 2000
Enter fullscreen mode Exit fullscreen mode

How This Relates to Liskov Substitution Principle

This approach is a direct application of the Liskov Substitution Principle (LSP). When we code to an interface like ISalaryCalculator, we’re ensuring that any class that implements this interface can be substituted without breaking the logic of the salary processing. The SalaryProcessor doesn’t care about the specific type of employee—it just knows that whatever it’s given will follow the contract set by ISalaryCalculator.

By following LSP, we make our systems more robust to changes. If a new type of employee, like a commission-based worker, needs to be added, we can just implement the ISalaryCalculator interface for that new type. No existing code needs to change, meaning we reduce the risk of introducing bugs.

Why This Works for Me

For me, this approach solves a ton of potential future problems. By coding to an interface, I’ve decoupled the logic that processes salaries from the specific details of how each employee type is paid. This makes it super easy to extend the system without touching existing code. If tomorrow we add a new type of employee, like commission-based or contract workers, I can just create a new class that implements ISalaryCalculator, and everything else will work as expected.

Not only does this reduce the chances of breaking existing functionality, but it also makes the system easier to test. For example, I can mock the ISalaryCalculator in my unit tests to validate that salary processing behaves as it should, without needing to create real employee objects.

Final Thoughts

I know that some people might find this level of abstraction unnecessary, especially for smaller systems, but I think the flexibility and clarity you gain from designing by interface are well worth the effort. The Contract Substitution Principle ensures your system is ready for change, making it easier to maintain and scale over time, and it closely ties into one of my favorite SOLID principles, the Liskov Substitution Principle.

What do you think? Have you used this principle in your projects?

Keep coding!!

Top comments (0)