Designing The Perfect Modular Architecture
Applying everything I've learned across 10 years of building
I’ve intentionally avoided treading the worn-out arguments about MVC, MVVM, MV, VIPER, and your favourite homegrown solution. I don’t consider it a gap in my blog content, because…
They aren’t very interesting to write about.
I don’t consider this proper “architecture”.
Your choice of screen-level file layout is just about consistency. Orienting yourself in an unfamiliar part of the codebase. Pick one, and stick to it. That’d be the whole blog post.
Proper Architecture™ is about the building blocks of our system, and how they fit together. It’s as much art as science. Draw boundaries and boxes across your codebase, and gerrymander until your dependency graph sparks maximal joy.
Today, we’re going down my long-awaited hit-list:
The what and why of modular iOS architecture.
Getting on the same page vis-a-vis dependency graphs.
The good, the bad, and the ugly architectures I’ve experienced in 10 years.
How I utilised this experience to design Granola’s modular architecture.
By the end, I hope to transfer some of my architectural instincts to you.
Contents
This post is so full of knowledge, that your email client will cut it off. Read on my website for the best experience.
What is Modular iOS Architecture?
Many great iOS codebases begin as a big ball of stuff. One app, one target, one module, one project. Everything lives in one place, and making a new feature, service, or model is as simple as writing a file into a folder.
This works surprisingly well for longer than you’d think, but then you might start to feel the codebase pushing back on you.
Build time
As a monolithic module grows, small changes might invalidate a larger portion of the dependency graph (uh, dependency blob?). Incremental builds become… less incremental, code completion inevitably sloooows down, and Xcode struggles to cache reusable build outputs.
Code quality
With one module, you often cannot enforce meaningful boundaries at interfaces, because internal code (the default access control) is visible everywhere within that module. You need discipline and lots of private methods to avoid spaghettification.
You know what I mean if you’ve ever shamefully tasted that sweetest forbidden fruit: turning that private function internal just so the unit test can speak directly to it.
It’s also mildly cruel to deprive junior developers of their first change to legitimately use the public keyword. Or better, public private(set). You feel so badass the first time.
Organisational problems
Often an architecture grows to reflect the shape of the organisation maintaining it. Companies with a platform team will often have a big Core module. “Feature teams” will have modules split up along pod lines.
When teams, pods, tribes, scrums, etc, are divided by feature ownership, but the codebase has no corresponding boundaries, it gets muddy fast. Who owns those services, that model, or this infra? Engineers may step on each other’s toes, re-implement solutions twice, or design wholly different APIs.
Modular solutions
Dividing your app into modules gives your iOS app clearer boundaries between features and services, making the codebase easier to understand and explain. It allows you to enforce encapsulation, encourages reuse, and draws straightforward ownership boundaries across modules so tons of devs (or agents?) can work in parallel.
Modularity improves build times (usually!!*) by limiting how much code has to be recompiled after a change. If you edit one top-level feature module, Xcode can reuse its cached build outputs for non-dependent modules, compile independent modules in parallel, and cache more granular outputs.
*There can be too much of a good thing. Make your dependency graph too complex and interconnected with many tiny, poorly-bounded modules, or create loooong, deeply-layered chains of dependencies, and you’ll just slow things down instead.
We waited an appropriately long time to refactor Granola: I’ll go into more detail soon, but our ultimate modular redesign was motivated by a combination of 1) team growth and 2) some very specific pushback we were experiencing from the codebase.
Tooling and Libraries
When you want to modularise your app, the default is SPM. It used to be bad. It’s good. I like Tuist. Rolling raw Xcode projects are probably fine if you get Claude to manage the boilerplate. CocoaPods and Carthage are mostly-deprecated, you maniac. Don’t touch Buck or Bazel unless you are willing to open that particular Pandora’s box, and maintain it forever.
When you create a module, you need to choose whether you’ll use a static, dynamic, or mergeable library. These are incredibly important for modularisation, but very out of scope for this article. To make a long story short, pick between them to balance build speed, bundle size, and launch time. To make a long story long, you can read this classic of mine:
Static, Dynamic, Mergeable, oh, my!
If you want to embarrass a senior iOS engineer, ask them to explain the difference between Dynamic Frameworks and Static Libraries.
The Dependency Graph
One important concept in modularising an app: the dependency graph. Consider the modules as boxes or nodes along a graph. Yes, the data structure (you thought LeetCode was a waste of time?!). One node can’t compile unless all its dependencies, or imports, are compiled.
Your app builds the leaf nodes at the bottom of the graph, then modules depending on them, then modules depending on them, recursively, all the way up to your top level app module that ties everything together. While it can parallelise a lot of this, you can’t build a node without its dependencies. You get it.
This is why spaghetti architectures can slow down your builds: one tiny change can invalidate the dependency graph of dozens or hundreds of modules importing it across your graph.
Some of the architectures we’re looking at today are optimised with the Xcode Build system in mind. This is actually the core insight behind Tuist’s TMA and “Feature Interface” API contracts:
If your module’s public interface stays the same, modules depending on you will often not need recompilation, even if your module changes internally.
Modular Architectures I’ve Seen
I first wrote about modular architecture 2.5 years ago, somewhat early in my blogging career. For a while, I was embarrassed to share it, because I evangelised an architecture that had some serious scaling issues. Amateur hour. I’m more nuanced these days. Simple can be better.
Let’s take a look at some of the architectures I’ve experienced over 10 years of engineering, and dozens of projects.
But first, a word from my sponsor. Erm, from me:
Keep reading to learn about single-module, core, layered, and naïve feature module architecture, and the serious problems hidden within.
Next, I explain the more advanced approaches I’ve learned since starting by blog: service & orchestration layers, API contracts, and hyper-modular architecture.
Finally, I’ll demonstrate how I took all these learnings to design the perfect architecture for Granola’s iOS app.
Get the article now, plus:
⚓️ Access my full library of 50+ paywalled articles
🚀 Read free articles a month before anyone else
🧵 Master concurrency with my full course and advanced training
🧑🚀 Get a free copy of my new eBook, “Land your iOS Tech Job”
❤️🩹 Support independent, sometimes funny, tech writing






