Over the last few posts, my legacy monolithic project with no unit tests has: configured a build server with statistics reports, empty coverage data, and a set of unit tests for the user interface. We're now in a really healthy position to introduce some healthy change into our project. Well... not quite: applying refactoring to an existing project requires a plan with some creative thinking that integrates change into the daily work cycle.
Have a Plan
I can't stress this enough: without a plan, you're just recklessly introducing chaos into your project. Though it would help to do deep technical audit, the trick is to keep the plan at a really high level. Your main goal should be to provide management some deliverable, such as a set of diagrams and a list of recommendations. Each set of recommendations will likely need their own estimation and planning cycle. Here's an approach you can use to help start your plan:
- Whiteboard all the components of your solution. You might want to take several tries to get it right: grouping related components together, etc. Ask a team member to validate that all parts of the solution are represented. When you've got a good handle on it, draw it out in Visio. (I find a whiteboard to be less restrictive at this phase...)
- Gather feedback on the current design from as many different sources as possible. Team members may be able to provide pain points about the current architecture and how it has failed in the past; other solution architects may have different approaches or experiences that may lead to a more informed strategy. Use this feedback to compile a list of faults and code smells that are present in the current code.
- Set goals for a new architecture. The pain points outlined by your developers may inspire you; but ideally your new architecture is clean, performs well, requires less code, secure, loosely coupled, easily testable, flexible to change and more maintainable -- piece of cake, right?
- Redraw the components of your solution under your ideal solution architecture. It can be difficult to look past the limitations of the current design, but don't let that influence your thinking. When you're done, compare this diagram to the current solution. Question everything: How are they different? What are the major obstacles to obtaining this design and how can they be overcome? What represents the least/most effort? What are the short versus long term changes? What must be done together versus independently? How does your packaging / deployment / build script / configuration / infrastructure need to change?
After this short exercise, you should have a better sense for the amount of changes and the order that they should be done. The next step is finding a way to introduce these changes into the your release schedule.
Introducing Change
While documenting your findings and producing a deliverable is key, perhaps the best way to introduce change into the release schedule is the direct route: tell the decision makers your plans. An informed client/management is your best ally, but you need to speak their language.
For example, in a project where the user-interface code is tied to service-objects which are tied directly to web-services, it's not enough to state this is an inflexible design. However, by outlining a cost savings, reduced bugs and quicker time to market by removing a pain point (the direct coupling between UI and Web-Services prevents third parties or remote developers from properly testing their code) they're much more agreeable to scheduling some time to fix things.
For an existing project, it's very unlikely that the client will agree to a massive refactoring such as changing all of the underlying architecture for the UI at the same time. However, if a business request touches a component that suffers a pain point, you might be able to make a case to fix things while introducing the change. This is the general theme of refactoring: each step in the plan should be small and isolated so that the impact is minimal. I like to think of it as a sliding-puzzle.
Introducing change to a project typically gets easier as you demonstrate results. However, since the first steps to introduce a new design typically requires a lot of plumbing and simultaneous changes, it can be a very difficult sell for the client if these plumbing changes are padded into a simple request. To ease the transition it might help if you alleviate the bite by taking some of the first steps on your own: either as a proof of concept, or as an isolated example that can be used to set direction for other team members.
Here are a few things you can take on with relatively minor effort that will ease your transition.
Rethink your Packaging
A common problem with legacy projects is the confusion within the code caused by organic growth: classes are littered with multiple disjointed responsibilities, namespaces lose their meaning, inconsistent or complex relationships between assemblies, etc. Before you start any major refactoring, now is a really good time to establish how your application will be composed in terms of namespaces and assemblies (packages).
Packaging is normally a side effect of solution design and isn't something you consider first when building an application from scratch. However, for a legacy project where the code already exists, we can look at using packaging as the vehicle for delivering our new solution. Some Types within your code base may move to new locations, or adopt new namespaces. I highly recommend using assemblies as an organizational practice: instruct team members where new code should reside and guide (enforce) the development of new code within these locations. (Just don't blindly move things around: have a plan!)
Recently, Jeffrey Palermo coined the term Onion architecture to describe a layered architecture where the domain model is centered in the "core", service layers are built upon the core, and physical dependencies (such as databases) are pushed to the outer layers. I've seen a fair amount of designs follow this approach, and a name for it is highly welcomed -- anyone considering a new architecture should take a look at this design. Following this principle, it's easy to think of the layers or services residing in different packages.
Introduce a Service Locator
A service locator is an effective approach to breaking down dependencies between implementations, making your code more contract-based and intrinsically more testable. There are lots of different service locator or dependency injection frameworks out there; a common approach is to write your own Locator and have it wrap around your framework of choice. The implementation doesn't need to be too complicated, even just a hashtable of objects will do; the implementation can be upgraded to other technologies, such as Spring.net, Unity, etc.
Perhaps the greatest advantage that a Service Locator can bring to your legacy project is the ability to unhook the User Interface code from the Business Logic (Service) implementations. This opens the door to refactoring inline user-interface code and user controls. The fruits of this labor are clearly demonstrated in your code coverage statistics.
Not all your business objects will fit into your service locator right away, mainly because of strong coupling between UI and BL layers (static methods, etc). Compile a list of services that will need to be refactored, provide a high-level estimate for each one and add them to a backlog of technical debt to be worked on a later date.
You can move Business Layer objects into the Service Locator by following the following steps:
- Extract an interface for the Service objects. If your business logic is exposed as static methods, you'll have some work to convert these to instance methods. I'll likely have a follow-up post that shows how to perform these types of refactoring using TDD as a safety net -- more later...
- Register the service with the service locator. This work will depend on how your Service Locator works, either through configuration settings or through some initiation sequence.
- Replace the references to the Service object with the newly extracted interface. If your business logic is exposed using static methods, you can convert the references to the Service object in the calling code to a property.
- Obtain a reference to the Service object from the Service Locator. You can either obtain a reference to the object by making an inline request to the Service Locator, or as the point above encapsulate the call in a property. The latter approach allows you to cache a reference to the service object.
Next steps
Now that you have continuous integration, reports that demonstrate results, unit tests for the presentation layer, the initial ground-work for your new architecture and a plan of attack -- you are well on your way to start the refactoring process of changing your architecture from the inside out. Remember to keep your backlog and plan current (it will change), write tests for the components you refactor, and don't bite off more than you can chew.
Good luck with the technical debt!