Gotta Keep ’em Separated
The unfortunately-named Interface Segregation Principle is an abstraction-level corollary to the Single Responsibility Principle. Like the SRP, this principle seeks to limit the burden on consumers and implementers by focusing the exposed API of an interface.
This post is part of a series on software design principles. As with all design principles, this is a general guideline, not a hard and fast rule. Following principles such as this will improve code quality, legibility, testability, and maintainability.
The Interface Segregation Principle (ISP) is the I in the SOLID design principles acronym. Perhaps a better name for it – one that has infinitely more to do with software engineering, doesn’t require a new acronym, and lacks the negative socio-political connotation – would be the “Interface Segmentation Principle”. Its premise is that a consumer of an object should not be forced to depend on any of that object’s functionality that the consumer itself doesn’t use. This means that an interface should only expose small, logically-grouped segments of functionality. While this is written from the consumer’s perspective, it also has implications for implementations.
This differs from the SRP since the SRP is about the relatedness of the overall functionality of a class, whereas the ISP is more specifically about what is exposed by an interface. In most languages, a class can implement more than one interface. A class can adhere to both the ISP and the SRP if the implementations of those interface members are all sufficiently small and overall related to the same concept.
The ISP is defined in terms of the consumer of an interface. Consider a potential LDAP client interface:
public interface ILdapClient : IDisposable
{
bool UseUdp { get; set; }
Task ConnectAsync(Endpoint endpoint);
Task DisconnectAsync();
ConnectionState ConnectionState { get; }
Endpoint ServerEndpoint { get; }
Task StartTlsAsync();
bool UsingTls { get; }
Task BindAsync();
Task UnbindAsync();
Task<User> GetEntryAsync(
string distinguishedName,
params string[] attributesToFetch);
Task AddEntryAsync(LdapEntry entry);
Task ModifyEntryAsync(LdapEntry entry);
Task DeleteEntryAsync(string distinguishedName);
Task<LdapSearchResult> SearchAsync(LdapSearchRequest searchRequest);
Task<LdapResult> CompareAsync(
string distinguishedName,
LdapAssertion assertion);
Task<LdapResponse> ExecuteAsync(LdapCommand command);
}
At first glance, this may not look like much functionality: 4 properties and 13 methods (12 defined here, and 1 inherited from IDisposable). However, this is a god interface, an anti-pattern wherein an interface does or knows too much. By extension, any class that implements this interface must implement all of its members and thus will itself necessarily become a god object, an anti-pattern for a concrete type that similarly over-provides functionality.
Consider a service that needs to verify that a user’s account exists and that the user has provided the correct password. The development team decides to use LDAP, and settles on this ILdapClient:
public class LdapAuthenticationService
{
private readonly ILdapClient _ldapClient;
public LdapAuthenticationService(ILdapClient ldapClient)
{
_ldapClient = ldapClient ?? throw new ArgumentNullException(nameof(ldapClient));
}
public async Task<bool> VerifyUserPassword(string username, string password)
{
if (string.IsNullOrWhitespace(username))
throw new ArgumentException(
"Username cannot be null, empty, or exclusively white-space.",
nameof(username));
if (string.IsNullOrWhitespace(password))
throw new ArgumentException(
"Password cannot be null, empty, or exclusively white-space.",
nameof(password));
string distinguishedName = ConvertToDistinguishedName(username);
var passwordAttributes = new[] { "passwordSalt", "passwordHash" };
LdapEntry ldapEntry = await _ldapClient.GetEntryAsync(
distinguishedName,
passwordAttributes);
bool isPasswordValid = false;
if (ldapEntry != null)
{
/* validate password */
}
return isPasswordValid;
}
private string ConvertToDistinguishedName(string username) { /**/ }
}
It can be easy to overlook the problem here. LdapAuthenticationService needs to check whether or not a given user account entry exists, and whether or not the password provided by the user is correct. This only requires the use of the GetEntryAsync method, which is clearly available on the ILdapClient interface. This service doesn’t use any of the other members available on the interface.
However, this can cause confusion, especially when reading documentation or working with an IDE that has intellisense and auto-complete. Having lots of unused peripheral members could make finding the correct information harder and more burdensome on the developer.
This also overlooks what other services within the application might do. For example, this implementation uses the Dependency Injection design pattern to inject an ILdapClient instance during instantiation. At no point does this implementation attempt to connect or bind the client to the LDAP server. It assumes that the client was connected and bound prior to the LdapAuthenticationService being instantiated. In and of itself this isn’t a problem, however other consumers of the interface may behave in unpredictable ways.
Assume that this application treats its ILdapClient as a request-scoped object, and creates a single instance to be used across any one inbound user-level request. A developer writing a pre-request service within the application sees the DisconnectAsync or Dispose methods on the ILdapClient interface and decides to be proactive about freeing resources. At the end of their service, the developer disconnects and disposes the client instance.
If GetEntryAsync throws an InvalidOperationException when not connected – or an ObjectDisposedException when the object has already been disposed – then LdapAuthenticationService would also throw an exception instead of returning true or false. Even if GetEntryAsync simply returned a null instance if it wasn’t connected, then this implementation would effectively ignore user credentials and always return false.
In this scenario, if the pre-request service disconnects the client, then all users would be unable to authenticate, nobody would be able to log in, and there would be no error messages or logs showing why. Debugging this scenario and tracing it back from the authentication service to where the pre-request service had preemptively disconnected the client could be challenging and time consuming. And all of this because the interface over-exposes functionality.
A better, ISP-conformant API would be to have multiple logically-grouped interfaces:
public interface ILdapCommandExecutor
{
Task<LdapResponse> ExecuteAsync(LdapCommand command);
}
public interface ILdapClientContext : IDisposable
{
Endpoint ServerEndpoint { get; }
Task BindAsync(Endpoint serverEndpoint, bool useUdp = false);
Task UnbindAsync();
}
public interface ILdapLookupClient
{
Task<User> GetEntryAsync(
string distinguishedName,
params string[] attributesToFetch);
}
public interface ILdapStoreClient : IDisposable
{
Task AddEntryAsync(LdapEntry entry);
Task ModifyEntryAsync(LdapEntry entry);
Task DeleteEntryAsync(string distinguishedName);
}
public interface ILdapRepository : ILdapStoreClient, ILdapLookupClient { }
public interface ILdapSearchClient
{
Task<LdapSearchResult> SearchAsync(LdapSearchRequest searchRequest);
}
public interface ILdapCompareClient
{
Task<LdapResult> CompareAsync(
string distinguishedName,
LdapAssertion assertion);
}
One might notice that this API leaves only has 9 methods (plus one inherited from IDisposable) and no properties. This is because the API is also simplified: that is, the TCP-specific connect/disconnect and Start-TLS semantics are not included. Ostensibly, implementations of ILdapClientContext would encapsulate an appropriate underlying internet protocol abstraction layer that’s not necessarily relevant to consumers of the higher-level LDAP functionality.
Also of note is the ILdapRepository interface that just combines the ILdapLookupClient and ILdapStoreClient interfaces. This is a convenience interface for services that need to operate in both “read” and “write” directions. For example, a consumer of the LdapAuthenticationService may decide to set a “lastPasswordFailed” timestamp attribute on a user’s account entry whenever VerifyUserPassword returns false. Since this functionality would require looking up the entry, adding the attribute, and writing it back, it would be more convenient to have a single dependency on ILdapRepository rather than two separate references to ILdapLookupClient and ILdapStoreClient.
In fact, in such an application, those separate ILdapLookupClient and ILdapStoreClient references might actually both point to the same ILdapRepository instance. This would be a bad assumption to make, however, since it would be just as easy for an implementation of ILdapRepository to simply encapsulate two separate implementations of ILdapLookupClient and ILdapStoreClient. If the latter were the case, then attempting cast an ILdapLookupClient instance to ILdapRepository (or ILdapStoreClient) might result in errors, so it’s always best to consume the required abstraction level and not assume any implementation details.
The above API would change the implementation of LdapAuthenticationService:
public class LdapAuthenticationService
{
private readonly ILdapLookupClient _ldapLookupClient;
public LdapAuthenticationService(ILdapLookupClient ldapLookupClient)
{
_ldapLookupClient = ldapLookupClient
?? throw new ArgumentNullException(nameof(ldapLookupClient));
}
/**/
}
The pre-request service would have to change in a similar way, by taking only the LDAP client interface it needs. At this point, “proactively” disconnecting and disposing of the LDAP connection becomes a very conscious decision to inject the ILdapBindingContext and call the appropriate methods. Limiting an interface’s API translates directly into limiting – if not outright preventing – its misuse.
As mentioned earlier, the ISP is written from the consumer’s perspective but also has implications on implementations. As with the ILdapClient example above, a large interface requires a large implementing class. That is, god interfaces necessarily beget god objects, which in turn necessarily violate the SRP. God objects are almost universally difficult to test and maintain.
The refactored LDAP API, however, has much smaller, focused interfaces. Implementing a class for each would be very simple. Testing these classes would be simple and easy. The overall code would be very easy to understand.
Additionally, by segmenting interfaces along lines of directionality, the ISP can also play very well with the concepts of co- and contravariance from the Liskov Substitution Principle. For example, consider the following database entity repository pattern:
public interface IEntityLookup<out TEntity, in TId>
where TId : IComparable
{
TEntity GetEntity(TId entityId);
}
public interface IEntityStore<in TEntity, in TId>
where TId : IComparable
{
void Insert(TEntity entity);
void Update(TEntity entity);
void Delete(TId entityId);
}
public interface IEntityRepository<TEntity, in TId> :
IEntityLookup<TEntity, TId>,
IEntityStore<TEntity, TId>
where TId : IComparable
{
}
Here, TEntity is covariant in IEntityLookup<,> but contravariant in IEntityStore<,>. TId is contravariant in both. This allows for code like the following:
public abstract class Animal
{
public int AnimalId { get; }
}
public abstract class Feline : Animal { }
public class DomesticCat : Feline { }
public class BigCat : Feline { }
public class FelineRepository : IEntityRepository<Feline, int> { /**/ }
public static class Program
{
public static void Main()
{
var felineRepository = new FelineRepository();
IEntityLookup<Animal, int> animalLookup = felineRepository;
IEntityStore<BigCat, int> bigCatStore = felineRepository;
}
}
Because all Feline instances are Animal instances, felineRepository can be referenced covariantly to a simple Animal lookup. While at runtime, this would only return instances of Feline, the code might not care about anything more than what’s universally available on Animal. Similarly, since BigCat instances are all Feline instances, felineRepository can be referenced contravariantly as a BigCat store. At runtime, only BigCat will be passed into the reference, but they’re all valid Feline instances which will be happily accepted by the Feline repository.
The Interface Segregation Principle might feel like an excuse to create a ton of unnecessary code files. However, it can be used to produce very clean code that’s easy to understand, consume, and even maintain. It also plays very well with other object-oriented design principles.