My current assignment is working on a rather large app that had a huge summer push that involved scaling the team up to several developers per platform and the addition of a handful of third-party libraries. As anyone who has worked in iOS development will tell you, the App Delegate is often the dumping ground for lots of things that need to happen at startup, but don’t have a good place to fit. Every third party library integration manual has something along the lines of the phrase “add the following lines to your App Delegate.” Over time, these small additions slowly accrete and take their toll in complexity and file length.
Our App Delegate on this particular project had grown well past the point (400 lines) of Swiftlint warnings, and the bulk of that code was all in the
applicationDidFinishLaunching method that starts the app. It got so complex that I actually introduced a bug by simply moving a line of code in the file. That was a sign that we needed to spend the time and undertake a solid refactor to make sure everything was back in line with our expectations. As I told my fellow developers when showing off the specifics in our show-and-tell meeting, the specifics of the refactor were largely pedestrian – mostly just taking the time to think about it and do all the little details. For some specific suggestions, see Vadim Bulavin’s Refactoring Massive App Delegates.
Refactoring – What are we looking for?
But this whole exercise got me thinking that it might be a great time to blog about some of the things I look for in a good refactor. What makes me smile when I can look at the code and feel like that was time well spent? As a quick aside, I really do love a good refactor. I love seeing code come into focus as all the pieces are pulled apart, cleaned, and reassembled better than they were. It’s a very rewarding exercise.
A frequent difficulty of writing good code is trying to fit the relevant context in your head at the same time. Do you understand how all of this piece of code works, what it was designed to do, and what kind of changes you can make to it? When code grows to an unwieldy size, it starts to make this task even harder. You can’t fit all of the code on your screen at once, and you can’t be sure what pieces are interconnected or interdependent.
A good refactor will break apart those large monoliths of code into smaller pieces. If you know you need to do 5 things at app start, it’s much easier to see 5 method calls or 5 commands that are launched off without needing to wade through each and every piece of all 5 to understand what’s happening.
Think back to the third party libraries I mentioned earlier. Let’s say each has 5-10 lines that initialize and start their integration. That seems pretty small, but now you add 5 of those. That’s 25-50 lines of code each in the middle of a possibly complicated method that you will need to read through every time you edit this method. That’s something that costs time and mental energy for something that probably never needs to change once implemented. By refactoring that out of our way, we no longer pay the cost each time we edit. We can gloss over the details because they’ve been pulled out elsewhere.
Along a complementary line of thinking is the goal of clarifying responsibilities. As we pull different things out of a monolithic method or object, we’re finding or creating places that are more appropriate to handle that responsibility. Much like the difficulty of working with large files, unclear responsibilities get in the way of reasoning about the code we’re working with.
In the case of the App Delegate, it’s very clear that it shouldn’t be responsible for every third party integration. It simply becomes a sort of dumping ground for these responsibilities because it runs early in the startup process. But each of these can be pulled out into smaller objects that can be given the responsibility for initializing one integration directly. This makes things easier to read, easier to discover (i.e. if I know there’s something in a specific framework, I know where to find its initialization – rather than searching through the App Delegate), and easier to reason about (if I’m changing something, what else is likely to be affected?)
Both of these qualities lead to the third goal of a good refactor – additional testing. Smaller pieces with clear responsibilities are much easier to write and maintain tests for. This is pretty obvious on the surface – the clear responsibilities mean that we know what to test, and the smaller pieces mean we have less to test in each piece. A lot of good code habits work like that – each improvement makes other improvements easier.
In this particular project, the whole codebase had an average of 93% test coverage. In the App Delegate, the coverage was 0%. One of the particularly thorny pieces of working with the App Delegate is just how difficult and awkward it is to pull apart and test. And yet, due to its ownership of the critical app startup process, it’s one of the most important pieces of the app. Very critical + zero tests = DANGER.
Breaking those pieces out of the App Delegate meant that we could write tests for each integration individually. By spreading out the critical pieces of app startup, we can get at far more of the functionality with our tests. That means it’s far less likely to break simply because we’ve moved a line of code around. As the responsibility is pulled away from the App Delegate, we also reduce its criticality. If it’s going to be a difficult piece to test (and the way it’s designed, it is), then let’s reduce it to calling out to some other well tested pieces and not worry about testing things that are going to be hard to test.
So to sum up: Small pieces of clear responsibility enforced by thorough tests. That’s a recipe for a very nice codebase overall and it serves as a foundation for what I’m looking to get to when I embark on a refactor. Refactoring isn’t wildly different from creating great code in the first place; we’re just giving ourselves permission to take what’s good and make it great.