Programming to abstractions is an important principle to keep in mind. I have shown in a number of demos (including IEnumerable, ISaveable, IDontGetIt: Understanding .NET Interfaces, Dependency Injection: A Practical Introduction, and T, Earl Grey, Hot: Generics in .NET) how programming to abstractions can make our code more extensible, maintainable, and testable.
Add Abstraction As You Need It
I am a big fan of using abstractions (specifically Interfaces). But I'm also a proponent of adding abstractions only as you need them.
As an example, over the past couple of weeks, I've been working on a project that contained a confirmation dialog. The dialog itself was pretty simple -- just a modal dialog that let you add a message, text for the OK and Cancel buttons, and a callback for when the dialog was closed. To make things more interesting, since the project uses MVVM, we end up making the call to this dialog from the View Model through an Interaction Trigger. What this creates is a couple of layers of code in order to maintain good separation between the UI (the View) and the Presentation Logic (in the View Model).
This confirmation dialog code has been working for a while, but it had a drawback -- it wasn't easily unit testable due to the fact that the interaction trigger called into the View layer. The result is that as I added confirmation messages to various parts of the code, the unit tests would stop working.
I was able to fix this problem by adding an interface (IConfirmationDialog) that had the methods to show the dialog and fire the callback. I used Property Injection for the dialog so that I would have the "real" dialog as the default behavior. But in my unit tests, I injected a mock dialog based on the interface. This let me keep my unit tests intact without changing the default run-time behavior of the dialog. If you'd like to see a specific example of using Property Injection with mocks and unit tests, see the samples in Dependency Injection: A Practical Introduction.
When selecting the right tool for the job, we need to know the strengths and the weaknesses of each tool. This lets us make intelligent choices to confirm that the benefits outweigh the drawbacks for whatever tool we choose. When working with abstractions and interfaces, this is no different.
We've talked about the advantages of using abstraction, but what about the drawbacks? The most obvious is complexity. Whenever we add another layer to our code, we make it a bit more complex to navigate. Let's compare two examples (taken from the Dependency Injection samples).
Navigation With a Concrete Type
We'll start by looking at a class that uses a concrete type (this is found in WithoutDependencyInjection.sln in the NoDI.Presentation project).
Here, we have a property (Repository) that is using a concrete type: PersonServiceRepository. Then down in the Execute method, we call the GetPeople method of this object. If we want to navigate to this method, we just put the cursor in GetPeople and then hit F12 (or right-click and select "Go To Definition"). This takes us to the implementation code in the PersonServiceRepository class:
Navigation With An Interface
If we use an interface rather than a concrete type, then navigation gets a little trickier. The following code is in DependencyInjection.sln in the DI.Presentation project:
This code is very similar to the previous sample. The main difference is that the Repository variable now refers tn an interface (IPersonRepository) rather than the concrete type. You can see that the code in the Execute method is exactly the same.
But if we try to navigate to GetPeople using F12 (as we did above), we get quite a different outcome:
Mitigating the Navigation Issue
We can mitigate the navigation issue by just doing a search rather than "Go To Definition". For this, we just need to double-click on "GetPeople" (so that it is highlighted), and then use Ctrl+Shift+F (which is the equivalent of "Find in Files"). This brings up the search box already populated:
If we click "Find All", then we can see the various declarations, implementations, and calls for this method:
This snippet shows the interface declaration (IPersonServiceRepository) as well as 2 implementations (PersonCSVRepository and PersonServiceRepository). If we double-click on the PersonCSVRepository, then we are taken to that implementation code:
This code shows the GetPeople implementation for reading from a CSV file. So, there are a few extra steps to finding the implementation we were looking for, but if we know our tools (namely, Visual Studio) and how to use them, then we can still get the information we need.
There are other options as well. Instead of using "Find In Files", we can use "Find All References" (Ctrl+K, R) which will give us similar (but not exactly the same) results as a search. Also, if you have a refactoring tool installed (like RefactorPro or Resharper), then you most likely have an even faster way to search for a particular method. It always pays to have a good understanding of your development environment.
Everything has a cost. In the case of programming to abstractions, that cost is added complexity and indirection when moving through source files. The benefits include extensibility, maintainability, and testability.
As mentioned, I'm a big fan of adding abstractions as you need them. Once you become familiar with your environment (the types of problems you are trying to solve and the parts that are likely to change), then you can make better up-front decisions about where to add these types of abstractions. Until then, start with the concrete types and add the abstractions when needed. Note: many times you may find yourself needing abstractions immediately in order to create good unit tests, but this will also vary depending on the extent of your testing.
For more information with finding the right level of abstraction, refer to Abstraction: The Goldilocks Principle. In many environments, the benefits will outweigh the costs, but we need to make a conscious decision about where abstractions are appropriate in each particular situation.