Structuring a new SwiftUI project so month six does not hurt
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.
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 → General → Minimum Deployments → set iOS to 26.0.
Check. Press ⌘B. The untouched template should build with no errors. That’s your known-good starting line.
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.
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.
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 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 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.
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.
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.
Predictable names, one type per file
Small rules, compounding returns:
- Name by role: a view ends in
View(TransferView), its model inModel(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.swiftcontainsBalanceCard. - Keep
Assets.xcassetsand string catalogs inResources/so non-code lives in one obvious place. - Use
Preview Content/(Xcode’s dev-only group) for sample data that powers#Previewbut 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
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:
- Build times climb because everything recompiles together.
- 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.
| Question | Layer-based | Feature-based |
|---|---|---|
| Find everything for one feature | Hop across folders | One folder |
| Scales to a large app | Degrades | Holds up |
| Cost to extract a module | High: scattered files | Low: folder is the seam |
| Good for | Tiny apps, demos | Anything you’ll maintain |
Where does this new file go?
A 10-second placement test
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