Understanding how code becomes coupled goes a long way towards identifying how to decouple it. Direct coupling, the obvious type, only accounts for the tip of the iceberg in my experience. Other types of code coupling include temporal, passive and circular references.
Temporal coupling, between classes, exists when there is a requirement to call methods on different classes in a strict order. This type of compelling suggests a dependency on knowing too much about the implementation to use it. For example, Class A has to call “connect” on Class B, used by Class C, before making a call to Class C, this means that Class A has a coupling to Class B because it uses Class C.
A similar temporal coupling exists between methods exposed by a single class. Where the class’s methods depend on the transition of internal state by calling another method or sequence of methods first.
Passive coupling results from improper cohesion of classes, abstractions or functionality. Usually coupling between classes and libraries that don’t need to be “along for the ride”. This frequently occurs when abstractions and implementations exist in the same project or library,
Circular references, where a library depends or references itself by depending on another library that has a dependency on it. These are not quite a thing of the past and can even creep into code during refactoring or through code merges. Most modern IDE platforms and build tools detect circular references, but the larger the loop the harder they are to detect.
Extract The Abstract
Using a “gateway” or abstraction library helps separate code while avoiding some of the above couplings. This is an adherence to the Dependency Inversion Principal
Gateway is my naming preference for an internal abstractions library, why gateway? Because loosely everything in its scope should go through it. You can have more than one gateway in a solution but the important thing is they should only contain Abstractions and classes required by shared implementation behaviour such as Exceptions. Though if there are a lot of Exceptions considering a separate library just for the shared behaviour elements makes sense. Don’t worry about having lots of small libraries.
Following this pattern should result in all the consumers and the implementations depending on the gateway library and classes and not each other.
Abstractions are abstractions all the way down, no abstraction depends on an implementation (with the exception of types built into the framework). For third party libraries provided without abstractions, there is merit in wrapping the types in your own interface(s), if possible. Doing so will give you a couple of advantages, 1. the ability to mock it if it’s something that’s too expensive to spin up during a test, 2. A simpler swap out path at a later date. If you do make this kind of interface then it should abstract the behaviour(s) you require on your terms, rather than be a full wrap of all features/methods verbatim.
Hint if you’re using the Visual Studio 2019 theme then you can use my light green/blue rule with the exception of native framework classes.
Where specific behaviours of implementation include the use of explicit exceptions, it makes sense to include these in the gateway or in their own exceptions library. All implementations would need to depend on them so they should not be owned by any particular implementation.
If you would like to read more about code dependencies then this article on dependency injection is worth a look.