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
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.
Single Module
Not much to say about this tbh!
Prior to 2019, every app I built was a single module; as is almost every indie project I touch today. If build times aren’t a problem, or you’re working alone, why take the time?*
*That said, I often ask my agent to split up my projects if I get where I want to outside of my token limits. It makes me happy, even if I’m the only one who cares.
Core Module Architecture
If you ever decide to modularise your app, but you don’t have much time, this is usually how you’ll start. It’s a 2-parter:
App, containing features and your top level target.
Core, with everything else: models, networking, persistence, auth, utils, analytics, and shared UI.
This approach is nice for multi-target apps. In The Side Hustle From Hell, we foolishly tried to create a 2-sided marketplace, with apps for car mechanics and punters whose engine just conked out. On iOS and Android. 4 apps. Zero users.
The Core module is also a natural fit for Kotlin Multiplatform or organisations with a dedicated platform team.
Layered Modular Architecture
The first time I saw this architecture was on a top-secret electric car project which, I found out while in the pub, got abruptly sh*tcanned. An extremely cracked engineer, who rapidly left our company for more money, built a 3-tier layered app architecture with FeatureKit, ModelKit, and NetworkKit.
It was the first modular app I’d touched. And the most exciting thing I’d seen.
This architecture groups common functionality together into layers that serve the data requirements of the layer above, enforcing a highly unidirectional data flow through each layer.
App
↓ ↑
Repository
↓ ↑
NetworkingThis architecture made an impression. I adopted the same structure for Bev, my original magnum opus (back when that word meant something). The top-level Bev module imports a Repository, which in turn synthesised the most up-to-date data from both our local Database and Networking modules.
This is nice for very simple projects, but begins to get tricksy once you add authentication, analytics, models, business logic, arbitrary services, and utilities. Where do you place those?
Unless your project remains very simple, you will feel the codebase nudging you towards a multi-modular setup inside each layer.
Naïve Feature Modules
Feature modules are the divergent evolution of your initial Core module. Instead of focusing on Layers and business logic, you might want to split your top-level UI layer into many; usually one module per feature.
My first startup, Carbn, eventually got here after playing around with a Core module to start with (rolling pure .xcprojects!), and my subsequent role at Gener8 had the same shape, set up using modern SPM.
If you work on a legacy app that wants to modularise, and you aren’t thinking that hard about it, you’ll probably land on feature modules. It offers nice ownership boundaries, excellent build time benefits, and two screaming weaknesses that I didn’t explain properly in my first architecture article.
The problems with feature modules
“Where do we put shared business logic?”
It’s nice to give feature modules ownership over their own models and services, but suddenly you’re contending with awkward refactors whenever another feature needs it.
Eventually, you either throw models, services, and logic back into a shared Core module, or endure an uncomfortable hybrid with some in the Core and some domain-specific stuff living in the features.
This is the first bit of resistance from your codebase you should feel, and keenly. Business logic and services mostly want to live in their own modules. Architecture wants to be consistent, and having to think hard about where to put your new model or service is a sign of a bad architecture.
“How do we reuse these screens?”
At Gener8, our clean SPM-defined feature modules worked great: we had a coordinator per module that could transition you between any screen in the module, or kick off a new flow to enter a new module.
Until, one day, I was asked to present UI from our Rewards module on the Home screen.
F*ck!
My beautifully-designed feature module architecture fell apart here. I had to create a low-level shared shim protocol, inject it at the top level, and wrap it in AnyView to allow Home to present the typed Rewards UI element.
This is another burning red flag 🚩. Creating a tiny shared protocol to let one screen talk to another is the quintessential architectural pushback I’ve been blathering on about. This… worked… but the codebase was not happy with the way I was torturing it.
This solution was, fundamentally, a factory protocol, but I couldn’t have articulated it at the time.
Both of these problems were solved in codebases I worked on later in my career.








