SwiftData Modularization with TCA in Tuist

Lee young-jun
8 min readMay 20, 2024

--

I had never separated a project into sub modules. Only once I created a player module as Private CocoaPods. You know nowadays many Pods are being deprecated and we should use SPM.

In this time, I challenged separating the database module to sub project in Tuist.

Let’s see current directory

Sources directory has Providers, Containers, Dependencies …

Core - Model

TCA calls modules feature. Each feature will use models shared between them. So I will modularize shared model first.

I would move main shared codes into Core directory

I created new Tuist project, but I saw two Project.swift. One is for Core project, another is for the root project.

And I inserted suffix Core to all project name and bundle id.

Then I generated Core Project.

To see Core Project in App Project, I modified Project.swift of app.

let project = Project(
packages: [
...,
.package(path: .relativeToCurrentFile("Modules/Core"))
],
targets: [
.target(
...,
dependencies: [
...,
.package(product: "...Core", type: .runtime)
],

And I can saw the package after regenerating Tuist.

However I couldn’t import it.

Workspace

Because we cannot import projects between them without workspace. So I had to configure the workspace. We can see example for workspace in the repository.

First, create workspace in root path of project

and move all project files excepting Workspace into Projects/App directory.

Also rename the project to simplify, removing the project name.

Run generate, so we can now App and Core projects under Projects .

Move sources to contain into Core module.

Now we can import Core framework. But we cannot use types within it.

All types are typically not opened to another projects, so we should publish them.

@Model
public final class QBCharacter {

Of course nested types also should be published.

public var elementalType: ElementalType

Even though I published all types. My app still couldn’t use them. Why?
The reason is the app didn’t embed the framework.

So we should alter the Workspace manifest again.

Add Core project dependency into App Project’s manifest.

dependencies: [
...,
.project(target: "....Core",
path: .relativeToManifest("../Core"))
],

Now we can see the Core framework in my App project!

Building has been also succeeded.

tuist build

tuist graph

Repository - Database

My app utilized local database using DatabaseProvider. So this time I will move it to Repository project.

First I createdLocal Project under Repositories.

tuist init — platform ios -n Local

And I moved database sources into the project.

Lastly App embeded the repository project.

dependencies: [
...,
.project(target: "....LocalRepository",
path: .relativeToManifest("../Repositories/Local"))
],

Now the name is Provider, so I renamed to DatabaseRepositoryProvider and created protocol.

public protocol DatabaseRepository{
func fetchCharacter(where keyword: String) throws -> [QBCharacter]?
func insert<Model>(_ model: Model) where Model : PersistentModel
}

extension DatabaseRepositoryProvider : DatabaseRepository

And I replaced the database dependency type with the protocol.

private enum DatabaseDependencyKey: DependencyKey {
@MainActor
static var liveValue: DatabaseRepository
= DatabaseRepositoryProvider.init(...)

Store - search

TCA introduced Feature as each module name. However I named the store because the type name StoreOf.

@Bindable var store: StoreOf<CharacterSearchStore>

If the feature should have view and store together, the name will have suffix feature.

Modularizing the store is more complex than above modules. It will use DependencyKey and other stores.

ModelContainer

The DependencyKey uses ModelContainer, so we have to make an interface for it.

private enum DatabaseDependencyKey: DependencyKey {
@MainActor
static var liveValue: DatabaseRepository
= DatabaseRepositoryProvider.init(container: ModelContainer.generate())

So We will first make Shared project and move ModelContainer.generate into the Sources.

DependencyKey

To use database from Store, the dependency key also should be in Shared project. But Xcode cannot compile Shared project without importing TCA.

let project = Project(
name: "Shared",
packages: [.remote(
url: "https://github.com/pointfreeco/swift-composable-architecture",
requirement: .upToNextMinor(from: "1.9.2"))],
targets: [
.target(
...,
dependencies: [.project(target: "....Core",
path: .relativeToManifest("../Core")),
.package(product: "ComposableArchitecture", type: .runtime)]

Now we can build Shared project. However the compiler couldn’t find DependencyKey during the App project compile.

I googled and checked others repositories and found a way. It is to contain only the DependencyValue and TestDependencyKey in sub project like this.

extension DatabaseDependencyKey: TestDependencyKey {
@MainActor
public static var testValue: DatabaseRepository = ...
}

public extension DependencyValues {
var database: DatabaseRepository {

The Xcode also complained to me with same errors related to TestDependencyKey.

I don’t know why, but I could solve the problem by switching the framework type to static 😀. (Please tell me better solution)

targets: [
.target(
name: "....Shared",
destinations: .iOS,
product: .staticFramework,

I examined an assumption if ThirdParty project contains TCA and it is staticFramework, Shared project can use TCA by embedding ThirdParty.

However my examination has been failed. Shared project have to be static.

AddStore

Now we can create SearchStore project, but it has AddStore as sub store.

@Reducer
struct CharacterSearchStore {
@ObservableState
struct State: Equatable {
...
@Presents var addCharacterState: AddCharacterStore.State?
}

So I created AddStore as static framework and made AddCharacterStore and it’s nested State and Action also. Additionally I created initializer to publish it. Maybe you can use third party macro like @MemberwiseInit(.public) 😎

@Reducer
public struct AddCharacterStore {
@ObservableState
public struct State: Equatable {
public var ...

public init(...) {
...
}
}

public enum Action {
case ...
}

public init(){}

SearchStore

Finally we can create our destination project of this article.

import ComposableArchitecture
import ...HelperCore
import ...HelperLocalRepository
import ...HelperShared
import AddCharacterStore

@Reducer
public struct CharacterSearchStore {
@ObservableState
public struct State: Equatable {
...
@Presents
public var addDateState: AddDateStore.State?

public init(...) {
...
}
}

public enum Action {
...
}

@Dependency(\.database) var database

public init() {

}

Simplify

Dependencies

As you can see, there are duplications of dependency. If the module imports Shared project, it doesn’t have to import Core. Store projects import Shared project, so we don’t need even it.

Now the App project depends on only Stores.

dependencies: [
.package(product: "LSExtensions", type: .runtime),
.package(product: "RoundedBorder", type: .runtime),
.project(target: "AddCharacterStore",
path: .relativeToManifest("../Stores/AddCharacter")),
.project(target: "SearchCharacterStore",
path: .relativeToManifest("../Stores/SearchCharacters"))
],

Project Description Helper

Now the Tuist manifests have many projects and it make them more complex. However as you can see, manifests are created by Swift, therefore we can make our own shortcuts for the project like this.

extension TargetDependency {
class Stores {
static let addCharacter : TargetDependency
= .project(target: "AddCharacterStore",
path: .relativeToRoot("Projects/Stores/AddCharacter"))
}
}

How can we share this extension across the projects? Tuist offers Project description helper. To share our extension, we should just move into Tuist/ProjectDescriptionHelpers directory and import ProjectDescriptionHelpers

There is not Tuist directory. Don’t worry. Just creat it.

Even though we created the extension file, we cannot import ProjectDesriptionHelpers. Rerun tuist edit, so you can see the framework in your Xcode.

After importing the framework, your Xcode will emit error. Because our extension is now embed in the framework, so we should open the classes to use in our project manifest.

public extension TargetDependency {
class Stores {
public static let addCharacter : TargetDependency
= .project(target: "AddCharacterStore",
path: .relativeToRoot("Projects/Stores/AddCharacter"))
}
}

Without rerunning tuist edit, we cannot solve the error. Now my code looks better.

Tuist provides also Template, I won’t talk about it here. There are already many articles to make good project structures. I focused only how to separate Store to sub project in Tuist.

If you found any help in this post, please give it a round of applause 👏. Explore more iOS-related content in my other posts.

You can give me claps up to 50 if you keep pressing the button :)

For additional insights and updates, check out my LinkedIn profile. Thank you for your support!

Troubleshootings

Cannot find type … in scope

attach public to type definition.

Property ‘id’ must be declared public because it matches a requirement in public protocol ‘Identifiable’

public var id

Property cannot be declared public because its type uses an internal type

Make nested type public.

Couldn’t find target ‘

Fit target of project dependency to project’s target name.

Property ‘…’ must be declared public because it matches a requirement in public protocol ‘Reducer’

Make State, Action, Body

initializer is inaccessible due to ‘internal’ protection level

public init(){ }

References

--

--

No responses yet