TCA Ideal file structure for production-level apps?

tldr: For big apps, would putting all Core files in one folder work better than separating Core files with respective view files based on their functions?

Our team is developing a huge production-level app with MVVM architecture. As we are refactoring our app to adopt TCA architecture, we have some concerns about how to reorganize our file structure.

This is our current structure:

App
├── Model
│ ├── User.swift
│ └── ...
├── Main
│ ├── MainView.swift
│ └── MainViewModel.swift
├── SignIn
│ ├── SignInView.swift
│ └── SignInViewModel.swift
├── ...
│ └── ...
.
.
.

Rather than putting viewModels in one file and putting views in the other, we have created folders based on views and put viewModels in related view folder. This structure is intuitive as files are separated based on their functions.

However, we noticed all sample apps with TCA provided by Point-free (like Tic-Tac-Toe example but much simpler than our app) have file structures that put core files and client files all together in one folder and view files in the other. If we use this structure, our structure will look like this:

App
├── Model
│ ├── User.swift
│ └── ...
├── Views
│ ├── MainView.swift
│ └── SignInView.swift
│ └──...(other views)
├── Core
│ ├── MainViewModel.swift
│ └── SignInViewModel.swift
│ └──...(other viewModels)
├── ...
│ └── ...

This structure works fine with the scale of Tic-Tac-Toe, but our app has dozens of views and viewModels. For instance, if we want to edit SignIn, we have to open up both the Core file and the view file with TCA structure, but we only need to open SignIn file with our previous structure.

Do you think enforcing TCA file organization system (putting Cores all together with views) works fine with big production level apps? ViewModels are basically Cores so we are curious which file structure would work with our app.

1 Like

Just wanted to leave my five cents here.

We're also building an application with TCA and did consider how to organize our folder/file structure. In the end, we decided to go down a similar path as the one you're proposing. However, we split up the "ViewModel" file into a ViewState, ViewAction, ViewEnvironment and Reducer based on the Modularity section on PointFree.

App
⎣__Module
   ⎣__Main
   .  ⎣__MainView
   .  ⎣__MainViewState
   .  ⎣__MainViewAction
   .  ⎣__MainViewEnvironment
   .  ⎣__MainReducer
   ⎣__SignIn
   .  ⎣__SignInView
   .  ⎣__SignInViewState
   .  ⎣__SignInViewAction
   .  ⎣__SignInViewEnvironment
   .  ⎣__SignInReducer

I think keeping these components together in one folder makes it easier to understand which parts are pieced together and would recommend such a folder structure over the one chosen for the small case studies. Such a folder structure is also common practice for UIKit application and has proven to scale for bigger and even modularized applications, so I see no clear reason why not to adopt it. :slightly_smiling_face:

4 Likes

Thanks for your response! Your architecture totally makes sense. One question here, if you split up the viewModel into state, action, environment, and reducer, have you ever had problems of managing too many files? I think this is something that is based on personal/team preference, but having 4 files for 1 view (5 including the view itself) seems a lot considering there can be dozens of views in a big app.

As you mentioned, folder and file structure comes down to personal preference. As these files stay rather small, one could also split it into two files (View / ViewState), but I wouldn't be sure how to name it. ViewModel is rather fitting, as it basically consists of ViewState, ViewAction, ViewEnvironment and Reducer.

1 Like

This is the structure I'm using in the app I'm currently working on :

  • Main
    • App (This is the main target that links the modules below)
      • Sources
        • SceneDelegate.swift
        • Core
          • AppAction.swift
          • AppEnvironment.swift
          • AppReducer.swift
          • AppState .swift
      • Resources (Assets catalog, Info.plist etc.)
    • Modules (Each feature lives in its own framework and has the same structure)
      • SomeFeature (Repeat Sources/Resources/Tests for each framework even for the supporting frameworks below)
        • Sources
          • Core
            • SomeFeatureAction.swift
            • SomeFeatureEnvironment.swift
            • SomeFeatureReducer.swift
            • SomeFeatureState.swift
          • Views
        • Resources
        • Tests
  • Support
    • Common (Shared code between main modules)
    • Design (Reusable UI Components)
    • Localization (Translations)

The key is to be consistent with each framework so it's easy to navigate them since you can end up with lots of them.

3 Likes

I’ve always organised my code according to what it is rather than how it’s used but that’s starting to seem rather unintuitive to me.

I now think that feature-related code should live together and library code that might be shared across features should live separate to that (potentially in a separate target or package).

I would also separate out common view components, including any design system code you might have, any common model code (generally value types) and utilities although I try and keep these to a minimum and ideally close to the code that uses it.

Feature-related code in a TCA app would be any views, controllers if not using SwiftUI. feature-specific value types, any view-specific state, actions and a reducer. Depending on the size of the feature, I find a single Feature/Core.swift containing the state, actions and reducer works well but you may want to separate these out further.

Because TCA allows you to break up features in such a logical way it makes sense to organise the code in the same way. It’s a starting point for potentially breaking features out into separate targets or making the features platform agnostic so they can be reused across different platform targets.

I also think this makes code easier to reckon with when you come back to it some time later. If you need to work on the “foo” feature you’ll know exactly where to find all the code specific to that feature.

3 Likes

You might like to watch The life of a file. It's about Elm and The Elm Architecture (TEA), not Swift, but TEA was a major inspiration for TCA.

2 Likes

I agree with @ohitsdaniel and @Dabou. If you want to deep dive a bit more regarding how to structure your code look at what the web community has been doing for quite some time. Specifically Features/Modules in Angular and React.

Yes you will notice that a view will have 3... to 5 files. But that is sort of community best practice. Basically no one says: You can not have everything in one file. You can have everything in 3 or 2 or 1 files (its really up to you), but then go try finding the actions and reducers and effects :) The separation helps with readability and separation of concerns.

Moreover, after you and your team have done couple of Feature modules (Signin, Home, User ) and then wire everything in the App Module it will become ingrained in how you structure complex apps.

Again as Daniel pointed out: its a personal preference so if you feel that you wanna have 3 files under Dashboard folder; for ex. DashboardView / DashboardViewModel / DashboardTCA (containing all the rest) then go for it and test it out.

Best of luck

1 Like

I have not finally decided yet as my first full TCA-based project is still work in progress, but this is my current structure that I'm happy with so far:

Here's my rationale:

  1. My App/Sources contains all the Swift code, all resources go to App/Resources.
  2. Top level within App/Sources I have the app entry point <AppName>App.swift
  3. All screens go to App/Sources/Composables, all shared stuff goes to App/Sources/Globals including reusable views for example
  4. Except for the entry point view (AppView) every screen gets its own folder & 3 files
  5. Why 3 files per screen? I have 4 reasons for that, it all developed over time via trial & error:
    1. I neither like too long files (too much scrolling), nor too many files (too many file switches). Splitting 5 types to 3 files seems like a good balance.
    2. The View typically is the longest type for me, so I gave it its own file. The Reducer is 2nd longest and the Env file is shortest (yes I shorten Environment to Env as that's a common abbreviation, like ID), so combining them leads to a not too long file. State and Action are in the middle for me til now so length-wise it's fine to put them in one file.
    3. As the View file only contains the view, I suffix it with View. The State and Action type in TCA already build the Store<State, Action> type together, so it feels right to me to suffix the file with Store. The Env type to me feels like just a wrapper (at the moment), not really it's "own" type, I'm not really sure where to put it to be honest, I had put it to Store at first. But because it's contents (scheduler, API client) are mostly used in the Reducer via effects I currently keep it there.
    4. I'm not sure why, but these 3 files – I like to think of it as the "RSV" system – appears familiar to me. I think because it reminds me of the good old "MVC" structure: "V" obviously stands for "View" in both. "S" for "Store" kinda feels like the "Model" to me (I mean, you literally just have to pass that to the view!) and lastly in "R" for "Reducer" all logic & decisions are made, so it obviously feels like the "Controller" to me. This familiarity I think will serve well for onboarding new developers to the project, or at least that's my hope.

Please note that I have also created a Swift script in my project which I run in order to automatically generate the folder & all 3 files in my App/Sources/Composables folder to save some typing. Here it is, in case someone finds it useful:

#!/usr/local/bin/swift-sh

import Foundation
import Files // @JohnSundell ~> 4.2
import ShellOut // @JohnSundell ~> 2.3
import HandySwift // @Flinesoft ~> 3.3

// MARK: - Input Handling

let usageInstructons: String = """

  Run like this: ./generate.swift <kind> <name>
  Replace <kind> with one of: composable
  Replace <name> with the name to use for your generated file(s).

  For example: ./generate.swift composable Login
  """

guard CommandLine.arguments.count == 3 else {
  print("ERROR: Wrong number of arguments. Expected 2, got \(CommandLine.arguments.count - 1).")
  print(usageInstructons)
  exit(EXIT_FAILURE)
}

enum Kind: String, CaseIterable {
  case composable
}

guard let kind = Kind(rawValue: CommandLine.arguments[1]) else {
  print("ERROR: Unknown kind '\(CommandLine.arguments[1])'. Use one of: \(Kind.allCases)")
  print(usageInstructons)
  exit(EXIT_FAILURE)
}

let name = CommandLine.arguments[2]

// MARK: - Defining File Contents

func storeFileContents(name: String) -> String {
  """
  import Foundation

  struct \(name)State: Equatable {
    #warning("TODO: not yet implemented")
  }

  enum \(name)Action: Equatable {
    #warning("TODO: not yet implemented")
    case exampleButtonClicked
  }

  """
}

func viewFileContents(name: String) -> String {
  """
  import ComposableArchitecture
  import SwiftUI

  struct \(name)View: View {
    let store: Store<\(name)State, \(name)Action>

    var body: some View {
      WithViewStore(store) { viewStore in
        #warning("TODO: not yet implemented")
        Button("\(name)") {
          viewStore.send(.exampleButtonClicked)
        }
      }
      .padding()
    }
  }

  #if DEBUG
    struct \(name)View_Previews: PreviewProvider {
      static let store = Store(
        initialState: \(name)State(),
        reducer: \(name.firstLowercased)Reducer,
        environment: \(name)Env()
      )

      static var previews: some View {
        \(name)View(store: store).previewScreens()
      }
    }
  #endif

  """
}

func reducerFileContents(name: String) -> String {
  """
  import ComposableArchitecture
  import Foundation

  struct \(name)Env {
    #warning("TODO: not yet implemented")
  }

  let \(name.firstLowercased)Reducer = Reducer<\(name)State, \(name)Action, \(name)Env>() { state, action, env in
    #warning("TODO: not yet implemented")
    switch action {
    case .exampleButtonClicked:
      print("example button was clicked in \(name)View")
      return .none
    }
  }

  """
}

// MARK: - Generating Code Files

switch kind {
case .composable:
  let sourcesFolder = try Folder(path: "App/Sources/Composables")
  guard !sourcesFolder.containsSubfolder(named: name) else {
    print("ERROR: There's already a folder named '\(name)' in App/Sources, please delete it first and retry.")
    exit(EXIT_FAILURE)
  }

  let folder = try sourcesFolder.createSubfolder(at: name)

  let storeFile = try folder.createFile(named: "\(name)Store.swift")
  try storeFile.write(storeFileContents(name: name))

  let viewFile = try folder.createFile(named: "\(name)View.swift")
  try viewFile.write(viewFileContents(name: name))

  let reducerFile = try folder.createFile(named: "\(name)Reducer.swift")
  try reducerFile.write(reducerFileContents(name: name))

  print("Successfully generated files. Drag & drop folder to Xcode to finish.")
  try shellOut(to: "open", arguments: [folder.path, "--reveal"])
}

If you want to use it, save it to a generate.swift file in the root of your project, run chmod +x ./generate.swift to make it executable, install swift-sh (brew install swift-sh), then run it e.g. via ./generate.swift composable Onboarding which will create an Onboarding folder with the 3 files. In case you actually do, you might want to also copy the previewScreens() modifier code to your project from here (or include HandySwiftUI, which is currently also a work in progress though) or just delete it from the script, also you may want to adjust the App/Sources/Composables path in the script.

What do you think of this approach? Any feedback is welcome! :slight_smile:

2 Likes