iOS App As a Microservice. Modularize Your App With Tuist

This is the second article in a series on modular app architecture. In this post, I will cover implementation details using Tuist

blocks as a visual for modular architecture
Photo by Vlado Paunovic / Unsplash

This is the second article in a series on modular app architecture. In this post, I will cover implementation details using Tuist. It is an excellent command line tool that helps you generate, maintain and interact with Xcode projects.

πŸ’₯
I covered the core ideas of modular architecture in the previous post. Check it out if you haven't yet!
iOS App As a Microservice. Build Robust App Architecture
What will you choose: MVVM, MVC, VIPER? Those all are local and problem-specific architectures. But how to structure your app on a larger scale to make it scalable and well-organized?

What’s next?

In the next and last post in this series, I will cover implementation tips with SwiftUI. Subscribe so you don’t miss it
UPD: now available

iOS App As a Microservice. Using SwiftUI in Modular App
The modular architecture is excellent. But how to implement it effectively with SwiftUI? From its core, SwiftUI is state-driven, and it can be tricky to modularize an app and define exact responsibility borders.

Why Tuist?

It encourages you to further code modularization as it provides an elegant way to create separate Xcode projects for different modules, making tight coupling or implicit dependencies less viable

Also, it's great for teamwork. Have you tried to commit an Xcode project to a VCS like GitHub?

It's a mess

Diff of the modified Xcode project is not human-readable. It's simply impossible to trace changes or review a PR. What if you could define the Xcode project in a simple config file? Tuist does that. Moreover, tuist config files are written in Swift.

Our goal

We want to divide our project into separate Xcode projects according to the architecture I proposed in the previous article.

To reiterate, our app will consist of a combination of modules and for every module or feature, we will create a new Tuist project.

πŸ’‘
Remember that each feature should not depend on other features' implementation. Only interfaces should be public

So, for each feature, we will create several targets corresponding to the feature interface, implementation, and testing or mocking targets if required.

Defining project

πŸ’₯
Sources for this post are published on GitHub. So, before reading this article you can see how elegant describing a project could be when using Tuist
GitHub - AlexRoar/TuistExample: Using Tuist for modular app architecture
Using Tuist for modular app architecture. Contribute to AlexRoar/TuistExample development by creating an account on GitHub.

Structure

Tuist project is a simple folder with config files describing your workspace structure

Your project root
β”œβ”€β”€ Workspace.swift
β”œβ”€β”€ Tuist
β”‚   β”œβ”€β”€ Config.swift
β”‚   β”œβ”€β”€ Dependencies.swift
β”‚   └── ProjectDescriptionHelpers
β”‚       └── <tuist helpers>
└── modules
    β”œβ”€β”€ Foo
    β”‚   β”œβ”€β”€ Project.swift
    β”‚   └── <module code, folders>
    β”œβ”€β”€ Biz
    β”‚   β”œβ”€β”€ Project.swift
    β”‚   └── <module code, folders>
    └── ...

But as I said early, each module should have at least an implementation and interface target

πŸ’‘
There could be modules that contain common tools and that are not dependent on any other module. Then, it might have implementation only

So, let's modify the structure according to that

Your project root
β”œβ”€β”€ Workspace.swift
β”œβ”€β”€ Tuist
β”‚   β”œβ”€β”€ Config.swift
β”‚   β”œβ”€β”€ Dependencies.swift
β”‚   └── ProjectDescriptionHelpers
β”‚       └── <tuist helpers>
└── modules
    β”œβ”€β”€ Foo
    β”‚   └── Project.swift
    β”‚       β”œβ”€β”€ interface
    β”‚       β”‚   └── <interface files>
    β”‚       └── src
    β”‚           └── <implementation files>
    β”œβ”€β”€ Biz
    β”‚   └── Project.swift
    β”‚       β”œβ”€β”€ interface
    β”‚       β”‚   └── <interface files>
    β”‚       └── src
    β”‚           └── <implementation files>
    └── ...

Before defining modules, we need to define where Tuist should search for these modules. This can be done in Workspace.swift file

import ProjectDescription

let workspace = Workspace(
    name: "ExampleWorkspace",
    projects: [
        "modules/*"
    ]
)
Subscribe and don't miss posts!

Project file

Tuist defines the Xcode project with a simple Swift file.

// Project.swift
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project(
  name: "ProjectName",
  targets: [
  	...
  ]
)

But this post is not just a review of Tuist

Let's define a project, knowing that we need to have an interface and implementation targets. Also, let's create an enum for feature names so that we don't have to use strings and remember all namings

πŸ’‘
As config is defined in Swift, you can use the power of suggestions and auto-completion in Xcode while defining your project structure.

For example, Xcode will suggest other modules' names when using enums

With several simple helpers, we could define project structure with Swift's beauty:

import ProjectDescription
import ProjectDescriptionHelpers

let project = Project(
    name: Feature.Foo.rawValue,
    targets: [
        .feature(
            implementation: .Foo,
            dependencies: [
                .feature(interface: .Biz),
                .external(.AsyncAlgorithms)
            ]
        ),
        .feature(
            interface: .Foo,
            dependencies: [
                .feature(interface: .Biz)
            ]
        )
    ]
)

Features are going to be separate frameworks.

πŸ’‘
All swift files that help to describe tuist configs should be placed in the ProjectDescriptionHelpers folder
public extension Target {
    static func makeFramework(
        name: String,
        sources: ProjectDescription.SourceFilesList,
        dependencies: [ProjectDescription.TargetDependency] = [],
        resources: ProjectDescription.ResourceFileElements? = []
    ) -> Target {
        Target(
            name: name,
            platform: .iOS,
            product: defaultPackageType,
            bundleId: makeBundleID(with: name + ".framework"),
            sources: sources,
            resources: resources,
            dependencies: dependencies
        )
    }
}

Then, we can define what feature is

public extension Target {
    static func feature(
        interface featureName: Feature,
        dependencies: [ProjectDescription.TargetDependency] = [],
        resources: ProjectDescription.ResourceFileElements? = []
    ) -> Target {
        .makeFramework(
            name: featureName.rawValue + "Interface",
            sources: [ "interface/**" ],
            dependencies: dependencies,
            resources: resources
        )
    }
    
    static func feature(
        interface featureName: Feature,
        dependencies: [ProjectDescription.TargetDependency] = [],
        resources: ProjectDescription.ResourceFileElements? = []
    ) -> Target {
        .makeFramework(
            name: featureName.rawValue,
            sources: [ "src/**" ],
            dependencies: dependencies,
            resources: resources
        )
    }
}

Finally, we combine modules in an app target. It's defined in the same way

public extension Target {
    static func makeApp(
        name: String,
        sources: ProjectDescription.SourceFilesList,
        dependencies: [ProjectDescription.TargetDependency]
    ) -> Target {
        Target(
            name: name,
            platform: .iOS,
            product: .app,
            bundleId: makeBundleID(with: "app"),
            deploymentTarget: .iOS(targetVersion: "16.0", devices: .iphone),
            sources: sources,
            dependencies: dependencies
        )
    }
}

let project = Project(
    name: "ExampleApp",
    targets: [
        .makeApp(
            name: "ExampleApp",
            sources: [
                "src/**"
            ],
            dependencies: [
                .common,
                .feature(implementation: .Foo),
                .feature(interface: .Foo),

                .feature(implementation: .Biz),
                .feature(interface: .Biz),

                .external(.FoggyColors)
            ]
        )
    ]
)

That's it.

Now we can create different features and state dependencies between them. After that, we simply use tuist generate command and it generates Xcode workspace and Xcode projects for us.

Tuist-generated workspace
Tuist-generated workspace

Great!

Now we have our project bootstrapped, and it is fully defined in nice Swift files with a clean structure and explicit dependencies. You can add all .xcodeproj and .xcworkspace to gitignore and forget about a mess in GitHub repositories.

πŸ’₯
Some details are not covered for the brevity of this post. The full example is published on GitHub and do not hesitate to ask me about anything in the comments!
GitHub - AlexRoar/TuistExample: Using Tuist for modular app architecture
Using Tuist for modular app architecture. Contribute to AlexRoar/TuistExample development by creating an account on GitHub.

Creating an app with Tuist

I already showed how to define project structure in the examples above. Let's get even more specific and write a simple app that will show a random value in a range.

App Architecture

RandomProvider defines a protocol for generating a random number and several implementations for it

// Interface
public protocol NumberProvider {
    var number: Int { get }
}

// Implementation
public struct NumberProviderZero: NumberProvider {
    public let number = 0
    
    public init() {
        
    }
}

public struct NumberProviderRandom: NumberProvider {
    private let range: ClosedRange<Int>
    
    public var number: Int {
        Int.random(in: range)
    }
    
    public init(range: ClosedRange<Int>) {
        self.range = range
    }
}

RandomScreen defines several UI screens to display random number and re-generate it. Notice that it depends only on RandomProviderInterface and not on RandomProvider which is the implementation

public struct RandomScreenSimple: RandomScreen {
    let randomProvider: NumberProvider
    
    @State var number: Int = 0
    
    public init(randomProvider: NumberProvider) {
        self.randomProvider = randomProvider
    }
    
    public var body: some View {
        VStack {
            Text("\(number)")
            Button("generate") {
                number = randomProvider.number
            }
        }.onAppear {
            number = randomProvider.number
        }
        .animation(.default, value: number)
    }
}

Common is a module that provides common tools. Actually, it is used only by the App module, but I wanted to show that many modules can depend on it

ExampleApp is an app module that combines other modules and builds the final app

This is the only module that can depend on other modules' implementation. Moreover, it chooses which implementation to use depending on the scenario. In the example app, NumberProvider implementation is changed in runtime

0:00
/
Example app

Final notes

So, in this post, we constructed a modular app using Tuist. In the example project, I added useful tools like

  • Additions to default Info.plist
  • Template for creating a new feature that can be invoked by
    tuist scaffold framework --name ModuleName. This will create a new module folder, Project.swift file
  • Building for release mode. You can invoke generation with an environment variable and this will make all modules static. Using static frameworks improves app speed and is good for production.
    TUIST_BUILD_TYPE_RELEASE=TRUE tuist generate --no-cache

Also, If you have not read my article on a general overview of modular architecture, check it out!

iOS App As a Microservice. Build Robust App Architecture
What will you choose: MVVM, MVC, VIPER? Those all are local and problem-specific architectures. But how to structure your app on a larger scale to make it scalable and well-organized?

Do not hesitate to ask anything in the comments

References

Xcode on steroids | Tuist
Tuist is a tool that helps developers manage large Xcode projects by leveraging project generation. Moreover, it provides some tools to automate most common tasks, allowing developers to focus on building apps.

Subscribe to Alex Dremov

Don’t miss out the latest publications! Only useful posts for developers to bump up your professional skills
bestdeveloper@example.com
Jump In