Mastering Empty Interfaces in C#: Practical Examples for Domain-Driven Design and Beyond

Uncover the hidden power of empty interfaces in software design. Discover how marker interfaces and supertypes can elevate your system's flexibility and robustness.

amol ankit · 3 min read

Understanding Empty Interfaces in Programming

Empty interfaces often get a bad reputation among programmers. But when used correctly, they can be a powerful tool in your design arsenal. In this article, we'll explore two practical uses for empty interfaces: marker interfaces and supertypes, using an e-commerce platform as our example.

Marker Interfaces in Domain-Driven Design

In Domain-Driven Design (DDD), you often work with aggregates—clusters of related objects treated as a unit. Each aggregate has a root entity, and repositories should only deal with these root entities.

Let's say you're working on an e-commerce platform. You want to ensure that your repository only deals with orders as aggregates, not individual components like shipments.

Code Example:

// Marker Interface to Identify Aggregate Roots
public interface IAggregateRoot { }

// Domain Models
public class Order : IAggregateRoot
{
    public int OrderId { get; set; }
    public string CustomerName { get; set; }
    public List<Shipment> Shipments { get; set; }
}

public class Shipment
{
    public int ShipmentId { get; set; } = null!;
    public DateTime ShipmentDate { get; set; } = null!;
}

// Repository Interface with Constraint
public interface IRepository<T> where T : IAggregateRoot
{
    T GetById(int id);
    void Save(T entity);
}

// Order Repository Implementation
public class OrderRepository : IRepository<Order>
{
    // Methods to retrieve and save orders
    public Order GetById(int id) => new Order { OrderId = id, CustomerName = "John Doe" };

    public void Save(Order entity) { /* Save logic here */ }
}

// Usage
var orderRepo = new OrderRepository();
var order = orderRepo.GetById(1);

In this example, the IAggregateRoot interface is a marker interface, ensuring that only aggregate roots like Order can be used with the repository. Trying to use Shipment with the repository would result in a compile-time error.

Empty Interfaces as Supertypes

Empty interfaces can also serve as supertypes for a group of related classes, even if they don't initially define any methods. Over time, these interfaces may evolve to include functionality as the design demands.

Consider the example of different shipping methods. Each shipping method might have different characteristics but share a common type.

Code Example:

// Supertype Interface for Shipping Methods
public interface IShippingMethod { }

// Concrete Shipping Method Implementations
public class StandardShipping : IShippingMethod
{
    public int EstimatedDeliveryDays { get; set; } = 5;
}

public class ExpressShipping : IShippingMethod
{
    public int EstimatedDeliveryDays { get; init; } = 2;
}

// Shipment that uses Shipping Methods
public class Shipment
{
    public IShippingMethod ShippingMethod { get; init; } = null!;

    public void PrintShippingDetails()
    {
        switch (ShippingMethod)
        {
            case StandardShipping standardShipping:
                Console.WriteLine($"Standard Shipping - Estimated Delivery: {standardShipping.EstimatedDeliveryDays} days");
                break;
            case ExpressShipping expressShipping:
                Console.WriteLine($"Express Shipping - Estimated Delivery: {expressShipping.EstimatedDeliveryDays} days");
                break;
        }
    }
}

// Usage
var shipment = new Shipment { ShippingMethod = new ExpressShipping { EstimatedDeliveryDays = 2 } };
shipment.PrintShippingDetails();

Here, the IShippingMethod interface is a supertype for shipping methods like StandardShipping and ExpressShipping. Initially, this interface might be empty, but it serves as a foundation for organizing related classes. As your design evolves, you can add methods to IShippingMethod, and all implementing classes will need to adapt, ensuring consistency across your application.

Evolution of Empty Interfaces

As your software evolves, what started as an empty interface might eventually gain methods. This transition from an empty to a non-empty interface is a natural part of an evolving design.

Code Example:

// Adding Method to the IShippingMethod Interface
public interface IShippingMethod
{
    void AdvanceShippingMethod();
}

public class StandardShipping : IShippingMethod
{
    public int EstimatedDeliveryDays { get; set; } = 5;

    public void AdvanceShippingMethod()
    {
        EstimatedDeliveryDays--;
    }
}

public class ExpressShipping : IShippingMethod
{
    public int EstimatedDeliveryDays { get; set; } = 2;

    public void AdvanceShippingMethod()
    {
        EstimatedDeliveryDays--;
    }
}

// Usage
var standardShipping = new StandardShipping();
standardShipping.AdvanceShippingMethod();

Console.WriteLine($"Updated Estimated Delivery Days: {standardShipping.EstimatedDeliveryDays}");

var expressShipping = new ExpressShipping();
expressShipping.AdvanceShippingMethod();

Console.WriteLine($"Updated Estimated Delivery Days: {expressShipping.EstimatedDeliveryDays}");

In this example, the AdvanceShippingMethod method is added to the IShippingMethod interface. All classes implementing IShippingMethod must now implement this method. This is a powerful demonstration of how empty interfaces can evolve naturally with your design.

If you want to play with this code, you can find it here on GitHub.

Conclusion

Empty interfaces might seem trivial initially, but they play crucial roles in software design. Whether as marker interfaces to enforce design constraints or as supertypes that evolve, they help you build flexible and robust systems. Embrace them where appropriate, and they'll serve you well in the long run.