nanda mochammad
Apple Developer Concepts

Structuring a new SwiftUI project so month six does not hurt

12 min read

Hi. Starting a new SwiftUI project? This article shows how to lay out the app from day one so you can still find your way around it six months in.

By the end of this article, you will have a small two-tab app called LedgerApp that builds and runs. You will know where the entry point goes, how to group files by feature, where state should live, how to name things, and when Swift Packages are worth it.

When you create a SwiftUI app, Xcode hands you a ContentView.swift and says nothing about what comes next. The file layout you pick in the first hour is the one you will live with for the next year. I have led a team through untangling an app that grew with no structure at all. That day-one drift costs weeks later, and it is avoidable.

The app is shaped to grow, but light enough that it does not slow you down on day one. Follow the steps in order. Each step ends with a check, so you know it worked before moving on.

Step 1

Create the project

Open Xcode and choose File → New → Project → iOS → App, then set:

  • Product Name: LedgerApp
  • Interface: SwiftUI
  • Language: Swift
  • Storage: None (leave Core Data off; we add persistence on purpose, later)

Pick a folder and create it. Then set the deployment target so the modern APIs are available. Select the project in the navigator → the LedgerApp target → General → Minimum Deployments → set iOS to 26.0.

Check. Press ⌘B. The untouched template should build with no errors. That is your known-good starting line.

Step 2

The entry point: @main, one root view, app-wide state

Every SwiftUI app starts at a single type marked @main that conforms to App. Xcode generated one named LedgerApp.swift (matching your product name). Open it and replace its contents with the code below. Keep it small. Its only jobs are to declare your scenes and set up the things the whole app shares.

// App/LedgerApp.swift
import SwiftUI

@main
struct LedgerApp: App {
    @State private var session = SessionModel()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(session)
        }
    }
}

RootView will own top-level navigation. The App type owns app-level state. Do not put logic into the App struct. We create RootView and SessionModel in the next two steps. Until then the project will not compile, which is expected.

Step 3

Create the folders, and the fork that decides everything

Most of us start with buckets called Views/, ViewModels/, Models/. That is layer-based: it groups files by their technical type. It looks tidy with five files. With fifty, one feature is spread across every folder. Touching “Profile” means hopping between three directories.

Diagram: layer-based grouping scatters one feature across Views, ViewModels, and Models folders, while feature-based grouping keeps a feature's view and model together in one folder. Layer-based group by technical kind Feature-based group by feature Views/ Home Profile Settings ViewModels/ Home Profile Settings Models/ User Profile Settings Editing Profile → 3 folders Home/ HomeView HomeModel Profile/ ProfileView ProfileModel Settings/ SettingsView SettingsModel Editing Profile → 1 folder
Layer-based grouping scatters one feature across folders by type; feature-based grouping keeps a feature's view and model side by side. As the app grows, the right-hand shape is the one that stays navigable.

Feature-based grouping flips it. Each feature is a folder that holds its own view, model, and helpers. Everything you need to understand “Transfer” is in Transfer/. It keeps change in one place and makes ownership clear. It is also the exact shape you will want if you ever move a feature into its own Swift Package.

In the navigator, create this layout. To make a top-level folder, right-click the LedgerApp group → New Group and name it (e.g. Features). For a nested folder, right-click the parent group instead: right-click Features → New Group → name it Home. To add a Swift file inside a group, right-click that group → New File from Template… → Swift File, and name it exactly as shown in the tree (for HomeView.swift, type HomeView). This is the starter tree:

LedgerApp/
├── App/
│   ├── LedgerApp.swift        // @main entry, app-level wiring
│   └── RootView.swift         // top-level TabView / navigation
├── Features/
│   ├── Home/
│   │   ├── HomeView.swift
│   │   └── HomeModel.swift
│   └── Transfer/
│       ├── TransferView.swift
│       └── TransferModel.swift
├── Models/                    // shared domain types
│   └── Account.swift
├── Services/                  // networking, persistence, auth
│   └── AccountService.swift
├── Components/                // reusable views: buttons, cards
│   └── BalanceCard.swift
└── Resources/
    ├── Assets.xcassets
    └── Localizable.xcstrings

Five top-level folders cover almost any app: App (entry + root), Features (the screens), Models (domain types shared across features), Services (the outside world: APIs, databases), and Components (reusable UI).

Now add just enough code to make it run. First the root view, which owns navigation:

// App/RootView.swift
import SwiftUI

struct RootView: View {
    var body: some View {
        TabView {
            HomeView()
                .tabItem { Label("Home", systemImage: "house") }
            TransferView()
                .tabItem { Label("Transfer", systemImage: "arrow.left.arrow.right") }
        }
    }
}

Then two placeholder screens, one per feature folder. Keep them small for now; the structure is the point:

// Features/Home/HomeView.swift
import SwiftUI

struct HomeView: View {
    var body: some View {
        ZStack(alignment: .bottomTrailing) {
            Text("Home")

            // A custom floating control is where you DO call the
            // Liquid Glass API — the glass button style here.
            Button {
                // start a new transfer
            } label: {
                Label("New transfer", systemImage: "plus")
            }
            .buttonStyle(.glassProminent)
            .padding()
        }
    }
}
// Features/Transfer/TransferView.swift
import SwiftUI

struct TransferView: View {
    var body: some View {
        Text("Transfer")
    }
}

You can leave HomeModel.swift, TransferModel.swift, Account.swift, and the rest as empty files for now. Then delete the generated ContentView.swift: right-click it in the navigator → Delete → Move to Trash. RootView is the root now. The remaining piece, SessionModel, comes in Step 4.

Check. Press ⌘B. The project will not run yet, and that is correct. SessionModel does not exist until Step 4. Xcode should report exactly one error: “Cannot find ‘SessionModel’ in scope” in LedgerApp.swift. If that is the only error, you are on track. Finish Step 4 and it clears. Any other error means something in this step is off, so fix it before moving on.

Step 4

Where state lives: own data low, share it deliberately

SwiftUI’s data flow is simpler than newcomers expect. A class marked @Observable is your source of truth. Views observe it. Views send changes back by calling its methods.

Diagram: an @Observable model is the single source of truth; views observe its state and send actions back through mutating methods, with the model shared across views via @Environment. @Observable AppModel single source of truth Views Home · Settings · … state: observed actions: mutating methods one model, shared across views via @Environment
One @Observable model is the single source of truth. Views read its published state and send intent back through its methods; @Environment hands that one model to every view that needs it without prop-drilling.

Create the shared model your @main already referenced:

// Models/SessionModel.swift
import Foundation

@Observable
final class SessionModel {
    var user: User?
    func signOut() { user = nil }
}

struct User: Identifiable {
    let id: UUID
    var name: String
}

The question for any piece of state is how widely is it needed? Local UI state stays in the view. Shared domain state moves up to an @Observable model and is injected once. The two snippets below show that rule (they show where each kind of state lives). They are not files to create here, because there is no Profile feature in LedgerApp:

// Local, ephemeral UI state → @State, owned by the view
struct TransferView: View {
    @State private var isConfirming = false
    // …
}

// A deep child reads the shared model without it being passed down by hand
struct ProfileView: View {
    @Environment(SessionModel.self) private var session
    // …
}

Step 2 injected the model with .environment(session) at the root. So any child view can read it with @Environment(SessionModel.self). No prop-drilling.

Check. Press ⌘R again. The app should now build cleanly and run exactly as before (two tabs). The difference is in the structure. You have a single shared source of truth wired in at the root, ready for any screen to read.

Conventions that pay rent

Predictable names, one type per file

Small rules that add up over time:

  • Name by role: a view ends in View (TransferView), its model in Model (TransferModel). You should not have to open a file to know what kind of thing it is.
  • One primary type per file, named after the file. BalanceCard.swift contains BalanceCard.
  • Keep Assets.xcassets and string catalogs in Resources/ so non-code lives in one obvious place.
  • Use Preview Content/ (Xcode’s dev-only group) for sample data that powers #Preview but never ships.

None of these are clever, which is the point. Boring and predictable is what a teammate (or future you) can move through quickly.

As a feature grows, its folder gains subfolders:

Features/
└── Transfer/
    ├── TransferView.swift
    ├── TransferModel.swift
    ├── Subviews/
    │   ├── RecipientRow.swift
    │   └── AmountField.swift
    └── TransferModelTests.swift   // tests sit next to what they test
When to reach for modules

Folders first; Swift Packages when the signal arrives

You do not need Swift Packages on day one. Reaching for them early is the over-engineering this whole post is trying to save you from. Start with folders. Watch for the two signals that you have outgrown them:

  1. Build times climb because everything recompiles together.
  2. Ownership boundaries form. A team or a person clearly owns “Transfer” and wants it isolated.

When both arrive, your feature folders are already the seams to cut along. Moving Features/Transfer/ into a local Swift Package is a contained move, because you grouped by feature from the start. On the team I led, that modular split is what let us pick up delivery pace without adding people. We got there by waiting for the signal.

QuestionLayer-basedFeature-based
Find everything for one featureHop across foldersOne folder
Scales to a large appDegradesHolds up
Cost to extract a moduleHigh: scattered filesLow: folder is the seam
Good forTiny apps, demosAnything you’ll maintain

Where does this new file go?

A 10-second placement test

Used by one feature onlybelongs to it→ Features/<Name>/
Reusable UI across featuresshared view→ Components/
Talks to APIs / storageoutside world→ Services/
Core domain typeshared everywhere→ Models/

When a file could go two places, put it next to the feature that uses it. Promote to a shared folder only once a second feature needs it.

Recap

The goal was a running SwiftUI app whose structure will not fight you later. You now have one. Built against the iOS 26 SDK, the tab bar already renders as Liquid Glass with no extra work, and your one custom control opts in with .buttonStyle(.glassProminent). Adding the next screen is now simple: new folder under Features/, a View and a Model, drop it into RootView.

The main points:

  • Keep the @main entry thin. It declares scenes and shares app-level state, nothing more.
  • RootView owns navigation.
  • Group files by feature, not by layer. Each feature folder holds its own view and model.
  • Keep state as low as it will go. Lift it to one shared @Observable model, injected at the root, only when two views need it.
  • Start with folders. Move to Swift Packages only when build times climb and ownership boundaries form.

Group by feature, keep state low, and move to packages only when the app asks for it.

I hope this writing helps you. Thanks for reading, and share it with someone who is starting a new SwiftUI project.

Cited sources