This chapter guides you through the process of finding the best architecture for your project.
There’s no shortage of architecture patterns. Unfortunately, most patterns only scratch the surface and leave you to figure out the fine details. In addition, many patterns are similar to one another and have only minor differences here and there.
There are pragmatic steps you can take to ensure your architecture is effective:
- Understand the current state of your codebase.
- Identify problems you’d like to solve or code you’d like to improve.
- Evaluate different architecture patterns.
- Try a couple patterns on for size before committing to one.
- Draw a line in the sand and define your app’s baseline architecture.
- Look back and determine if your architecture is effectively addressing the problems you want to solve.
- Iterate and evolve your app’s architecture over time.
The two primary problems that good architecture practices solve are slow team velocity and fragile code quality.
Here are several problems that, when present, lead to slow velocity and fragile code quality:
- My app’s codebase is hard to understand.
- Changing my app’s codebase sometimes causes regressions.
- My app exhibits fragile behavior when running.
- My code is hard to re-use.
- Changes require large code refactors.
- My teammates step on each other’s toes.
- My app’s codebase is hard to unit test.
- My team has a hard time breaking user stories into tasks.
- My app takes a long time to compile.
Each of these problems can be caused by two fundamental root causes: highly interdependent code and large types.
Code becomes highly interdependent when code in one type reaches out to other concrete, i.e., non-protocol, types. Making one part of your code depend on another is extremely easy. This is especially true when a codebase has a lot of visible global objects.
Large types are classes, structs, protocols and enums that have long public interfaces due to having many public methods and/or properties. They also have very long implementations, often hundreds or even thousands of lines of code.
Adding code to an existing type is much easier than coming up with a new type. When creating a new type, you have to think about so many things: What should the type be responsible for? How long should instances of this type live for? Should any existing code be moved to this type? What if this new type needs access to state held by another type?
Designing object-oriented systems takes time. When you’re under pressure to deliver a feature making this tradeoff can be difficult. The opportunity cost is hard to see. The thing is, many problems are caused by large types – problems that will slow you down and problems that will affect your code’s quality.
It’s time to examine the problems associated with team velocity and code quality.
There are several ways in which architecture can impact readability:
How long are your class implementations?
600 line view controllers are very difficult to understand. A good architecture breaks large chunks of code into small, modular pieces that are easy to read and understand. The more an architecture encourages locally encapsulated behavior and state, the easier the code will be to read.
How many global variables does your codebase have, and how many objects are instantiated directly in another object?
The more your objects directly depend on each other and the more your objects depend on global state, the less information a developer will have when reading a single file. This makes it incredibly difficult to know how a change in a file might affect code living in another file. This forces developers to Command-click into tons of files in order to piece together the control flow.
How differently are your view controllers implemented across your app’s codebase?
Developers, including your future self, will spend a lot of time figuring things out if different features are implemented using different architecture patterns. Having a consistent structure drastically reduces the cognitive overhead required to understand code.
In addition, using consistent architecture patterns allows you to establish a common vocabulary. Speaking the same vocabulary helps everyone easily discuss and understand each other’s code.
You can’t see the effects of code changes easily when you’re working in a codebase that’s highly interdependent. Ideally, you should be able to easily reason about how the current file you’re editing is connected to the rest of your codebase. The best way to do this is to limit object dependencies and to make the required dependencies obvious and visible.
In a really fragile codebase, the change-break-fix cycle can snowball out of control. You end up spending more time fixing issues than improving your app. Not only is this a team velocity problem, it is also a code quality problem.
Apps can be complex systems running in complex environments. Things like multi-core programming and sharing data with app extensions contribute to the complexities involved in building iOS apps. Consequently, apps are susceptible to problems that are hard to diagnose such as race conditions and state inconsistency.
Some architecture patterns and concepts attempt to address these kinds of issues by designing constraints that act as guard rails to help teams avoid the most common pitfalls.
The structure of a codebase determines how much code you can re-use. The structure also determines how easily you can add new behavior to existing code.
Large types can prevent your code from being reusable. For example, a huge 2,000-line class is unlikely to be reusable because you might only need part of the class.
The part you need might be tightly coupled with the rest of the class, making the part you need impossible to use without the rest of the class. Types that are smaller and that have less responsibility are more likely to be reusable.
Ultimately, making code reusable allows you to ship a consistent user experience and allows you to tweak your app’s behavior easily.
How many times have you thought a feature change would be simple, yet you found yourself doing a large refactor instead? Architecture patterns not only help you re-use code, they also help you replace parts of your code without needing to do a big refactor.
Updating the types in your code to be easily replaceable really speeds up team velocity because it allows multiple people to work on multiple parts of a codebase at the same time.
Your app’s architecture also impacts how easily you can work in parallel with your teammates. When a codebase doesn’t lend itself to parallel work-streams, teammates either accidentally step on each others toes or become idle waiting for a good time to start committing code again.
Ideally, a codebase has small enough units that each person on a team can write code in a separate file while building a feature. Otherwise, you’ll run into issues such as merge conflicts that can take a long time to resolve.
To summarize, you’ll be able to build features much faster if your app’s architecture allows your team to easily parallelize work by loosely coupling layers and features that make up your codebase.
Code is notoriously hard to unit test because codebases are commonly made up of parts that are tightly coupled together. This makes the different parts impossible to isolate during test.
While building a new feature, how many times do you build and run your app? Several dozen or more, right? A long compile time can really slow you down. A fast feedback loop can really speed things up. That sounds good, but what does compile time have to do with architecture? A modular app architecture helps the Xcode build system from recompiling code that hasn’t changed.
A good architecture can even help you plan software development projects. Breaking user stories into tasks can be very difficult. Breaking user stories into tasks that everyone on your team understands is even more difficult. An app architecture that categorizes types into responsibilities creates a common vocabulary.
A common vocabulary enables you to build a shared understanding with other teammates about what kinds of objects are used to build features. This allows you to easily break down user stories into the different kinds of objects needed.
In addition to boosting your team’s velocity and strengthening your code’s quality, architecture can increase your code’s agility. Code that is agile is code that can be easily changed to meet an objective without requiring a massive re-write.
Here are some problems that can be solved with architecture in order to increase your code’s agility:
- I find myself locked into a technology.
- I’m forced to make big decisions early in a project.
- Adding feature flags is difficult.
Have you ever needed to plan a big migration project to migrate your code from one technology to another? Or have you wanted to migrate your code from one technology to another but couldn’t because the effort would be too big? Being locked into technology can put a pause on feature development and can prevent you from taking advantage of the benefits that new technologies have to offer. This problem is especially relevant to mobile development because, as you’ve experienced, mobile technology is constantly changing.
Third parties come and go. Many times, you’re expected to respond to these changes quickly. Your app’s architecture can help you do so.
Choosing which technologies to use when starting a new project is tempting. Some of these technology choices are big decisions that feel like one-way doors. Once you’ve made a choice, there’s no looking back. As apps get more complex, developers find themselves needing to make more technology decisions. You need a lot of technologies to build modern iOS apps. Wouldn’t it be nice to be able to start building apps without having to make all the big decisions up front? You might even find that you didn’t need a certain technology after all. A good architecture allows you to make technology decisions at the most opportune time.
Software teams are starting to use data-driven and lean approaches to app development. To take these approaches, developers use feature flags to A/B test features and to toggle unfinished features off. Your app’s architecture can make it easy or difficult to incorporate feature flags into your app’s codebase. You’ll be able to add feature flags easily if your app’s codebase is broken down into small loosely coupled pieces. A good app architecture gives you the flexibility needed to switch between behaviors and the flexibility to turn specific things on and off.
After you’ve identified the problems you’d like to solve, a good next step is to survey architecture patterns. The good news is, there are a ton of architecture patterns to chose from. The bad news is, there are a ton of architecture patterns to chose from!
New patterns pop up all the time, so it’s worth looking around to see if you can find something else that works best for you.
Choosing a pattern is not easy because we tend to feel a strong connection with one pattern or another. In all honesty, which pattern you select is less important than how you put the pattern to practice.
The best way to decide which pattern to use is to try a couple of the patterns in your codebase.
Here are some questions based on some of the pain points I’ve experienced when trying different patterns:
- Do you end up with a lot of boilerplate code? If so, does the boilerplate at least make the code easier to understand?
- Do you end up with a lot of empty files that only proxy method calls to other objects?
- Is the pattern hard to understand?
- How much will you need to refactor to apply the pattern?
- Is the pattern adding a lot of new concepts and vocabulary?
- Will you need to import a library to use the pattern?
These aren’t necessarily bad things. They’re just things to think about as you survey and compare different patterns.
All this to say, architecture is more of an art than science. Go experiment, learn and be creative. There’s no right way to do it. Just remember, there are many good ways to architect and there are many not-so-great ways to architect — but not a single right way.
Remember, how you apply a pattern is more important than which pattern you pick.
Here are some to keep in your back pocket:
- Loosely coupled parts: Whether you’re using MVC, MVVM, Redux, VIPER, etc. make sure your code is broken down into small loosely coupled parts.
- Cohesive types: Make sure your types exhibit high cohesion, i.e., the properties and methods that make up each type belong together. If you have small types that have very focused responsibilities, your types probably exhibit high cohesion.
- Multi-module apps: Make sure your app is broken down into several Swift modules.
- Object dependencies: Make sure you’re managing object dependencies using patterns such as Dependency Injection containers and Service Locators.