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.
Komentar