Inversion of Control in Legacy Applications
Podcast: Play in new window | Download (63.2MB) | Embed
Subscribe: Apple Podcasts | Spotify | Email | RSS | More
When you get dropped into a legacy project, one of the main things you’ll miss is the use of more modern development practices, such as testing, inversion of control, proper object models, and configuration management. However, you probably can’t simply stop active development for six months to re-architect your entire application. This is especially risky if you don’t have a proven track record of reworking applications in this fashion.
The code is never the most important aspect of a business, no matter how important it is to the developers. While working on old crappy systems is generally stressful and doesn’t help your career much, it’s really hard to get the business people to care. However, you can usually get away with making incremental system improvements. If you plan these improvements well, refactoring can make your work easier, giving you time for further improvements. Adding dependency injection and inversion of control is one of the first things you’ll probably do as you refactor a legacy application to make it more maintainable.
Cleaning up code in older applications is a lot of work, and you simply cannot do it all in a short period of time. There is a bit of art and science to the process of cleaning up legacy systems to make them more maintainable. You have to be smart about the way you handle this work, both because of the risk of breaking things and because of the way office politics tend to work. However, if you do it well, you can drastically improve the functioning of nearly any old crusty app that you run across.
Episode Breakdown
Why you would want to do this.
Improved ability to quickly change the app. When components are pluggable, it’s easier to switch implementations across the application as needed. You can even control this configuration dynamically from settings, which simplifies your code. This can also be a critical component of your strategy if you plan to support a plugin architecture.
Improved object lifecycle management. Your app may not be properly managing things like network sockets, database connections, etc. A lot of this stuff is encapsulated in objects that you use and getting rid of those objects gets rid of the underlying resource. Sloppy development practices tend to mean that these objects hang around longer than they should, especially in garbage collected languages. This is also a good way to manage lifetimes of objects using policies to make sure that they are kept around for the length of a logical unit of work.
Easier testing. Because you can switch the implementation being used, it’s easier to use mocks and stubs. This tends to simplify testing code, because instead of fully implementing the object you are trying to mock, you only really need to mock the methods that you are actually using. It’s also nice to be able to mock some of the slower objects with faster code that returns more quickly. This makes tests run faster.
Necessary precursor for modern application development. Most modern object-oriented development frameworks make fairly heavy use of dependency injection and inversion of control. Additionally most modern frameworks can be configured to use IOC in a relatively transparent fashion when communicating with your application components.
Prerequisites to this process.
Data access layer While you certainly can proceed without having a data access layer, having one makes this process easier because you can abstract those calls away. Database calls, network calls, interactions with the file system, and interactions with other APIs often add a lot of complexity that you really don’t want to deal with just yet. Additionally, lots of applications mix their business logic in with these calls, so refactoring to separate the two is a good way to make the codebase more sane in the first place.
Refactor to interfaces If your language of choice allows it, make interfaces for as many of your types as possible and use those. This is one of the cleanest and easiest ways to switch out implementations without breaking anything. This does have a slight overhead for developers in most IDEs though, so make sure your team knows how to deal with it.
Get testing practices in order Needless to say, if you are doing this, you need a QA department and probably at least some automated tests. Because the stuff you are messing with is deep in the system, you will break things in ways you don’t expect. You cannot get by with some dude who only tests the happy path and only tests stuff that you think you might have broken.
Wire in DI/IOC setup to your application’s start process.
This is the first step and you need to do it in a way that doesn’t cause problems. With some IOC frameworks, this is as simple as downloading a package and maybe adding a line of code or two. However, for other frameworks, there is often a fair bit of configuration just to get started and you’ll have to get it right for it to work at all. One of the biggest headaches here is getting this stuff to play nicely with any configuration management system that you might have.
This can be tricky due to the timing of this step. Additionally, you have to think about when to hook this stuff up. This step may depend on other parts of the system. For instance, you may need to specify things like how you retrieve settings before wiring up your IOC container, simply because it uses that configuration. Additionally, you probably are going to need logging and instrumentation here if you want to cleanly troubleshoot any problems that occur, as the built in management of errors may not be working at the point when you need to have IOC in place.
The framework you use may get in your way here, perhaps considerably. A lot of legacy frameworks are simply not build with IOC in mind. These can mean anything from a lack of appropriate application lifecycle hooks during startup to frequent use of static types, sealed (no-inherit) classes, to old ways of managing objects. This is particularly prevalent in fairly recent versions of the .NET stack, but you’ll run into it a lot with older code.
Configure third party code in the IOC container.
Third party services need to be done next, because they often present unique issues. Third party dependencies can often have their configuration. They may also make direct calls to libraries that you’d rather mock. Sometimes it’s actually impossible to mock them at all due to the way they are built.
Be especially careful about object lifetimes. Third party components are built with some assumptions about how they will be used. You should try to match those assumptions, especially the ones about object lifetimes, as closely as possible. Doing otherwise risks hard-to-debug errors, memory leaks, and even data corruption, so it’s very important to get this right.
Be mindful of dependencies in third party types as well. Third party libraries themselves may support dependency injection and modern software practices, that doesn’t mean the libraries they are using are doing so, or that they do so cleanly. For instance, you might find that the library you are using relies on a data access framework that isn’t IOC compatible. This might make it difficult for you to induce situations in a library to see what happens when they occur. This can be important for situations like time changes, leap years, etc., when you want to test before the fact.
Configure your own services in the IOC container.
One of two approaches. Configure all of them immediately, or do it as you go. If you configure them all and slowly switch over to using that configuration type by type, this limits the scope in the IOC container that you have to worry about. If you just switch things over as you find them, it’s less upfront work, but potential more debugging as you find lifecycle and usage problems in the app. Realistically, you won’t pick a strategy based on what works best, but rather based on what you can get away with under your current constraints. Pick your poison.
Doing it all at once will make the rest of this easier. If you can get away with this, it’s ideal. However, you are potentially looking at weeks or months of steady, annoying work, with literally nothing to show to management unless they deeply understand what you are doing and why.
This will require you to refactor places where static types are used. Either scenario will require that you refactor any static types that you have so that they are no longer static. If you can’t refactor the static type, you’ll need to introduce an abstraction around it.
Build a service locator wrapper.
While you could directly transition to using your IOC container, you are probably better off putting a layer in between your application and the container. This does several things for you. First of all, it abstracts you from the container, so that you aren’t out of luck when you find that it doesn’t work well and have to replace it. It also gives you an area where you place logic around object creation that will eventually migrate to the IOC container, before you are entirely sure what that logic will be.
Be sure and add logging here. You’ll want to log and instrument these calls and have both things be configurable. This can make it a lot easier to troubleshoot how your code might use your IOC container in the future, without dealing with any of the “wrinkles” that your container will undoubtedly have. IOC container code tends to be somewhat complex due to the kind of situations it has to handle. You are unlikely to understand all the underlying reasons the first few times you use a particular container. You learn by getting burned, and this layer can make diagnostics easier.
Move all calls that create instances to use the wrapper methods (one per type). Separate your calls by type so that you can troubleshoot a particular type with targeted logging and debugging. This also makes the later step of gradually refactoring away from this class easier to do in a piecemeal fashion.
Carefully examine logging/instrumentation output for expensive operations and to what goes in and out of your service locator wrapper.
Wire up instrumentation and logging so you can track what is occurring. Now that you have things in place, you need to collect a system baseline. Remember, the goal is to make the system better. You need to be able to prove that it’s better. This is especially important if management doesn’t understand why you are doing this. Real data gets them off your back.
You might be surprised by how often some types are being created. One of the first things you’ll notice is that at least some classes are being created at least an order of magnitude more often than you thought. Remember, nobody (probably) thought about object lifetimes before you started this process. You are bound to find surprises. When you find them, take note of them, and point them out to people up the chain of command along with why they are a problem.
You’ll also find that some types take a surprising amount of time to create. You’ll probably also discover that some types are created extremely slowly for whatever reason. You’ll have to examine how frequently they are created to determine whether this is a problem. Whatever the case, take note of these, because you need to be careful about how you manage their lifecycle.
Start updating constructors to take dependencies into account. Mark old constructors as obsolete.
Do this bottom up, starting with the simplest types. These types tend to be used by other types further up, so you’ll need to handle them first anyway. This will also simplify the types of problems you run into as you transition to more modern practices. It’s no fun running into a challenging IOC bug while having to deal with a complex use case in your application at the same time. This is also a good way to help improve the skill of your team as well. Starting with the easy cases first makes the learning curve less intimidating.
You may want to create new types as you go to add flexibility. You might want to put thin wrappers around a lot of your direct interaction with the system, simply so that you can effectively test the system as you go forward.
Strongly consider adding tests as part of this process. Remember, you are making fairly deep changes to the software, so having thorough tests will greatly reduce your stress. Processes like adding IOC tend to result in large, sweeping software changes, and these are faster if you have good tests.
Start by converting a single, thin vertical slice of your app to use IOC all the way through. Rinse and repeat.
Once most of the innards of the app are converted, you need to start on the user-facing parts. The reason you leave these for last is that they tend to orchestrate much of the rest of the rest of the system. These are also the points where your code is being called by an external framework, whether that is something like a web host, or your desktop operating system. The systems calling into your code may not fully utilize your DI/IOC setup and this may require custom code to resolve.
This means getting your IOC container configured in such a way that it works with your framework. Usually there is a pile of configuration that has to happen so that the calling code knows how to talk to your framework. Sometimes this configuration may have to change between different environments, so be prepared to have to deal with whatever configuration management tools you are using as well. You may find that constructor injection can’t work and that you have to use a different strategy in this part of your application.
You’ll probably run into types that can’t be injected, for whatever reason. A lot of the types used under the hood of your framework may be used statically as well, rather than being injected. This can often mean a limited amount of control over how certain parts of your code work, regardless of anything you’ve done elsewhere. It can also mean that you have to directly hook into parts of your runtime framework, simply to get the system to play nicely with your code, especially if your framework is older.
Object lifecycle issues will often become visible as you do this. You’ll again run into object lifecycle issues, because user behavior tends to differ considerably from developer behavior. Be especially careful of situations where expensive objects are created in response to user input, as these are places where a user could conceivably overload your system. Also be careful about the intersection of object lifecycles and threading, as this part of many systems tends to dispatch to background threads to do work while keeping the front-end responsive.
Start deprecating methods on your IOC wrapper class, and remove them as they become unused.
A lot of the wrapper methods you built earlier are probably already unused by this point. Get rid of them. As you’ve been slowly working along, you’ve probably eliminated many, if not all, of the calls to most of the methods on your IOC wrapper. Unless you were exceptionally disciplined, you probably also didn’t clean up dead code as you went along, so now is a good time to do so.
How to get rid of the rest. There are three approaches, and you’ll use them based on the circumstances you encounter. Some of the remaining items are simply weird corners of your application that you missed in the earlier steps. Resolve these first. Some of the remaining items have a lot more logic around the way that you are creating the types being used. Consider reworking these into a factory pattern. You might also find that the types you are trying to create are no longer used. Delete these as well. You should be able to get rid of your IOC wrapper class after this and either use build-in IOC or call the container.
Look into ways to hook instrumentation into your IOC container. This will help you troubleshoot in the future. You probably should replace the instrumentation from your wrapper class with code that directly interfaces with your IOC container. You’ll still occasionally need to instrument things. When doing this, you probably will either run into timing issues or object lifecycle issues as you try to make your logging framework play well with the IOC container. Make sure that your log verbosity is still configurable, both at the level of severity and based on the object type you are creating. Otherwise you’ll be drinking out of a fire-hose.
Book Club
Remote Work – The Complete Guide
Will Gant
Chapter 2 – How Remote Work Provides Companies a Competitive Advantage. In this chapter, Will talks about the things that remote work can do for your company. While many companies have been forced to support remote work lately, this chapter was written in a time when companies didn’t realize how vulnerable they were to a disruption. This chapter is geared towards business advantages that you can use to convince your boss of the value of remote work, even if there isn’t a pandemic going on. In it, he discusses things like employee retention, a larger and cheaper hiring pool, greater business resiliency, as well as cost advantages of allowing remote work.
Tricks of the Trade
Just like there are lots of things we don’t think about being in older applications, there are a lot of people working behind the scenes that we don’t think about until we need them. In this time of global crisis those on the front lines, the doctors, nurses, and first responders are getting lots of praise. Let’s not forget about the lab rats diligently working to create a vaccine. Or those in the service industry who are not getting the hours or tips they need to pay bills. Something as simple as saying, “Thanks I appreciate you.” to a person on at a call center can make their day when most of who they deal with are angry people. Also, thank your infrastructure and operations coworkers. They are overloaded with work as people are all trying to work remote.