nanda mochammad
Apple Developer Concepts

Structuring a new SwiftUI project so month six does not hurt

11 min read

When you create a SwiftUI app, Xcode hands you a ContentView.swift and a polite silence about everything after it. That blank canvas is a quiet trap: the file layout you fall into in the first hour is the one you’ll fight for the next year. I’ve led a team through untangling a monolith that grew with no structure at all, and the cost of that day-one drift is real. It’s also avoidable.

This is a walkthrough, not a lecture. By the end you’ll have a small two-tab app called LedgerApp that builds, runs, and is already shaped to scale: opinionated enough to grow, light enough that it doesn’t slow down day one. Follow the steps in order; each 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 deliberately, 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 → GeneralMinimum Deployments → set iOS to 26.0.

Check. Press ⌘B. The untouched template should build with no errors. That’s 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 this, and keep it small, because its only jobs are to declare your scenes and wire up the dependencies 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. Resist the urge to stuff logic into the App struct; it should read like a table of contents, not a chapter. We create RootView and SessionModel in the next two steps; until then the project won’t compile, which is expected.

Step 3

Create the folders, and the fork that decides everything

Here is the decision that determines how the project ages. The instinct most of us start with, buckets called Views/, ViewModels/, Models/, is layer-based: it groups files by their technical type. It looks tidy with five files. With fifty, a single feature is smeared across every folder, and 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 contains its own view, model, and helpers. Everything you need to understand “Transfer” is in Transfer/. It localises change, makes ownership obvious, and, not incidentally, is the exact shape you’ll want if you ever extract 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 FeaturesNew 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 carry 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 minimal for now; the point is the structure, not the UI:

// 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, since RootView is the root now. The remaining SessionModel arrives in Step 4.

Check. Press ⌘B. The project will not run yet, and that’s correct: SessionModel doesn’t exist until Step 4. Xcode should report exactly one error: “Cannot find ‘SessionModel’ in scope” in LedgerApp.swift. If that’s the only error, you’re 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 the part newcomers wire up by guesswork. The model is actually simple: a class marked @Observable is your source of truth, views observe it, and 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 decision for any piece of state is how widely is it needed? Local UI state stays in the view; shared domain state is lifted to an @Observable model and injected once. The two snippets below illustrate that rule (they show where each kind of state lives) and are not files to create here (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
    // …
}

Because Step 2 injected the model with .environment(session) at the root, any descendant 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 structural: 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, compounding returns:

  • 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. That’s the point: boring and predictable is what a teammate (or future you) can navigate at speed.

As a feature grows, the tree grows outward, not messier; its folder simply 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 that this whole post is trying to save you from. Start with folders. Watch for the two signals that you’ve 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. Promoting Features/Transfer/ to a local Swift Package is a contained move precisely 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 headcount, but we earned it by waiting for the signal, not by guessing on day one.

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.

What you built, and the sticky-note version

You now have a running SwiftUI app whose structure won’t fight you later: a thin @main entry, a RootView that owns navigation, features grouped in their own folders, and one shared @Observable model injected at the root. 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 mechanical: new folder under Features/, a View and a Model, drop it into RootView.

The whole philosophy fits on a sticky note: group by feature, own state low, and let folders become packages only when the app asks. Start there and month six is a place you’ll actually want to keep building.

Cited sources