When I published Modular iOS Architecture @ Just Eat in December 2019, modularisation was still a relatively novel concept in iOS development. We had been at it since 2016, driven by specific organisational needs: bringing together country apps onto a single platform and apply Conway's Law rather than the more common motivation of build-time parallelisation. The article described the architecture we had settled on after three years of iteration: a directed acyclic graph of modules, partitioned into three broad groups (domain, core and shared), with strict rules against peer-module dependencies.

In the six years since, a great deal has changed. CocoaPods was fully replaced by Swift Package Manager. The monorepo approach we were tentatively advocating for became the unquestioned standard in the industry. The team grew substantially, the product expanded into new markets, and the number of modules roughly doubled. What didn't change was the fundamental shape of the graph or the core dependency rules governing it.

Yet in late 2025, a seemingly small problem — a UI component with nowhere to live in the existing model — surfaced a conceptual ambiguity that had been quietly accumulating for years. Resolving it led to the refresh described in this article.


What the 2019 model got right

Before describing what changed, it's worth being precise about what didn't. Several properties of the 2019 architecture proved robust enough to survive intact:

  • The graph must be acyclic. Dependencies flow in one direction, always. No module may depend — directly or transitively — on a module that depends on it. This means respecting the Acyclic Dependencies Principle.
  • Feature modules do not depend on other feature modules. Anything two features need to share is extracted into a lower-level module. This prevents the lateral coupling that tends to develop over time when features are allowed to reach into each other.
  • Each module exposes a facade. Modules should be deep with a narrow, stable public interface. The internal architecture of a module is unconstrained and teams may use MVVM, TCA, or anything else, provided they do so behind the facade.
  • Demo apps are first-class citizens. Every module ships with a standalone demo application. This remains one of the most impactful practices we have: it forces good API design, serves as living documentation, and provides a fast local feedback loop during development.

The 2026 refresh doesn't replace the 2019 architecture and it's rather a refinement of the vocabulary and an extension of the rules to cover cases the original model didn't address.


The crack that started the conversation

The problem that triggered the rethink was a component called CountdownTimer. It is a countdown UI component built on top of our design library and not part of the JET design system (PIE), used by both the Checkout and Restaurant domain modules.

Under the 2019 model, every module belonged to one of three groups: domain (feature-specific, may change frequently), shared (pure utilities with no local dependencies) and core (like shared but only imported at app-level). Domain modules could depend on shared modules, but not on other domain modules. Shared and core modules could not depend on anything local. Modules in each group can depend on third-party dependencies.

CountdownTimer fit neither group cleanly:

  • It can't be shared because it depends on PIE. Under the 2019 definition, shared modules have no dependencies.
  • It can't live inside Checkout or Restaurant because the other would need it too, creating a domain → domain dependency — which is forbidden.
flowchart TB
    Checkout --> CountdownTimer
    Restaurant --> CountdownTimer

The model had no concept for a module that has dependencies and is shared across domain modules. CountdownTimer was first the crack and it didn't take long for other similar modules to appear highlighting the need for revisiting the design.


A note on the cross-domain proposal

A colleague proposed an elegant-looking fix: introduce a third layer called cross-domain, sitting between domain and shared. The layering would become:

domain      →  cross-domain   →  shared
Checkout       CountdownTimer    DateFormatting
Restaurant     DynamicLayout     ErrorUtilities

CountdownTimer would live in cross-domain: it could depend on shared modules, and domain modules could depend on it. The approach was implemented: layer rules were encoded in a YAML config file, and a new CLI tool was introduced to enforce them. For a moment it looked like the problem was solved.

I revisited the proposal shortly after and something felt off. The more I thought about it, the more I realised the cross-domain layer was a positional label rather than a meaningful category. CountdownTimer is not "cross-domain" because of anything intrinsic to what it is. It was labelled "cross-domain" purely because of how many modules happened to use it and because it had a dependency on PIE. If a future project used it from only one feature module, would we move it back to domain? The category was defined by the topology rather than the purpose.

The same confusion was visible elsewhere. We had long kept a distinction between "Core" modules (APIClient, NavigationEngine) and "Shared" modules (DateFormatting, ErrorUtilities). Both sat near the bottom of the graph, but they occupied separate mental buckets with no formal rule distinguishing them. Core and shared were a positional label and not a purposeful one.

I believed that the right fix was not a new positional layer but rather a different categorisation model altogether.


The 2026 model: categorise by purpose

The refreshed model categorises every component by its purpose (the role it plays) rather than by where it happens to sit in the dependency graph. The topology is then a consequence of the rules and not an input to them.

There are five categories. Three describe components we own; two describe third-party packages.

First-party categories

  • App — iOS applications and their extensions: the consumer app, demo apps, notification extensions, widgets, etc. These are the roots of the graph; nothing depends on an App.
  • Feature — what we used to call "domain": an area of the product with UI belonging to a business process. E.g. Checkout, Orders, Restaurant, Account. Deep modules implementing a facade pattern; they change frequently and are consumed only by the App.
  • Foundation — modules with a single, clear responsibility: DateFormatting, ErrorUtilities, APIClient, NavigationEngine, Logger, CountdownTimer. The key move here is the unification of what were previously two separate buckets (Core and Shared) into one category. APIClient and DateFormatting are both Foundation modules because of their purpose (a single, clear responsibility), not because of their depth in the graph. It follows naturally that Foundation modules may depend on other Foundation modules.

The Foundation category is also where CountdownTimer lands. It has a single clear responsibility and depends on PIE, which is itself a Foundation module (a design library with a single clear responsibility). With this new model, no new layer are needed.

Third-party categories

A longstanding gap in the original model was that third-party packages were outside the rules entirely. Nothing prevented a Foundation module from pulling in third-party dependencies, even though we all agreed this was bad practice. The 2026 model closes that gap by giving third-party packages their own categories:

  • SDK — a heavyweight third-party capability that encapsulates an entire domain and should sit behind an injection boundary. Firebase, BranchSDK, GoogleTagManager, SnowplowTracker. The rule: only an App may depend on SDKs. Features and Foundations may not. This is the enforced form of "place large third-party dependencies high in the graph, behind an abstraction" — a principle that existed in 2019 as guidance and exists in 2026 as a hard constraint.
  • Utility — a small, stable third-party helper safe to depend on widely. Alamofire, Nuke, Stateful, PhoneNumberKit. A Utility behaves like a Foundation leaf: App, Feature, and Foundation modules can all depend on one.

The SDK/Utility split mirrors the first-party Feature/Foundation split: SDKs are heavyweight capabilities we keep at arm's length; Utilities are low-level building blocks. The distinction is recorded in a package catalogue file (that we use for a variety of operations on the stack) that lists all third-party dependencies, where each entry carries a category field:

{ "name": "Firebase",  "category": "sdk"        }
{ "name": "Alamofire", "category": "utility"    }
{ "name": "PIE",       "category": "foundation" }

First-party remote packages follow the same category rules as local modules. This is the case of PIE which is consumed as a remote first-party package and is categorised as a Foundation module.


Two rules

The architecture enforces exactly two rules. Everything else follows from them.

Rule 1 — No cyclic dependencies: The graph must be a DAG. A→B→…→A in any form is rejected, detected via depth-first search.

Rule 2 — The category matrix: Every dependency edge from component u to component v must be permitted by:

From ↓ / To → App Feature Foundation SDK Utility
App
Feature
Foundation

Several consequences are worth calling out explicitly:

  • Feature → Feature remains forbidden. This is the unchanged core of the 2019 rule. Anything two features need to share is extracted into a Foundation module or coordinated through an upstream abstraction in the App.
  • Foundation → Foundation is now explicitly allowed. This is the deliberate relaxation. It resolves the Core/Shared ambiguity and removes the need for any special treatment of modules.
  • App → SDK is the only entry point for heavyweight third-party SDKs. A Feature or Foundation depending on Firebase or BranchSDK will fail validation. The SDK is a dependency of the App, and Feature modules receive its capabilities through an injected abstraction.
  • Utility modules are Foundation-equivalent for third-party packages. A Utility may be depended on by any first-party component, exactly as a Foundation module can.
  • SDK and Utility nodes are leaves. Their outgoing edges are not modelled or enforced since we do not control what third-party components depend on.

The category matrix is hardcoded in a validator CLI, not read from a configuration file. This was a deliberate choice: the matrix encodes an architectural law derived from ADP (Acyclic Dependency Principle), SDP (Stable Dependency Principle), and DIP (Dependency Inversion Principle).

The following diagram shows a representative slice of our module graph, with edges colour-coded by allowed category pair:

flowchart TB
    subgraph L0["L0"]
        App
    end
    subgraph L1["L1"]
        APIClient
        Account
        Checkout
        NavigationEngine
        Firebase
        SnapshotTesting
    end
    subgraph L2["L2"]
        CountdownTimer
        DateFormatting
        Stateful
        KeychainAccess
    end
    subgraph L3["L3"]
        PIE
    end
    App --> Account
    App --> Checkout
    App --> APIClient
    App --> NavigationEngine
    App --> Firebase
    Checkout --> CountdownTimer
    Account --> PIE
    APIClient --> DateFormatting
    CountdownTimer --> PIE
    NavigationEngine --> Stateful
    Account --> KeychainAccess
    App --> SnapshotTesting
    linkStyle 0,1 stroke:blue
    linkStyle 2,3 stroke:green
    linkStyle 4 stroke:magenta
    linkStyle 5,6 stroke:orange
    linkStyle 7,8 stroke:brown
    linkStyle 9 stroke:pink
    linkStyle 10 stroke:teal
    linkStyle 11 stroke:grey

Blue: App → Feature
Green: App → Foundation
Magenta: App → SDK
Grey: App → Utility
Orange: Feature → Foundation
Teal: Feature → Utility
Brown: Foundation → Foundation
Pink: Foundation → Utility


Layers are a derived view, not an authored property

One of the more significant conceptual shifts in the 2026 model is the distinction between category — an authored, stable property — and layer — a computed, dynamic one.

Category is what you write: modules are tagged with the category property once, and that tag doesn't change unless the module's purpose changes. It is stable precisely because it describes what something is.

Layer is what the validator computes: each node's longest distance from the set of App roots, calculated as a topological-order pass over the graph. Layer numbers are not written anywhere and therefore layers cannot be broken. The concept doesn't exist in code.

As a consequence, the following observations arise:

  1. No two components in the same computed layer can depend on each other. Same-layer dependencies are impossible by construction as they would contradict the longest-path definition.
  2. Adding a legal dependency re-layers the graph without violating any rule. Say App depends directly on both AppUpdate and DateFormatting, placing both at L1. If AppUpdate later adds a dependency on DateFormatting — a legal Feature → Foundation edge — DateFormatting is simply recomputed to L2. Its longest path is now App → AppUpdate → DateFormatting. Nothing is violated; the graph just gets one step deeper.
flowchart TB
    subgraph L0["L0"]
        App
    end
    subgraph L1["L1"]
        AppUpdate
    end
    subgraph L2["L2"]
        DateFormatting
    end
    App --> AppUpdate
    App --> DateFormatting
    AppUpdate --> DateFormatting
    linkStyle 0 stroke:blue
    linkStyle 1 stroke:green
    linkStyle 2 stroke:orange

This matters in practice because it means the question "which layer is my module at?" has no single, stable answer — it depends on the current state of the graph. The right question is always "which category is my module?" and the layer follows from the rules, automatically.


How it fits together in practice

Each module carries a YAML spec declaring its category and its dependencies:

name: CountdownTimer
category: foundation
localDependencies: []
remoteDependencies:
  - name: PIE
name: Checkout
category: feature
localDependencies:
  - name: CountdownTimer
    path: ../CountdownTimer
  - name: DateFormatting
    path: ../DateFormatting
  - name: ErrorUtilities
    path: ../ErrorUtilities
remoteDependencies:
  - name: PIE

A separate CLI (PackageGenerator) converts these specs into SPM Package.swift manifests. App-level dependencies are declared in TargetDependencies.json files which are read by Tuist to generate the Xcode project. Third-party package categories live in the package catalogue (PackageDependencies.json). This is the only place where the SDK/Utility distinction is declared, and the validator enforces it with the same rules as local modules.

The validator (ModularArchitectureValidator) is a Swift CLI tool that pulls all three sources together, builds the full dependency graph, and runs the rules over it:

ModularArchitectureValidator validate \
  --modules-folder Modules \
  --package-dependencies PackageDependencies.json \
  --target-dependencies TargetDependencies.json

On a rule violation, it reports every offending edge and exits with a non-zero code:

ERROR: Checkout (feature) → Orders (feature) — feature cannot depend on feature
ERROR: APIClient (foundation) → Firebase (sdk) — foundation cannot depend on sdk

The validator runs locally during project setup and on every pull request on the CI. A violation blocks the PR. Is also exposes an emit-graph command that outputs the full graph in Mermaid or Graphviz DOT format which we find useful for visualising the dependency topology as the codebase evolves.


A note on the diamond shape

PIE is a leaf that many modules share. Several features and Foundation modules converge on it:

flowchart TB
    App --> Search
    App --> Restaurants
    App --> Orders
    App --> ...
    Search --> PIE
    Restaurants --> PIE
    Orders --> PIE
    ... --> PIE

This is a diamond shape in the graph, and it is entirely deliberate. "Dependency hell" describes conflicting versions of a shared dependency; for source-integrated first-party packages, there are no versions to conflict. A diamond over a stable, widely-shared Foundation module is healthy reuse.

We accept that the Dependency Inversion Principle cannot be applied to a design library in any practical sense as it's not feasible to abstract the entire design system behind protocols and that is the right call. PIE is a stable leaf that any Feature or Foundation may depend on.

It is also worth noting that Apple has made the build-time cost of wide diamond dependencies substantially smaller in recent Xcode releases. With explicit module builds (SWIFT_ENABLE_EXPLICIT_MODULES), Xcode determines the full module graph upfront and begins compiling dependencies as soon as their emit-module phase completes, rather than waiting for the full compilation of each target. The diamond topology that looked concerning from a build-time perspective in 2019 is much less of an issue these days.


Onwards and upwards

All in all, the 2026 refresh is narrower in scope than it might appear. The graph looks similar to what it looked like six years ago. The Facade pattern, the no-Feature → Feature rule, the monorepo, the reliance on demo apps remain unchanged.

What changed is the vocabulary and the scope of enforcement. "Category" is now the authored, stable label for a component's purpose; "layer" is reserved for the computed depth in the graph, which is a consequence of the rules rather than a property you configure. The Foundation category unified what was previously called "Core" and "Shared", removing an ambiguity that had confused engineers. Third-party packages were brought inside the rules for the first time, giving us a formal way to enforce what was previously only a convention.

What this unlocks is small but consequential. APIClient depending on DateFormatting is now an ordinary Foundation → Foundation edge, not a special case requiring explanation. New shared component like CountdownTimer are simply Foundation modules. A team proposing to add Firebase as a dependency of a Feature module will get a CI failure, not just a code review comment.

Architectures rarely change in dramatic leaps. Most meaningful improvements are renamings, small relaxations, and rules written more precisely than before. We hope this one proves as durable as the last.