Post

Expandable modularization with Tuist Part 4 - Modularization in practice

Expandable modularization with Tuist Part 4 - Modularization in practice

Introduction

In this final part, we’ll build a project structure that is practical for real production work.

We’ll assume multiple developers are working in parallel and that the app has at least two feature modules.

As covered in the previous post, this guide builds a Tuist project using the structure below.

module-architecture

Before creating the project, let’s review principles for adding modules, the internal structure of feature modules, and common pitfalls.

Design Principles for Adding Modules

  • Choose the appropriate tier for each module (Feature, Core, Layer, and so on).

  • Decide whether to split interface and implementation.

  • Choose framework/library type based on usage (dynamic vs static).

Notes

  • Design patterns such as MVVM and MVP are related to Features.

  • Remember that modularization operates at a larger scale than presentation-layer patterns.

Feature module

Feature modules are typically split into a UI-rendering area and a data-handling area.
To prevent circular dependencies and support sample apps and tests, the resulting structure is slightly more complex.

feature-module

Interface

  • Provides externally accessible interfaces and models for the feature’s UI entry points.

UI

  • Holds the UI portion of the feature. (Goal: keep UI as dumb as possible.)

Presentation

  • Handles UI actions and connects to the domain layer. (In MVVM, this is where Model and ViewModel live.)

Testing

  • Provides mock data.

  • Provides code for use in the Example app.

Tests

  • Contains unit tests and UI tests.

Example

  • A lightweight app used to quickly validate feature behavior.

Cautions

Circular references between modules

For example, assume there is navigation from Settings to Profile, and also from Profile back to Settings.
If implemented directly, the Settings module depends on Profile while Profile also depends on Settings.
This is an inter-module circular dependency.

feature-module-2

When circular references occur, clear boundaries between components disappear and chain changes may occur.

Uncle Bob emphasizes that dependencies between components should be acyclic (ADP: Acyclic Dependencies Principle). We continuously check our dependency graph for loops and break them when they appear.

Solution for circular references between modules

Case 1) Resolve by creating a route module

  • Problem → Every feature module must depend on the route module, which can make that module too central and heavy.

Case 2) Break dependencies through interfaces (DIP: Dependency Inversion Principle)

  • Separate modules into interface modules and implementation modules.

  • Each implementation module resolves circular references by referring to the interface module.

feature-module-3

Create a Real Tuist Project

Now that we’ve covered the caveats, let’s move on to building an actual Tuist project.

Starting from the basic structure, which was created last time, we aim to create the advanced structure.

Workspace.swift

workspace_swift

  • Create a workspace.

    • Register projects grouped as App, Features, Layers, Cores, ThirdParties, and Utils. (You can think of the image above as one project map.)

App

app_swift

  • The app setup is mostly the same as in the previous post.

    • It depends on the top-level project of each layer.

Features

features_swift_01

Features module

Each feature has an Interface module and UI module.
This is where we configure dependency injection.

features_swift_02 features_swift_03

Individual Feature

  • Create a project using a predefined Project extension.

  • Creates Interface, UI, Presentation, Testing, and Tests modules. (Example is added when needed.)

  • Interface

    • Uses a dynamic framework form without extra dependencies.
  • UI

    • Receives required dependencies and includes NeedleFoundation, interfaces, and presentation dependencies.
  • Presentation

    • Receives required dependencies, including domain modules for data retrieval.

Layers

layers_swift_01 layers_swift_02 layers_swift_03

  • The current layer structure has Domain, Data, and Remote structures.

    • Layer-level DI is configured across the entire app.
  • The ownership relationship is Domain ← Data ← Remote.

    • Domain is a dynamic framework

    • Data and Remote are static libraries.

Cores

cores_swift_01 cores_swift_02 cores_swift_03

  • In Core, modules do not depend on each other.

    • Each module is consumed directly where needed.

    • In this structure they are dynamic frameworks, but static libraries are also valid when needed.

ThirdParties

third_parties_swift_01 third_parties_swift_02 third_parties_swift_03

  • ThirdParty modules are created when interface-based wrapping is needed.

    • Wrapped third-party libraries are not called directly elsewhere; only internal implementation modules reference them.

    • Access these modules through interfaces.

Utils

utils_swift_01 utils_swift_02 utils_swift_03

  • Like Core, Util modules do not depend on each other.

    • Each module is consumed directly where needed.

Helper extensions

helper_extensions

  • As the number of modules grows, hard-coded module names/strings become more error-prone.

  • Helper extensions reduce those manual errors.

Write DI and basic code inside the app

We previously checked how to create a project through tuist edit.

Now let’s implement DI and verify that DIP works in practice.

DI Authoring with NeedleFoundation

  • We use Needle as the DI framework.

app

  • Feature-level DI is configured in Features.

  • Layer-level DI is configured across the app.

  • Third-party DI is configured through App or ThirdParties, depending on context.

A closer look at the Feature module

feature_detail_01 feature_detail_02

Define the protocol for creating the home screen in Interface.
Implement that interface in the UI module.

feature_detail_03

In each screen, Builder and ViewModel are injected to handle navigation.

feature_detail_04

The ViewModel declares required UseCase dependencies and receives them via injection.


Conclusion

From Part 1 to Part 4, we covered a practical end-to-end approach to iOS modularization.
We walked through why modularization matters, how to structure modules, and how concepts like DIP, DI, and Clean Architecture fit together.

Next, our iOS team will dig deeper into Swift Concurrency.
We’ll continue with topics such as Actor and Sendable, focusing on practical usage in production code.

This concludes the modularization series, and we’ll return with the next topic soon.


References

This post is licensed under CC BY 4.0 by the author.