DEV Community

Cover image for Working with interfaces
Karen Payne
Karen Payne

Posted on

Working with interfaces

Introduction

In C#, interfaces are powerful for designing flexible and reusable code. An interface defines a contract that classes or structs can implement, specifying what methods, properties, events, or indexers they must provide without dictating how they should do so. This abstraction allows developers to build loosely coupled systems where components can interact seamlessly, regardless of their underlying implementations. By adhering to interfaces, you can simplify testing, enhance code maintainability, and support polymorphism, making it easier to extend and modify applications as requirements evolve.

Source code

Basic example

In a team of developers, each developer may have their own naming conventions which for multiple projects leads to inconsistencies. This makes it difficult to move from project to project along with writing generic code.

In the following classes each has an identifier that in two cases is constant while one is not along with if in code there is a need to get at the identifier additional code is required.

public class Person
{
    public int PersonIdentifier { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateOnly BirthDate { get; set; }
    public Gender Gender { get; set; }
    public Language Language { get; set; }
}

public class Customer
{
    public int CustomerIdentifier { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string AccountNumber { get; set; }
}

public class Product
{
    public int ProdId { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

To remedy the identifier issue, the following interface and code reviews provide consistency.

/// <summary>
/// Represents an entity with a unique identifier.
/// </summary>
/// <remarks>
/// This interface is implemented by classes that require an identifier property.
/// It is commonly used to standardize the identification of entities across the application.
/// </remarks>
public interface IIdentity
{
    public int Id { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Implementing the interface, for each class, Id points to the original identifier.

public class Person : IIdentity
{
    public int Id
    {
        get => PersonIdentifier;
        set => PersonIdentifier = value;
    }

    public int PersonIdentifier { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateOnly BirthDate { get; set; }
    public Gender Gender { get; set; }
    public Language Language { get; set; }
}

public class Customer : IIdentity
{
    public int Id
    {
        get => CustomerIdentifier;
        set => CustomerIdentifier = value;
    }
    public int CustomerIdentifier { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string AccountNumber { get; set; }
}

public class Product : IIdentity
{
    public int Id
    {
        get => ProdId;
        set => ProdId = value;
    }
    public int ProdId { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

To test this out, the classes above are used which implement IIdentity and a Category class which does not implement IIdentity.

internal partial class Program
{
    private static void Main(string[] args)
    {

        Person person = new()
        {
            PersonIdentifier = 1,
            FirstName = "John",
            LastName = "Doe",
            BirthDate = new DateOnly(1980, 1, 1)
        };

        if (person is IIdentity p)
        {
            AnsiConsole.MarkupLine($"[cyan]Id[/] " +
                                   $"{p.Id,-3}[cyan]PersonIdentifier[/] " +
                                   $"{person.PersonIdentifier}");
        }
        else
        {
            AnsiConsole.MarkupLine("[red]Person is not an IIdentity[/]");
        }

        Customer customer = new()
        {
            CustomerIdentifier = 2,
            FirstName = "Jane",
            LastName = "Doe",
            AccountNumber = "1234567890"
        };

        if (customer is IIdentity c)
        {
            AnsiConsole.MarkupLine($"[cyan]Id[/] " +
                                   $"{c.Id,-3}[cyan]CustomerIdentifier[/] " +
                                   $"{customer.CustomerIdentifier}");
        }
        else
        {
            AnsiConsole.MarkupLine("[red]Customer is not an IIdentity[/]");
        }

        Product product = new()
        {
            ProdId = 3,
            Name = "Widget",
            Price = 9.99m
        };

        if (product is IIdentity prod)
        {
            AnsiConsole.MarkupLine($"[cyan]Id[/] " +
                                   $"{prod.Id,-3}[cyan]ProdId[/] " +
                                   $"{product.ProdId}");
        }
        else
        {
            AnsiConsole.MarkupLine("[red]Product is not an IIdentity[/]");
        }

        Category category = new()
        {
            CategoryId = 4,
            Name = "Widgets"
        };

        if (category is IIdentity cat)
        {
            AnsiConsole.MarkupLine($"[cyan]Id[/] " +
                                   $"{cat.Id,-3}[cyan]CategoryId[/] " +
                                   $"{category.CategoryId}");
        }
        else
        {
            AnsiConsole.MarkupLine("[red]Category is not an IIdentity[/]");
        }


        Console.ReadLine();
    }
}
Enter fullscreen mode Exit fullscreen mode

Note person, customer and product display Id from IIdentity while category shows that the class does not implement IIdentity using type checking with pattern matching.

Displayed results from code above

Multiple interfaces

Using Person and Customer classes, each had a FirstName, LastName but not BirthDate, Gender or Language.

The following interfaces ensures each class which implements this interface has FirstName and LastName as with the first example for id, a developer might use First_Name and Last_Name or totally different naming convention. In this case they can implement FirstName and LastName to point to their property names.

Since each class is assumed to be used to store some type of person, their birth date, gender and spoken language are required. Keep in mind these properties may not be necessary, and that birth date could also be a date time or even a date time offset depending on business requirements such as a doctor recording a birth date in a hospital.

/// <summary>
/// Represents a human entity with basic personal attributes.
/// </summary>
/// <remarks>
/// This interface defines the essential properties that describe a human, 
/// such as their name, birthdate, gender, and preferred language.
/// It is intended to be implemented by classes that model human-related entities.
/// </remarks>
public interface IHuman
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateOnly BirthDate { get; set; }
    public Gender Gender { get; set; }
    public Language Language { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Revised classes

public class Person : IIdentity, IHuman
{
    public int Id
    {
        get => PersonIdentifier;
        set => PersonIdentifier = value;
    }

    public int PersonIdentifier { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateOnly BirthDate { get; set; }
    public Gender Gender { get; set; }
    public Language Language { get; set; }
}

public class Customer : IIdentity, IHuman
{
    public int Id
    {
        get => CustomerIdentifier;
        set => CustomerIdentifier = value;
    }
    public int CustomerIdentifier { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateOnly BirthDate { get; set; }
    public Gender Gender { get; set; }
    public Language Language { get; set; }
    public string AccountNumber { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Example which uses dual pattern matching rather and condition && condition as one would do in earlier frameworks to determine, in this case if Person implements IIdentity and IHuman.

private static void ImplementsIdentifyAndHuman()
{
    Person newPerson = new()
    {
        PersonIdentifier = 5,
        FirstName = "Tatiana",
        LastName = "Mikhaylov",
        BirthDate = new DateOnly(1990, 5, 15),
        Gender = Gender.Female,
        Language = Language.Russian
    };

    if (newPerson is IIdentity p and IHuman h)
    {
        AnsiConsole.MarkupLine($"[cyan]Id[/] {p.Id, -3}[cyan] " +
                               $"First[/] {h.FirstName} [cyan] " +
                               $"Last[/] {h.LastName} [cyan] " +
                               $"Gender[/] {h.Gender} [cyan] " +
                               $"Language[/] {h.Language} [cyan] " +
                               $"Birth[/] {h.BirthDate}");
    }
    else
    {
        AnsiConsole.MarkupLine("[red]newPerson does not use IIdentity/IHuman");
    }
}
Enter fullscreen mode Exit fullscreen mode

results from above method

INotifyPropertyChanged

INotifyPropertyChanged notifies clients that a property value has changed which is used often in Windows Forms projects.

In the following sample.

  • The Person class (Person.cs) is divided into two files, the main class file contains properties.
  • The class file PersonNotify.cs contains code which is required by INotifyPropertyChanged.
  • field - Field backed property (currently in preview) is used rather than separate private fields for each property which is cleaner than conventual fields.

PersonNofiy.cs

/// <summary>
/// Implements <see cref="INotifyPropertyChanged"/> interfaces.
/// </summary>
public partial class Person
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged(string propertyName)
        => PropertyChanged?.Invoke(this, new(propertyName));

    protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Person.cs

/// <summary>
/// Represents a person with identifiable and human-related attributes.
/// </summary>
/// <remarks>
/// This class implements the <see cref="IIdentity"/> and 
/// <see cref="IHuman"/> interfaces, providing properties 
/// for unique identification and personal details such as name, birthdate, gender, 
/// and language. It also supports property change notifications through 
/// <see cref="INotifyPropertyChanged"/>.
/// </remarks>
public partial class Person : IIdentity, IHuman, INotifyPropertyChanged
{
    public int Id
    {
        get => PersonIdentifier;
        set => PersonIdentifier = value;
    }

    public int PersonIdentifier { get; set; }
    public string FirstName
    {
        get => field.TrimEnd();
        set => SetField(ref field, value, nameof(FirstName));
    }

    public string LastName
    {
        get => field.TrimEnd();
        set => SetField(ref field, value, nameof(LastName));
    }

    public DateOnly BirthDate
    {
        get;
        set => SetField(ref field, value, nameof(BirthDate));
    }
    public Gender Gender
    {
        get;
        set => SetField(ref field, value, nameof(Gender));
    }

    public Language Language
    {
        get;
        set => SetField(ref field, value, nameof(Language));
    }
}
Enter fullscreen mode Exit fullscreen mode

Example

For this example, the code has been modified to log notifications to a file using SeriLog NuGet package.

PersonNotify.cs

Note the logging only occurs while running in Microsoft Visual Studio or Microsoft Visual Studio Code.

public partial class Person
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged(string propertyName)
        => PropertyChanged?.Invoke(this, new(propertyName));

    protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
    {
        if (Debugger.IsAttached)
            Log.Information("Property: {P}", propertyName);

        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;

        if (Debugger.IsAttached)
            Log.Information("   Value: {V}", value);
        OnPropertyChanged(propertyName);
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Method in Program.cs

private static void LogAndModifyPerson()
{
    Log.Information($"Creating new person\n{new string('-', 80)}");
    Person newPerson = new()
    {
        PersonIdentifier = 5,
        FirstName = "Tatiana",
        LastName = "Mikhaylov",
        BirthDate = new DateOnly(1990, 5, 15),
        Gender = Gender.Female,
        Language = Language.Russian
    };

    Log.Information($"Modifying person\n{new string('-',80)}");

    newPerson.FirstName = "Jane";
    newPerson.Language = Language.English;
    AnsiConsole.MarkupLine("[cyan]See log file[/]");
}
Enter fullscreen mode Exit fullscreen mode

Log file after running the code in LogAndModifyPerson.

[2025-01-01 15:49:28.381 [Information] Creating new person
--------------------------------------------------------------------------------
[2025-01-01 15:49:28.403 [Information] Property: "FirstName"
[2025-01-01 15:49:28.404 [Information]    Value: "Tatiana"
[2025-01-01 15:49:28.405 [Information] Property: "LastName"
[2025-01-01 15:49:28.405 [Information]    Value: "Mikhaylov"
[2025-01-01 15:49:28.405 [Information] Property: "BirthDate"
[2025-01-01 15:49:28.405 [Information]    Value: 05/15/1990
[2025-01-01 15:49:28.406 [Information] Property: "Gender"
[2025-01-01 15:49:28.407 [Information] Property: "Language"
[2025-01-01 15:49:28.407 [Information]    Value: Russian
[2025-01-01 15:49:28.408 [Information] Modifying person
--------------------------------------------------------------------------------
[2025-01-01 15:49:28.408 [Information] Property: "FirstName"
[2025-01-01 15:49:28.408 [Information]    Value: "Jane"
[2025-01-01 15:49:28.408 [Information] Property: "Language"
[2025-01-01 15:49:28.408 [Information]    Value: English
Enter fullscreen mode Exit fullscreen mode

Dependency Injection

Using interfaces for Dependency Injection (DI) promotes flexibility, maintainability, and testability in software design. By programming to an interface rather than a concrete implementation, developers can easily swap out dependencies without altering the core application logic, adhering to the Dependency Inversion Principle.

This decoupling enables easier unit testing, as mock or stub implementations of the interfaces can be injected into tests, isolating the unit under test. Furthermore, using interfaces makes the codebase more extensible, allowing new implementations to be added without modifying existing code. It also enhances readability and enforces a clear contract for the behavior of dependencies, resulting in more robust and scalable systems.

Many third party libraries uses DI for many of the reasons listed above.

The following uses FluentValidation.AspNetCore NuGet package for validating a entry read from a SQL-Server localDb database using Microsoft EF Core 9.

Note: Data is added to the database in the DbContext.

IValidator is registered as service for the following validator.

public class PersonValidator : AbstractValidator<Person>
{
    public PersonValidator()
    {
        RuleFor(x => x.FirstName).NotEmpty();
        RuleFor(x => x.LastName).NotEmpty();
        RuleFor(x => x.EmailAddress).NotEmpty().EmailAddress();
    }
}

public partial class Person
{
    public int PersonId { get; set; }
    [Display(Name = "First")]
    public string FirstName { get; set; }
    [Display(Name = "Last")]
    public string LastName { get; set; }
    [Display(Name = "Email")]
    public string EmailAddress { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Registration is done in Program.cs

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        // Add services to the container.
        builder.Services.AddRazorPages();

        builder.Services.AddScoped<IValidator<Person>, PersonValidator>();
        builder.Services.AddFluentValidationAutoValidation();
Enter fullscreen mode Exit fullscreen mode

See the full article FluentValidation tips C#

Another example can be found here, ASP.NET Core DI constructor with parameters. In this example, a local interface is used rather than a third party interface in a NuGet package.

Also shows how register a service dependent on another service, both in the same project.

Delegates/events

There are countless reasons for needing events that can be enforced with an interface, for example INotifyPropertyChanged. In the following, we want to know when a process has started and another event for updates while a process runs.

These delegates are defined outside any class which may use them so that any class can use them.

/// <summary>
/// Represents a container for delegate definitions used for handling events related to status updates and process initiation.
/// </summary>
/// <remarks>
/// This class defines delegates that are utilized in event-driven programming to notify subscribers about specific actions or changes.
/// </remarks>
public class Delegates
{
    public delegate void UpdateStatusEventHandler(string status);
    public delegate void StartedEventHandler();
}
Enter fullscreen mode Exit fullscreen mode

The interface.

/// <summary>
/// Defines a contract for an interface that includes events for status updates and process initiation.
/// </summary>
/// <remarks>
/// Implementers of this interface are expected to provide mechanisms to handle the <see cref="StatusUpdated"/> 
/// and <see cref="Started"/> events, enabling notification of status changes and the initiation of processes.
/// </remarks>
public interface ISampleInterface
{
    event UpdateStatusEventHandler StatusUpdated;
    event StartedEventHandler Started;
}
Enter fullscreen mode Exit fullscreen mode

A class, using the interface. To keep code clean, Delegate class is setup as a static using statement.

using static InterfaceWithDelegate.Classes.Delegates;
Enter fullscreen mode Exit fullscreen mode
/// <summary>
/// Represents a class that implements the <see cref="ISampleInterface"/> interface, 
/// providing functionality to trigger events such as <see cref="Started"/> and <see cref="StatusUpdated"/>.
/// </summary>
/// <remarks>
/// This class is responsible for invoking the <see cref="Started"/> event when the start process is initiated 
/// and the <see cref="StatusUpdated"/> event to notify about status changes.
/// </remarks>
public class ExampleClass : ISampleInterface
{
    public event UpdateStatusEventHandler StatusUpdated;
    public event StartedEventHandler Started;

    public void Start()
    {
        Started?.Invoke();
        StatusUpdated?.Invoke("Started");
    }
}
Enter fullscreen mode Exit fullscreen mode

Example usage which writes to the console and logs to a file using SeriLog.

internal partial class Program
{
    private static void Main(string[] args)
    {

        ExampleClass example = new();

        // Subscribe to the Started event
        example.Started += OnStarted;

        // Subscribe to the StatusUpdated event
        example.StatusUpdated += OnStatusUpdated;

        // Call the Start method to trigger events
        AnsiConsole.MarkupLine("[yellow]Calling Start[/]");
        example.Start();

        Console.ReadLine();
    }


    /// <summary>
    /// Handles the <see cref="ExampleClass.Started"/> event.
    /// </summary>
    /// <remarks>
    /// This method is invoked when the <see cref="ExampleClass.Started"/> event is triggered,
    /// indicating that the start process has been initiated.
    /// </remarks>
    private static void OnStarted()
    {
        AnsiConsole.MarkupLine("[cyan]Started event[/] [yellow]triggered![/]");
        Log.Information("Started");
    }

    /// <summary>
    /// Handles the <see cref="ExampleClass.StatusUpdated"/> event.
    /// </summary>
    /// <param name="status">
    /// The updated status message provided by the <see cref="ExampleClass.StatusUpdated"/> event.
    /// </param>
    /// <remarks>
    /// This method is invoked whenever the <see cref="ExampleClass.StatusUpdated"/> event is triggered,
    /// allowing the application to respond to status changes.
    /// </remarks>
    private static void OnStatusUpdated(string status)
    {
        AnsiConsole.MarkupLine($"[cyan]Status updated:[/] [yellow]{status}[/]");
        Log.Information("Status updated");
    }
}
Enter fullscreen mode Exit fullscreen mode

Default implementation

C# 8 and above allows concrete implementation for methods in interfaces as well. Earlier it was allowed only for abstract classes.

This change will now shield our concrete classes from side-effects of changing the interface after it has been implemented by a given class e.g. adding a new contract in the interface after the DLL has already been shipped for production use. So our class will still compile properly even after adding a new method signature in the interface being implemented.

In the sample below WillNotBreakExistingApplications is not required so it will not break anything.

namespace DefaultImplementationDemo;
internal partial class Program
{
    static void Main(string[] args)
    {

        Demo d = new();
        d.SomeMethod();
        Console.ReadLine();
    }
}

internal interface ISample
{
    public void SomeMethod();
    public void WillNotBreakExistingApplications()
    {
        Console.WriteLine("Here in the interface");
    }
}

public class Demo : ISample
{
    public void SomeMethod()
    {
        Console.WriteLine("Some method");
    }
}
Enter fullscreen mode Exit fullscreen mode

To invoke WillNotBreakExistingApplications.

static void Main(string[] args)
{

    Demo d = new();
    d.SomeMethod();

    ISample demoInstance = new Demo();
    demoInstance.WillNotBreakExistingApplications();

    Console.ReadLine();
}
Enter fullscreen mode Exit fullscreen mode

The following implements WillNotBreakExistingApplications in the class.

namespace DefaultImplementationDemo;

internal partial class Program
{
    static void Main(string[] args)
    {
        Demo d = new();
        d.SomeMethod();
        d.WillNotBreakExistingApplications();
        Console.ReadLine();
    }
}

internal interface ISample
{
    void SomeMethod(); 
    public void WillNotBreakExistingApplications()
    {
        Console.WriteLine("Here in the interface");
    }
}

public class Demo : ISample
{
    public void SomeMethod()
    {
        Console.WriteLine("Some method");
    }

    public void WillNotBreakExistingApplications()
    {
        Console.WriteLine("Here in the class");
    }
}
Enter fullscreen mode Exit fullscreen mode

Performance considerations

Besides working with interfaces make sure the code has optimal performance. For instance, the following method uses INumber<T> to sum elements in an array.

public class ConstrainSample
{
    public static T Sum<T>(T[] numbers) where T : INumber<T>
    {
        return numbers == null
            ? throw new ArgumentNullException(nameof(numbers))
            : numbers.Aggregate(T.Zero, (current, item) => current + item);
    }
}
Enter fullscreen mode Exit fullscreen mode

The code above introduces some overhead due to delegate invocation for each element in the array.

This is a common mistake by new developers, not considering performance. The Aggregate extension method may or may not be appropriate for each task. If there are performance issues consider the code sample below which will always be better performant.

public class ConstrainSample
{
    public static T Sum<T>(params T[] numbers) where T : INumber<T>
    {
        if (numbers == null) throw new ArgumentNullException(nameof(numbers));

        var result = T.Zero;

        foreach (var item in numbers)
        {
            result += item;
        }

        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

Override ToString

It is not possible to do the following without trickery.

public interface IBase
{
    string ToString();
}   
Enter fullscreen mode Exit fullscreen mode

What is appropriate is to create an abstract class as follows.

public abstract class Base
{
    public abstract override string ToString();
}

public class Child : Base
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public override string ToString() 
        => $"{Id} {Name} {Description}";
}
Enter fullscreen mode Exit fullscreen mode

If there are one or more interfaces used, the (in this case) Base comes before IIdentity.

public abstract class Base
{
    public abstract override string ToString();
}

public interface IIdentity
{
    public int Id { get; set; }
}
public class Child : Base, IIdentity
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public override string ToString() 
        => $"{Id} {Name} {Description}";
}
Enter fullscreen mode Exit fullscreen mode

IComparable<T>

A common assertion is testing whether a value is between two other values, as shown below and we could use pattern matching.

internal static void ConventionalBetween(int value)
{
    if (value >= 1 && value <= 10)
    {
        // do something
    }
}
Enter fullscreen mode Exit fullscreen mode

This logic has nothing wrong, but what about other numeric types? Let's use a language extension for several numeric types for demonstration purposes.

public static class ConventionalExtensions
{
    public static bool IsBetween(this int sender, int start, int end) 
        => sender >= start && sender <= end;

    public static bool IsBetween(this decimal sender, decimal start, decimal end)
        => sender >= start && sender <= end;

    public static bool IsBetween(this double sender, double start, double end)
        => sender >= start && sender <= end;
}
Enter fullscreen mode Exit fullscreen mode

This logic is not wrong, yet there is a better way: create one language extension that will work with the above and even dates.

The following language extension method works with any type that implement IComparable<T>.

Two types from Microsoft documentation.

Shows definitions for DateOnly and Int32

public static class ConventionalExtensions
{

    public static bool IsBetween<T>(this T value, T lowerValue, T upperValue) where T : struct, IComparable<T>
        => Comparer<T>.Default.Compare(value, lowerValue) >= 0 &&
           Comparer<T>.Default.Compare(value, upperValue) <= 0;

}
Enter fullscreen mode Exit fullscreen mode

Examples

internal static void WorkingSmarterWithInt(int value)
{
    if (value.IsBetween(1,10))
    {
        // Do something
    }
}
internal static void WorkingSmarterWithDecimal(decimal value)
{
    if (value.IsBetween(1.5m, 10.5m))
    {
        // Do something
    }
}

internal static void WorkingSmarterWithDateTime(DateTime value)
{
    if (value.IsBetween(new DateTime(2020,1,1), new DateTime(2020, 1, 15)))
    {
        // Do something
    }
}
internal static void WorkingSmarterWithDateOnly(DateOnly value)
{
    if (value.IsBetween(new DateOnly(2020, 1, 1), new DateOnly(2020, 1, 15)))
    {
        // Do something
    }
}
Enter fullscreen mode Exit fullscreen mode

The lesson is to work smarter by knowing not just types but also the glue that holds them together. Of course, not everyone is going to care for language extensions, but those who do can use them in perhaps a team or personal library, which keeps things consistent and also easy to remember.

For more on IComparable see the following.

Do not stop with IComparable, check out other interfaces like IQueryable<T> and IOrderedQueryable ;T>. See the following GitHub repository.

The following are against an known model.

public static class OrderingHelpers
{
    /// <summary>
    /// Provides sorting by string using a key specified in <see cref="key"/> and if the key is not found the default is <see cref="Customers.CompanyName"/>
    /// </summary>
    /// <param name="query"><see cref="Customers"/> query</param>
    /// <param name="key">key to sort by</param>
    /// <param name="direction">direction to sort by</param>
    /// <returns>query with order by</returns>
    /// <remarks>Fragile in that if a property name changes this will break</remarks>
    public static IQueryable<Customers> OrderByString(this IQueryable<Customers> query, string key, Direction direction = Direction.Ascending)
    {
        Expression<Func<Customers, object>> exp = key switch
        {
            "LastName" => customer => customer.Contact.LastName,
            "FirstName" => customer => customer.Contact.FirstName,
            "CountryName" => customer => customer.CountryNavigation.Name,
            "Title" => customer => customer.ContactTypeNavigation.ContactTitle,
            _ => customer => customer.CompanyName
        };

        return direction == Direction.Ascending ? query.OrderBy(exp) : query.OrderByDescending(exp);

    }
}
Enter fullscreen mode Exit fullscreen mode

These are generic versions not tied to a specific model and may be too much for some developers to understand and if so, ask GitHub Copilot to explain the code.

public static class QueryableExtensions
{
    public static IOrderedQueryable<T> OrderByColumn<T>(this IQueryable<T> source, string columnPath) 
        => source.OrderByColumnUsing(columnPath, "OrderBy");

    public static IOrderedQueryable<T> OrderByColumnDescending<T>(this IQueryable<T> source, string columnPath) 
        => source.OrderByColumnUsing(columnPath, "OrderByDescending");

    public static IOrderedQueryable<T> ThenByColumn<T>(this IOrderedQueryable<T> source, string columnPath) 
        => source.OrderByColumnUsing(columnPath, "ThenBy");

    public static IOrderedQueryable<T> ThenByColumnDescending<T>(this IOrderedQueryable<T> source, string columnPath) 
        => source.OrderByColumnUsing(columnPath, "ThenByDescending");

    private static IOrderedQueryable<T> OrderByColumnUsing<T>(this IQueryable<T> source, string columnPath, string method)
    {
        var parameter = Expression.Parameter(typeof(T), "item");
        var member = columnPath.Split('.')
            .Aggregate((Expression)parameter, Expression.PropertyOrField);
        var keySelector = Expression.Lambda(member, parameter);
        var methodCall = Expression.Call(typeof(Queryable), method, new[] 
                { parameter.Type, member.Type },
            source.Expression, Expression.Quote(keySelector));

        return (IOrderedQueryable<T>)source.Provider.CreateQuery(methodCall);
    }
}
Enter fullscreen mode Exit fullscreen mode

IGrouping<TKey,TElement> Interface

A common operation is putting data into groups so that the elements in each group share a common attribute.

The IGrouping interface makes this possible.

In the following example, the task is to group books by price range using the built-in extension method GroupBy. This method uses a switch expression to group books in a list of books that everyone should be able to relate to.

In the following code, each operation has been broken out.

  • Json method provides mocked book data. Note using /* lang=json*/ will flag any errors in the json structure and is not documented.
  • **GroupBooksByPriceRange **method performs the group operation.
  • BooksGroupings is the main method which displays the results.

Results

Price Range: $10 to $19
    Id: 1, Title: Learn EF Core, Price: $19.00
    Id: 2, Title: C# Basics, Price: $18.00
Price Range: $30 and above
    Id: 3, Title: ASP.NET Core advance, Price: $30.00
    Id: 5, Title: Basic Azure, Price: $59.00
Price Range: Under $10
    Id: 4, Title: VB.NET To C#, Price: $9.00
Enter fullscreen mode Exit fullscreen mode

Full code

using System.Diagnostics;
using System.Text.Json;
using VariousSamples.Models;

namespace VariousSamples.Classes;
internal class BookOperations
{
    /// <summary>
    /// Groups a predefined collection of books by price ranges and outputs the grouped results.
    /// </summary>
    /// <remarks>
    /// This method deserializes a JSON string containing book data into a list of <see cref="Book"/> objects.
    /// It then groups the books into price ranges using the <see cref="GroupBooksByPriceRange"/> method.
    /// The grouped results are written to the debug output for inspection.
    /// </remarks>
    public static void BooksGroupings()
    {

        List<Book> books = JsonSerializer.Deserialize<List<Book>>(Json())!;

        IEnumerable<IGrouping<string, Book>> results = GroupBooksByPriceRange(books);

        foreach (var (price, booksGroup) in results.Select(group 
                     => (group.Key, group)))
        {

            Debug.WriteLine($"Price Range: {price}");

            foreach (var book in booksGroup)
            {
                Debug.WriteLine($"\tId: {book.Id}, Title: {book.Title}, Price: {book.Price:C}");
            }

        }
    }

    /// <summary>
    /// Provides a JSON string representation of a predefined collection of books.
    /// </summary>
    /// <remarks>
    /// The JSON string contains an array of book objects, each with properties such as
    /// <c>Id</c>, <c>Title</c>, and <c>Price</c>. This method is used to supply sample
    /// data for operations involving books.
    /// </remarks>
    /// <returns>
    /// A JSON string representing a collection of books.
    /// </returns>
    private static string Json()
    {
        var json = 
            /* lang=json*/
            """
            [
              {
                "Id": 1,
                "Title": "Learn EF Core",
                "Price": 19.0
              },
              {
                "Id": 2,
                "Title": "C# Basics",
                "Price": 18.0
              },
              {
                "Id": 3,
                "Title": "ASP.NET Core advance",
                "Price": 30.0
              },
              {
                "Id": 4,
                "Title": "VB.NET To C#",
                "Price": 9.0
              },
              {
                "Id": 5,
                "Title": "Basic Azure",
                "Price": 59.0
              }
            ]
            """;

        return json;

    }

    /// <summary>
    /// Groups a collection of books into price ranges.
    /// </summary>
    /// <param name="books">
    /// A list of <see cref="Book"/> objects to be grouped by price range.
    /// </param>
    /// <returns>
    /// An <see cref="IEnumerable{T}"/> of <see cref="IGrouping{TKey, TElement}"/> where the key is a string
    /// representing the price range and the elements are the books within that range.
    /// </returns>
    private static IEnumerable<IGrouping<string, Book>> GroupBooksByPriceRange(List<Book> books)
    => books.GroupBy(b => b switch
        {
            { Price: < 10 } => "Under $10",
            { Price: >= 10 and < 20 } => "$10 to $19",
            { Price: >= 20 and < 30 } => "$20 to $29",
            { Price: >= 30 } => "$30 and above",
            _ => "Unknown"
        });
}
Enter fullscreen mode Exit fullscreen mode

Model

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    public decimal? Price { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Note
The switch used can also be done as follows and some may not case for a switch expression.

private static IEnumerable<IGrouping<string, Book>> GroupBooksByPriceRange(List<Book> books)
{
    return books.GroupBy(b =>
    {
        if (b.Price < 10) return "Under $10";
        if (b.Price >= 10 && b.Price < 20) return "$10 to $19";
        if (b.Price >= 20 && b.Price < 30) return "$20 to $29";
        if (b.Price >= 30) return "$30 and above";
        return "Unknown";
    });
}
Enter fullscreen mode Exit fullscreen mode

IParsable<T>

The IParsable interface defines a mechanism for parsing a string to a value, as shown in the following example. The list is meant to represent lines in a file, and IParsable creates a new Person.

Data is perfect for keeping the example simple. Out in the wild, assertions would be needed to validate that elements can be converted to the right type, and in the case of Gender and Language, they are correct.

#nullable enable
using System.Diagnostics.CodeAnalysis;
using VariousSamples.Interfaces;

#pragma warning disable CS8618, CS9264

namespace VariousSamples.Models;

public class Person : IHuman, IIdentity, IParsable<Person>
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateOnly DateOfBirth { get; set; }
    public Gender Gender { get; set; }
    public Language Language { get; set; }

    public Person() { }
    /// <summary>
    /// Initializes a new instance of the <see cref="Person"/> class with the specified details.
    /// </summary>
    /// <param name="id">The unique identifier for the person.</param>
    /// <param name="firstName">The first name of the person.</param>
    /// <param name="lastName">The last name of the person.</param>
    /// <param name="dateOfBirth">The date of birth of the person.</param>
    /// <param name="gender">The gender of the person.</param>
    /// <param name="language">The preferred language of the person.</param>
    private Person(int id, string firstName, string lastName, DateOnly dateOfBirth, Gender gender, Language language)
    {
        Id = id;
        FirstName = firstName;
        LastName = lastName;
        DateOfBirth = dateOfBirth;
        Gender = gender;
        Language = language;
    }
    /// <summary>
    /// Parses a string representation of a <see cref="Person"/> and returns an instance of the <see cref="Person"/> class.
    /// </summary>
    /// <param name="item">The string representation of a person in the format "Id,FirstName,LastName,DateOfBirth,Gender,Language".</param>
    /// <param name="provider">An optional <see cref="IFormatProvider"/> to provide culture-specific formatting information.</param>
    /// <returns>A new instance of the <see cref="Person"/> class populated with the parsed data.</returns>
    /// <exception cref="FormatException">
    /// Thrown when the input string does not match the expected format or contains invalid data.
    /// </exception>
    public static Person Parse(string item, IFormatProvider? provider)
    {
        string[] personPortions = item.Split('|');
        if (personPortions.Length != 6)
        {
            throw new FormatException("Expected format: Id|FirstName|LastName|DateOfBirth|Gender|Language");
        }
        return new Person(
            int.Parse(personPortions[0]),
            personPortions[1],
            personPortions[2],
            DateOnly.Parse(personPortions[3]),
            Enum.Parse<Gender>(personPortions[4]),
            Enum.Parse<Language>(personPortions[5])
        );
    }
    /// <summary>
    /// Attempts to parse the specified string representation of a <see cref="Person"/> into an instance of the <see cref="Person"/> class.
    /// </summary>
    /// <param name="value">The string representation of a person in the format "Id,FirstName,LastName,DateOfBirth,Gender,Language".</param>
    /// <param name="provider">An optional <see cref="IFormatProvider"/> to provide culture-specific formatting information.</param>
    /// <param name="result">
    /// When this method returns, contains the parsed <see cref="Person"/> instance if the parsing succeeded, 
    /// or <c>null</c> if the parsing failed. This parameter is passed uninitialized.
    /// </param>
    /// <returns>
    /// <c>true</c> if the parsing succeeded and a valid <see cref="Person"/> instance was created; otherwise, <c>false</c>.
    /// </returns>
    public static bool TryParse([NotNullWhen(true)] string? value, IFormatProvider? provider, [MaybeNullWhen(false)] out Person result)
    {
        result = null;

        if (value == null)
        {
            return false;
        }

        try
        {
            result = Parse(value, provider);
            return true;
        }
        catch
        {
            return false;
        }

    }
}
Enter fullscreen mode Exit fullscreen mode

Sample

List<string> list =
[
    "1|John|Doe|1990-01-01|Male|English",
    "2|Mary|Doe|1992-02-01|Female|English",
    "3|Mark|Smith|2000-02-01|Female|Spanish"
];

List<Person> people = list.Select(x => 
    Person.Parse(x, CultureInfo.InvariantCulture))
    .ToList();
Enter fullscreen mode Exit fullscreen mode

Helper methods

Earlier in this article, code was presented to show how to determine if a model implemented one of several interfaces. There may be a need to check to see which models implement a specific interface or to check if a model implements several interfaces. Code has been provided in source code for this article to do so.

Examples

private static void GetAllClassesImplementing()
{
    Customer customer = new()
    {
        CustomerIdentifier = 2,
        FirstName = "Jane",
        LastName = "Doe",
        AccountNumber = "1234567890"
    };

    if (customer is IIdentity c)
    {
        AnsiConsole.MarkupLine($"[cyan]Id[/] " +
                               $"{c.Id,-3}[cyan]CustomerIdentifier[/] " +
                               $"{customer.CustomerIdentifier}");
    }
    else
    {
        AnsiConsole.MarkupLine("[red]Customer is not an IIdentity[/]");
    }

    Console.WriteLine();

    var entities = Helpers.GetAllEntities<IIdentity>();
    foreach (var entity in entities)
    {
        AnsiConsole.MarkupLine($"[cyan]{entity.Name}[/]");
    }

    Console.WriteLine();

    var entitiesMore = Helpers.ImplementsMoreThanOneInterface<Person>(
        [typeof(IIdentity), typeof(IHuman)]);

    AnsiConsole.MarkupLine(entitiesMore ? 
        "[cyan]Yes[/]" :
        "[red]No[/]");

}
Enter fullscreen mode Exit fullscreen mode

Summary

Various ideas have been presented about the benefits of using custom and .NET Framework native interfaces, which will assist those who have never used interfaces in getting started.

In the provided source code there are more samples than presented here to check out.

Top comments (6)

Collapse
 
sanampakuwal profile image
Sanam

Love this series!! ❤️

Collapse
 
raddevus profile image
raddevus

This is a very good (informative) article.
I really like the first part about using the IIdenity interface.
However, I have two questions:
1) would you say that using this Interface would be applying the Adapter OOP pattern?
Just curious about that.
2) The 2nd paragraph after the Basic Example heading is a bit confusing. Can you help me understand what you were saying?

The paragraph states:

In the following classes each has an identifier that in two cases is constant while one is not along with if in code there is a need to get at the identifier additional code is required.

I'm parsing that to :
1 - In the following classes, each has an identifier, that in two cases is constant.
(but I don't see any constants)
2 - while one is not (not constant, right?)
3 - along with if in code there is a need to get at the identifier additonal code is required (unsure what you're saying here bec all the properties are public)

I'm going to re-read this article bec there is a lot here.
thanks for writing it up.

Collapse
 
karenpayneoregon profile image
Karen Payne

Constant does not mean const.

Collapse
 
aleksa_b8581c222038e8477b profile image
Aleksa • Edited

Problem with interfaces is that they are often used without real need and just make a burden for developers. In most projects they are just useless. I mean they are talked about being best practice while they are actually not. They are just useful sometimes, very rarely.

Collapse
 
karenpayneoregon profile image
Karen Payne

I agree that interfaces may be misused. Perhaps there needs to be a section addressing misused.

Collapse
 
silviu_mihaipreda_68cbac profile image
Silviu Mihai Preda

The base class of the C# type system, object, exposes ToString(). There is no need to create Base with the sole purpose of virtualizing that method