5

Consider the following scenario.

I have an interface IService:

public interface IService
{
    void DoSomething();
}

with an implementation:

public class Implementation : IService
{
    // This might throw an exception of type MyException
    public void DoSomething()
    {
        // Implementation code
    }   
}

MyException can be considered a part of the interface contract in this case, since all implementations may throw this exception.

Now, I have multiple clients which consume this service via dependency injection:

public class Client
{
    private readonly IService _service;

    public Client(IService service)
    {
        _service = service;
    }

    public void Consume()
    {
        try
        {
             _service.DoSomething();
        }
        catch (MyException ex)
        {
            // Handle MyException
        }
    }
}

In order to handle rate limiting, I implement a resilience decorator:

public class ResilienceDecorator : IService
{
    private readonly IService _decoratee;

    public ResilienceDecorator(IService decoratee)
    {
        _decoratee = decoratee;
    }

    public void DoSomething()
    {
        // Put rate limiting logic here which wraps call to decoratee.DoSomething()
        // This logic may throw "RateLimiterRejectedException"
    }
}

The problem:

The ResilienceDecorator might throw a RateLimiterRejectedException. However, it doesn't make sense to include this exception in the IService contract because it is possible that clients receive different implementations. Some clients may receive the decorated version while others may not.

To me, this seems to violate the Liskov Substitution Principle because the client should only know about the abstraction IService, but it needs to handle exceptions that are specific to certain implementations or decorators.

How can I improve this design?

7 Answers 7

4

TL;DR in C#, you should not be using exceptions for conditions which are part of the contract in the first place. Exceptions are for violations of contracts.

Languages where the exceptions are part of the contract, aka 'checked exceptions' exist. C# is not one of them.

Compare Java's int parsing, which throws an exception on error, with C#'s TryParse, which returns a status code indicating success.

Just like the underlying HTTP response, make your API include both response state and data; I'd tend to use a value type called 'ResultOrRetry' for this rather than out parameters.

--

You can treat certain conditions - invalid input, having to retry an operation with the same parameters, failing to exclusively open a file - as exceptions to the normal flow of control, or as part of the normal flow of control.

If you then use exceptions to model the flow of control, then you have two options to make clients use them - you either document in the comments, which can get out of sync, or you change the language to enforce it.

This is why languages include the types of exceptions thrown are part of the signature of the methods of the interface. Java has checked exceptions, C++ had exception specifications. The purpose of these constructs is to allow exceptions to be used for flow control - you throw a PleaseRetryLater exception and the client code knows not to exit the retry loop. This paper says Eiffell and C# do not, but use exceptions for error signalling, but the examples in Eiffell's exception tutorial are using them for flow control.

The checked exception mechanism is not in C# because it generally is considered too verbose compared to its benefits - the same reason you're using C# not Spark ADA. Essentially what would happen in Java remoting interfaces is that the exceptions would be documented then every client would have to catch them and not be able to do anything so would log or wrap and rethrow, which was a lot of code that had no value for the end user.

If your client doesn't know that it should implement some flow control to handle RateLimiterRejectedException then that is not really a problem - the call failed for an unknown reason; all C# methods might throw any exception, clients catch and process the ones they are interested in not the ones the implementation is.

If you want all clients to implement the retry mechanism, don't throw the exception but move the retry mechanism to the service.

To make it clear to the downvoters, I am presenting C++ and Java as examples of what not to do! Do not consider all failures as part of the contract, but the contract specifies the successful behaviour of the function.

9
  • Checked exceptions have nothing to do with the question. The question is about general contract, which can be documented in natural language.
    – Basilevs
    Commented Jul 27 at 11:27
  • 2
    @Basilevs checked exceptions are a means to put the exceptions in the contract enforced by the language. It is informative that more modern languages do not consider them to be worth being part of the contract. Commented Jul 27 at 11:29
  • 2
    Dynamic exception specifications was removed from C++.
    – Mat
    Commented Jul 27 at 12:40
  • I'm not aware that checked exceptions are universally considered a good idea. I wouldn't consider Java and C++ as representative of "modern languages".
    – chepner
    Commented Jul 27 at 14:25
  • @chepner I said C# is more modern that Java and removed them, both of which are easily verifiable facts, and presented my opinion on reasons for that - applying LSP rigorously to exceptions is usually not worth the effort. Commented Jul 27 at 22:12
3

Keeping exceptions LSP compliant

Indeed, exceptions can be part of a contract. It is a special kind of postcondition.

LSP requires that postconditions are not weakened. This means that if the contract specifies a set of possible exceptions, i.e. MyException , then the subtype can only trigger an exception in the same set or a subset thereof. To be LSP-compliant, your example therefore requires that RateLimiterRejectedException is a subtype of MyException.

Avoiding undesired coupling with specific exceptions

The problem in your design then is that the customer of IService shall catch MyException. It could also catch subtypes of MyException for dealing but these subtypes are specific to some implementations. This is at best undesired coupling and bad dependency management, at worst an OCP issue.

The only solution that I see to make your design cleaner would be to not only inject a IService but also a IServiceExceptionHandler that would handle the MyException that could be received, checking for more specific exception types if appropriate. Since the context that would inject the service knows exactly which service is injected, it could also know about the potential exceptions at stake. So, clean dependency management guaranteed that prevents undesired coupling in the IService consumer.

Hints for the exception handler to be injected

The simplest way is to implement an IExceptionHandler responsible for the whole exception handling and inject it, using OCP to extend it.

The chain of responsibility pattern could be a more elaborate approach to dynamically to associate specific exception handling actions to specific subtypes and assemble the exception handler at runtime. if you're using decorators, you could alternatively use an exception handler decorator.

If the goal is only to display the right error message, a keep it simple: leaner and more elegant approach would be to use a tell-don't ask approach and tell the exception received to display the error message.

2

If I called a method that returns an instance of the base-class, or an implementation of the interface, with no information at all what concrete class I’ve received, could I use this instance successfully?

In your case you could add “might throw a rate limiter rejected exception” to your contract because in plain English an instance not capable of throwing the exception fulfils the contract. You couldn’t add “throws xyz exception when the caller does abc” because an instance not capable of doing that violates the contract. In the first case I’d have code to handle the exception but it would never get called, and I don’t require it to be called. In the second case my code would fail because it expects an exception to detect the abc case, and it’s not raised, so that’s a LSP violation.

You can also create a subclass and never treat its instances as baseclass instances, in that case it may very well violate LSP but it doesn’t matter because nobody relied on it. In that case you might consider not making it a subclass, but having an instance variable that belongs to the other call (which is not a baseclass anymore); you decide what’s better for you.

So in your concrete case: Your design requires exception handlers that might never be called but will be called when needed; that is slightly annoying (if you have five subclasses throwing five different exceptions you need five handlers instead of one) but it is no problem.

0

Make RateLimiterRejectedException extend TemporaryProblem.

The interface and its contract are defined by client using it. Usage should be prioritized over implementation when designing an interface.

Clients may not be interested in exact problem that happens. Most of the time they just need to distinguish a fatal error and a temporary problem. Consider an interface that throws FatalError and TemporaryProblem. Client could retry the operation after temporary problem without being aware of exact nature of the problem. Implementors can then extend error types with additional information for more aware clients, while staying compatible with less aware ones. LSP is not violated.

0
public class Implementation : IService
{
   // This might throw an exception of type MyException
   public void DoSomething()
   {
       // Implementation code
   }   
}

MyException can be considered a part of the interface contract in this case, since all implementations may throw this exception.

Yes, no, maybe, so. Part of the contract is "may throw" and a particular case of "may throw" is "no throw".

How can I improve this design?

Ain't broke, don't fix it. That would be in plain english.

Getting technical "may throw" could be implemented both ways with checked or with unchecked exceptions. The particularity of implementation using unchecked exceptions is that the caller code is plainer since it doesn't include the exception handling code. Also with unchecked exceptions the caller code might not at all handle exceptions when failure isn't of relevance or even wider when the result isn't of relevance.

0

However, it doesn't make sense to include this exception in the IService contract because it is possible that clients receive different implementations. Some clients may receive the decorated version while others may not.

To me, it makes perfectly sense to include a specification of the allowed exceptions in the contract - a contract which allows a certain set of exception does not forbid implementations which never make use of it.

However, it is usually a good idea to write the exception spec in way one does not need to be adapted for each kind of new service implementation. So,

  • if the only two kinds of allowed exceptions are "MyException" and "RateLimiterRejectedException", write that into the spec

  • if a client has to expect the two mentioned exceptions, but also other kinds (and so should provide a "catch everything else" logic), then write that into the spec

  • if the allowed kind of exceptions should be derived from "MyException", then write that into the spec

  • if clients have to inform themselves which service implementations are possible and which exceptions they might throw, then write that into the spec. Note this is only practicable if there are not more than 2 or 3 different service implementations which are "set in stone" or fully under control of the person or team who also designs the clients. Otherwise it can quickly become errorprone and unmaintainable.

As long as services are obeying this spec, and clients are implemented accordingly, there will be no LSP violation. But without clear spec, or a spec which leaves holes, it is impossible to say whether the LSP is violated.

6
  • Forcing the interface to contain the combined aggregate of all of its implementors' exceptions is a variation on an OCP violation. We're discussing a grand total of two here, so it's not the end of the world, but regardless of size I do consider this a violation.
    – Flater
    Commented Jul 28 at 8:55
  • @Flater: thats correct, but the OCP is not an end in itself, and from the way the question was asked, I guess the asker does not see the OCP here as a primary design goal. Moreover, it is somewhat debatable if this is really an OCP violation. Think of IService and its implementation as compiled code in a black box library and the decorator as code outside this component. The component then can be reused without source code changes or recompilation even with this new exception from a decorator. I have doubts this counts as an OCP violation.
    – Doc Brown
    Commented Jul 28 at 16:27
  • "Think of IService and its implementation as compiled code in a black box library and the decorator as code outside this component." You could say that about any codebase in an effort to sidestep any argument about the internal design of said codebase. The alleged OCP violation is an internal one, I agree on that; but I'm not agreeing that we should consider it a violation only if it leaks beyond whatever arbitrary boundary we choose to judge it against.
    – Flater
    Commented Jul 28 at 23:22
  • "I have doubts this counts as an OCP violation." What if tomorrow there's a new implementation of this interface, which throws a new kind of exception? You have to go back and explicitly include this in the interface contract, and update everyone who makes used of that interface to now account for this new exception (= implementor's implementation detail) possibly being thrown. That whole "I need to go back and update this list of options" is precisely what OCP tries to combat, an extension should not require that another things needs to be modified to account for said extension.
    – Flater
    Commented Jul 28 at 23:23
  • Just to point out that I'm not tone deaf to the existence of exceptions: I see no issue with documenting the existence of the exception for any consumer of said piece of logic (who sees it as a black box), but there is a difference between adding something to the documentation and including it as part of the IService interface purely by merit of an implementor having implemented it of their own accord.
    – Flater
    Commented Jul 28 at 23:37
0

I dont think the question is really about throwning vs result object... Both are ok in general with a bunch of caveats and open philosophical dispute. If you define an interface the definition shall be complete from the client component point of view. So you can choose a set of exceptions or a result object (e.g. enum), but either way it shall be tied to the interface itself, not a particular implementation. From the component isolation stand point its the only way for the client component to be ignorant of the interface implementation and yet be able to handle some scenarios (e.g. retry). I.e. the client component shall never care about the implementation of the interface it uses, at least as a rule of thumb.

Not the answer you're looking for? Browse other questions tagged or ask your own question.