Edition #13 — From Prototype to Client Delivery: Structuring Swift Projects That Don’t Break
🏗️ Stop wasting hours untangling messy projects.
The 3 AM Realization
It’s Friday night. You’re three Red Bulls deep, staring at a file called UserManager.swift that somehow ballooned to 1,200 lines. Your client demo is Monday morning, and they want one “simple” feature added: two-factor authentication.
Simple, right?
Except you can’t figure out where the login logic actually lives. Is it in AuthenticationHelper? LoginViewController? That random extension buried in NetworkUtils? Or hell, maybe all three?
This isn’t about skill. It’s about structure.
Every iOS developer has been here—scrolling through god-objects at midnight, trying to untangle spaghetti code they wrote with such confidence three months ago. The difference between junior and senior developers isn’t writing better code. It’s organizing code so you’re not crying into your keyboard at 3 AM.
Start With The End In Mind
Before you create that shiny new Xcode project, ask yourself one question:
“When I need to hand this off to another developer in six months, will they curse my name?”
Because you will hand it off. To a teammate. To a contractor. Or to future-you, who has conveniently forgotten every “clever” decision present-you is about to make.
Pick Your Battle: Architecture Patterns
You don’t need perfect architecture. You need one that matches your team’s reality—and your deadline.
MVVM (Model-View-ViewModel)
This is your starting point for 80% of iOS apps. It’s simple, it works, and your teammates already understand it (probably).
Works great when you’re building standard CRUD apps, working with SwiftUI, or have a small team where everyone touches everything.
Falls apart when ViewModels become “everything that isn’t a View"—suddenly you’ve got UserProfileViewModel with 15 methods doing networking, validation, formatting, navigation, and somehow managing the coffee machine.
Clean Architecture (VIP/VIPER)
Powerful. Structured. Also overkill for most projects.
Works great when you have multiple developers who need to work in parallel, complex business logic that needs isolation, or extreme testability requirements.
Falls apart when you’re creating five files just to display a settings toggle. If your team is groaning every time they add a simple feature, you’ve over-engineered it.
The Composable Architecture (TCA)
Powerful, opinionated, and has a devoted following.
Works great when you need predictable state management, love functional programming, and have time to climb the learning curve.
Falls apart when your team just wants to ship features and doesn’t want to learn a new paradigm while they’re at it. Also falls apart if you don’t commit fully—half-TCA, half-MVVM is a nightmare.
My honest advice? Start with MVVM. Seriously. When you outgrow it—and you’ll know—then consider upgrading. Until then, don’t solve problems you don’t have yet.
The Folder Structure That Actually Makes Sense
I’ve seen a lot of folder structures. Most fall into two categories: overly flat (everything dumped in one folder) or overly nested (features/authentication/presentation/views/login/components/buttons—you get the idea).
Here’s what works:
MyApp/
├── App/
│ ├── MyApp.swift // @main entry point
│ └── AppDelegate.swift // Optional: Only if needed for legacy APIs
│
├── Core/
│ ├── Extensions/
│ │ ├── String+Validation.swift
│ │ └── Color+Theme.swift
│ ├── Utilities/
│ │ ├── Logger.swift
│ │ └── DateFormatter+App.swift
│ └── Constants/
│ └── AppConstants.swift
│
├── Features/
│ ├── Authentication/
│ │ ├── Models/
│ │ │ └── User.swift
│ │ ├── Views/
│ │ │ ├── LoginView.swift
│ │ │ └── SignUpView.swift
│ │ ├── ViewModels/
│ │ │ └── LoginViewModel.swift
│ │ └── Services/
│ │ └── AuthService.swift
│ │
│ ├── Dashboard/
│ │ └── (same structure)
│ └── Settings/
│ └── (same structure)
│
├── Networking/
│ ├── APIClient.swift
│ ├── APIEndpoint.swift
│ └── NetworkError.swift
│
├── Resources/
│ ├── Assets.xcassets
│ ├── Fonts/
│ └── Localizable.xcstrings // String catalog (iOS 15+)
│
└── Tests/
├── AuthenticationTests/
└── NetworkingTests/
Note: For pure SwiftUI apps targeting iOS 14+, you can skip AppDelegate entirely and use just MyApp.swift with the @main attribute. Only include AppDelegate if you need UIKit lifecycle hooks or third-party SDK initialization.
The philosophy here: - Features are kingdoms: Each feature owns its models, views, and logic. When you delete a feature, you delete one folder. No hunting. - Core is the library: Reusable stuff lives here. If it’s used by 2+ features, it’s probably Core material. - Networking stands alone: API changes shouldn’t force you to touch feature code.
The Three Rules That Keep Everything From Falling Apart
Rule 1: One File, One Responsibility (For Real This Time)
You’ve heard "separation of concerns” a million times. Here’s what it looks like in practice:
What I used to do (and you probably do too):
// LoginViewController.swift - 650 lines of pain
class LoginViewController: UIViewController {
// UI Setup (100 lines)
private func setupUI() {
// Constraint hell here
}
// Networking (80 lines)
private func performLogin() {
let url = URL(string: "https://api.myapp.com/login")!
var request = URLRequest(url: url)
// 60 more lines of URLSession boilerplate
}
// Business Logic (120 lines)
private func validateEmail(_ email: String) -> Bool {
// Regex validation
}
private func validatePassword(_ password: String) -> Bool {
// Password rules
}
// Data Persistence (90 lines)
private func saveUserSession(_ token: String) {
UserDefaults.standard.set(token, forKey: "authToken")
// More saving logic
}
// Navigation (60 lines)
// Error Handling (100 lines)
// Analytics (50 lines)
// ... you get the idea
}
What actually works:
// LoginView.swift (~100 lines) - Just SwiftUI
// LoginViewModel.swift (~80 lines) - Business logic & state
// AuthService.swift (~60 lines) - Networking
// SecureStorage.swift (~50 lines) - Keychain wrapper
// AuthCoordinator.swift (~45 lines) - Navigation
Same functionality, but now:
Rule 2: Embrace Swift Concurrency (Stop Fighting It)
Here’s the thing about modern Swift: if you’re still writing completion handlers in 2025, you’re making your life harder than it needs to be.
The old way (and please, stop doing this):
class NetworkManager {
static let shared = NetworkManager()
private init() {}
func fetchUserProfile(completion: @escaping (Result<User, Error>) -> Void) {
// Completion handler pyramid of doom
// No structured concurrency
// Hard to cancel
// Testing nightmare
}
}
// And now you're stuck with this everywhere:
class ProfileViewModel: ObservableObject {
@Published var user: User?
func loadProfile() {
NetworkManager.shared.fetchUserProfile { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let user):
self?.user = user
case .failure(let error):
// Error handling
}
}
}
}
}
The Swift 6 way (with proper concurrency):
// Mark your service as Sendable for Swift 6 strict concurrency
protocol NetworkService: Sendable {
func fetchUserProfile() async throws -> User
}
actor APINetworkService: NetworkService {
private let baseURL: String
private let session: URLSession
init(baseURL: String, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
}
func fetchUserProfile() async throws -> User {
// Clean async/await
// Automatic cancellation
// Thread-safe by default
let endpoint = APIEndpoint.fetchProfile
return try await request(endpoint)
}
}
@MainActor
@Observable // Swift 5.9+ Observation framework
class ProfileViewModel {
private let networkService: any NetworkService
var user: User?
var isLoading = false
var errorMessage: String?
init(networkService: any NetworkService) {
self.networkService = networkService
}
func loadProfile() async {
isLoading = true
errorMessage = nil
do {
user = try await networkService.fetchUserProfile()
} catch {
errorMessage = "Failed to load profile: \(error.localizedDescription)"
}
isLoading = false
}
}
Note: The @Observable macro with @MainActor requires Swift 5.9.2 or later. For Swift 5.9.0-5.9.1, you may need to use @ObservationTracked manually or stick with @Published.
Why this is better:
Rule 3: Protocols Are Your Friend (But Don’t Go Crazy)
Protocols give you flexibility. They let you swap implementations. They make testing possible.
But I’ve also seen codebases with protocols for everything, including a custom StringWrapperprotocol that just wraps String with no added functionality. Don’t be that person.
Good use of protocols (with modern Swift):
// Protocol with associated types for better type safety
protocol DataStorage: Sendable {
associatedtype StorageError: Error
func save<T: Codable & Sendable>(_ value: T, forKey key: String) async throws
func load<T: Codable & Sendable>(forKey key: String) async throws -> T?
func delete(forKey key: String) async throws
}
// Actor-based storage for thread safety
actor UserDefaultsStorage: DataStorage {
enum UserDefaultsError: Error {
case encodingFailed
case decodingFailed
}
private let userDefaults: UserDefaults
init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
}
func save<T: Codable & Sendable>(_ value: T, forKey key: String) async throws {
let data = try JSONEncoder().encode(value)
userDefaults.set(data, forKey: key)
}
func load<T: Codable & Sendable>(forKey key: String) async throws -> T? {
guard let data = userDefaults.data(forKey: key) else { return nil }
return try JSONDecoder().decode(T.self, from: data)
}
func delete(forKey key: String) async throws {
userDefaults.removeObject(forKey: key)
}
}
// Keychain storage for sensitive data
actor KeychainStorage: DataStorage {
enum KeychainError: Error {
case saveFailed(OSStatus)
case loadFailed(OSStatus)
case deleteFailed(OSStatus)
}
// Implementation using Security framework
// Thread-safe by default thanks to actor
}
Now your ViewModels work with any DataStorage, not specific implementations. When you realize UserDefaults isn’t secure enough for auth tokens, you swap in KeychainStorage and nothing else changes.
The Networking Layer (With Modern Swift Concurrency)
Here’s a pattern I wish someone had shown me years ago, updated for Swift 6:
Don’t write this everywhere:
// LoginViewModel.swift
func login(email: String, password: String) async {
guard let url = URL(string: "https://api.myapp.com/auth/login") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// ... 40 more lines of duplication
}
Write this once:
// Models.swift - Make credentials a proper Sendable struct
struct LoginCredentials: Codable, Sendable {
let email: String
let password: String
}
// APIEndpoint.swift
enum APIEndpoint: Sendable {
case login(LoginCredentials)
case fetchProfile
case updateSettings(Settings)
var path: String {
switch self {
case .login: return "/auth/login"
case .fetchProfile: return "/user/profile"
case .updateSettings: return "/user/settings"
}
}
var method: String {
switch self {
case .login, .updateSettings: return "POST"
case .fetchProfile: return "GET"
}
}
func body() throws -> Data? {
switch self {
case .login(let credentials):
return try JSONEncoder().encode(credentials)
case .updateSettings(let settings):
return try JSONEncoder().encode(settings)
case .fetchProfile:
return nil
}
}
}
// APIClient.swift - Thread-safe with actor
actor APIClient {
private let baseURL: String
private let session: URLSession
private var authToken: String?
init(baseURL: String, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
}
func setAuthToken(_ token: String) {
self.authToken = token
}
func request<T: Codable & Sendable>(_ endpoint: APIEndpoint) async throws -> T {
guard let url = URL(string: baseURL + endpoint.path) else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method
request.httpBody = try endpoint.body()
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Add auth token if available
if let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.serverError(httpResponse.statusCode)
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw NetworkError.decodingFailed
}
}
}
enum NetworkError: Error, Sendable {
case invalidURL
case invalidResponse
case serverError(Int)
case decodingFailed
}
Now every API call looks like this:
let user: User = try await apiClient.request(.fetchProfile)
let credentials = LoginCredentials(email: email, password: password)
let session: AuthSession = try await apiClient.request(.login(credentials))
One place for error handling. One place for authentication headers. One place to add logging. One place to fix bugs. And it’s all thread-safe thanks to Swift 6’s actor model.
Configuration: Environment-Aware From Day One
You’re developing against localhost:8000. Your staging server is staging-api.myapp.com. Production is api.myapp.com.
If you’re manually changing this in code before each build, we need to talk.
The modern way:
enum Environment: Sendable {
case development
case staging
case production
var baseURL: String {
switch self {
case .development: return "http://localhost:8000"
case .staging: return "https://staging-api.myapp.com"
case .production: return "https://api.myapp.com"
}
}
var apiKey: String {
switch self {
case .development: return "dev_key_12345"
case .staging: return "staging_key_67890"
case .production: return "prod_key_real"
}
}
var features: FeatureFlags {
switch self {
case .development:
return FeatureFlags(debugMenu: true, analyticsEnabled: false)
case .staging:
return FeatureFlags(debugMenu: true, analyticsEnabled: true)
case .production:
return FeatureFlags(debugMenu: false, analyticsEnabled: true)
}
}
}
struct FeatureFlags: Sendable {
let debugMenu: Bool
let analyticsEnabled: Bool
}
struct AppConfig: Sendable {
static let current: Environment = {
#if DEBUG
return .development
#elseif STAGING
return .staging
#else
return .production
#endif
}()
}
// Usage:
let client = APIClient(baseURL: AppConfig.current.baseURL)
Set up build configurations in Xcode (Debug, Staging, Release) with custom compiler flags (-D STAGING), and you’re done. No more “oops, I deployed to production with the dev API key.”
Testing: Not Optional Anymore
Look, I get it. Deadlines are crushing you. Your PM wants the feature yesterday. Testing feels like busywork when there’s so much to ship.
But here’s what nobody tells you until you learn it the hard way:
Not testing costs you way more time than testing does.
That feature you “just need to ship”? You’ll spend 3x longer debugging it in production—at 2 AM, when your app crashes for 10,000 users—than you would have spent writing tests upfront. I know because I’ve been that person refreshing the crash logs at 2 AM.
The realistic testing pyramid:
Recommended by LinkedIn
Don’t try to test everything. Test what breaks most often and what costs you the most when it breaks.
Here’s a modern ViewModel test with Swift 6:
(This looks like a lot of code, but you can write this in 10-15 minutes. It’ll save you hours of manual testing later.)
import Testing // Swift Testing framework (better than XCTest)
// Protocol for your service
protocol AuthServiceProtocol: Sendable {
func login(email: String, password: String) async throws -> User
}
@Suite("Login ViewModel Tests")
struct LoginViewModelTests {
@MainActor
@Test("Successful login updates user state")
func successfulLogin() async throws {
// Given
let mockService = MockAuthService(shouldSucceed: true)
let viewModel = LoginViewModel(authService: mockService)
// When
await viewModel.login(email: "test@example.com", password: "password123")
// Then
#expect(viewModel.isLoggedIn)
#expect(viewModel.errorMessage == nil)
#expect(viewModel.user?.email == "test@example.com")
}
@MainActor
@Test("Failed login shows error message")
func failedLogin() async throws {
// Given
let mockService = MockAuthService(shouldSucceed: false)
let viewModel = LoginViewModel(authService: mockService)
// When
await viewModel.login(email: "wrong@example.com", password: "wrong")
// Then
#expect(!viewModel.isLoggedIn)
#expect(viewModel.errorMessage != nil)
#expect(viewModel.user == nil)
}
@MainActor
@Test("Loading state is managed correctly")
func loadingState() async throws {
// Given
let mockService = SlowMockAuthService()
let viewModel = LoginViewModel(authService: mockService)
// When
async let loginTask: Void = viewModel.login(email: "test@example.com", password: "password")
// Then - should be loading
try await Task.sleep(for: .milliseconds(50))
#expect(viewModel.isLoading)
// Wait for completion
await loginTask
#expect(!viewModel.isLoading)
}
}
// Mock service conforming to Sendable
actor MockAuthService: AuthServiceProtocol {
private let shouldSucceed: Bool
init(shouldSucceed: Bool) {
self.shouldSucceed = shouldSucceed
}
func login(email: String, password: String) async throws -> User {
guard shouldSucceed else {
throw AuthError.invalidCredentials
}
return User(id: "123", email: email, name: "Test User")
}
}
actor SlowMockAuthService: AuthServiceProtocol {
func login(email: String, password: String) async throws -> User {
try await Task.sleep(for: .milliseconds(100))
return User(id: "123", email: email, name: "Test User")
}
}
Why Swift Testing over XCTest?
The mock service gives you complete control over success/failure scenarios without touching real servers.
Documentation That Actually Helps Future You
Code should be self-explanatory. Comments should explain why, not what.
Bad comments:
// Set background color to white
view.backgroundColor = .white
// Create a button
let button = UIButton()
// Check if user is logged in
if isLoggedIn {
// ...
}
These are noise. Delete them.
Good comments:
// Using white background instead of system background because
// QR code scanner needs high contrast to work reliably in both modes
view.backgroundColor = .white
// Delay required to avoid race condition with keyboard dismiss animation
// Context: iOS 17.0 introduced a timing issue where rapid transitions
// cause the keyboard to re-appear. This workaround will be removed
// when we drop iOS 16 support.
// See: https://github.com/myapp/issues/234
try? await Task.sleep(for: .milliseconds(300))
// Temporary workaround: API returns birthdate in multiple formats
// Backend team is migrating to ISO8601 (tracking: BACK-456)
// TODO: Remove after backend migration completes
let date = parseFlexibleDateFormat(dateString)
These explain decisions, gotchas, and technical debt. Future you will be grateful.
Modern Swift also gives us better tools:
// Use MARK for navigation
// MARK: - Lifecycle
// MARK: - Public Methods
// MARK: - Private Helpers
// Document complex algorithms with DocC
/// Calculates the optimal cache eviction order using LRU algorithm.
///
/// This implementation prioritizes frequently accessed items while
/// maintaining a balance between recency and frequency.
///
/// - Parameter items: Items to evaluate for eviction
/// - Returns: Ordered list of items to evict, from least to most valuable
/// - Complexity: O(n log n) where n is the number of items
func calculateEvictionOrder(_ items: [CacheItem]) -> [CacheItem] {
// Implementation
}
The Pre-Delivery Checklist
Before you call it “done,” run through this. Every time.
Environment & Configuration
Security
Performance & Stability
User Experience
Swift 6 / Modern Swift
Code Quality
Tools That Make Your Life Easier
SwiftLint: Enforces code style automatically. Configure once, forget about formatting debates. Add Swift 6 concurrency rules.
SwiftFormat: Auto-formats your code. Like Prettier for Swift. Integrates with Xcode.
SwiftGen: Generates type-safe code for assets, colors, and localization. Never typo an asset name again.
Fastlane: Automates builds, screenshots, and deployments. Essential once you’re shipping to TestFlight regularly.
Periphery: Detects unused code. Great for cleanup before major releases.
Tuist (optional): If your Xcode project file is causing merge conflicts, this helps manage it as code. Also great for modular architecture.
The Secret Nobody Talks About
Perfect architecture doesn’t exist.
Every pattern has tradeoffs. Every structure has edge cases. Every rule has exceptions.
I’ve seen teams spend three months “getting the architecture right” before writing a single feature. Then the product pivot comes, and it all gets rewritten anyway.
The goal isn’t perfection. It’s consistency and clarity.
Your team should be able to answer these questions instantly: - Where do I put a new screen? - Where do I add a new API call? - Where do I write validation logic? - How do I test this? - Is this code thread-safe?
If everyone on your team knows the answers without thinking, your structure is good enough. Ship it.
Start Small, Grow Deliberately
You don’t need all of this on day one. Hell, you don’t need half of it.
Week 1: Get the folder structure right. Separate features, networking, and core utilities. That’s it.
Week 2: Set up dependency injection for your networking layer. Make it testable.
Week 3: Add your first unit tests. Just test the business logic that scares you most.
Week 4: Configure environments properly. Stop changing URLs before deploys.
Month 2: Migrate to actors where you have shared mutable state. Start with the obvious stuff.
Month 3: Add Swift Testing. Write tests that actually give you confidence.
The important thing? Be consistent. A mediocre structure followed consistently beats a perfect structure ignored half the time.
And remember: you can refactor. Code isn’t carved in stone. If something isn’t working, change it. The best time to refactor was three months ago. The second best time is now.
What’s Next?
Next week, we’re diving into something that trips up everyone: real-time data in Swift with modern concurrency.
WebSockets, async sequences, AsyncStream, SwiftUI updates—how do you handle live data without destroying your battery, memory, or sanity?
I’ll show you patterns that actually work in production, not just in conference demos. Including how to handle backpressure, reconnection logic, and state synchronization without losing your mind.
Got a war story about project structure saving (or destroying) you? Found a better pattern? Drop it in the comments. Let’s learn from each other’s pain.
P.S. — If you’re staring at a 1,000-line ViewController right now, it’s okay. We’ve all been there. Pick the messiest function in that file and extract it to its own service. Then do another one tomorrow. Rome wasn’t built in a day, and neither are well-structured apps. Progress over perfection.
P.P.S. — If you’re still on Swift 5.9 and thinking “I don’t need Swift 6,” start preparing now. The concurrency changes are coming whether you’re ready or not. But trust me, they make your life easier, not harder.
About the Author
Bruno Valente Pimentel is a Principal iOS Architect with 16 years specializing in the Apple ecosystem. He has shipped 30+ iOS apps (7 featured by Apple, including App of the Month and App of the Year projects) and mentored 30+ developers, with ~75% advancing to senior/lead roles.
Starting in the iPhone 3GS era, Bruno has witnessed every evolution of iOS development — from OpenGL ES to Metal, from manual memory management to ARC, from UIKit to SwiftUI. He specializes in iOS architecture, AR/VR (ARKit, RealityKit), real-time computer vision, accessibility, and performance engineering.
As a WWDC attendee (5+ editions) and former CocoaHeads Rio chapter leader (8+ years), he combines deep technical expertise with a commitment to knowledge-sharing.
Found This Valuable?
Like this article so more iOS developers can discover production-grade multi-camera patterns.
Comment with your multi-camera war stories — I read and respond to every one.
Swift in Practice Production code. Real metrics. Hard-won lessons.
I see the models folder is inside the feature, that means no DTO, that is not a problem, but what if I need to use the User.swift into multiple features? Wher I should put this model?
One pattern I keep seeing in client projects: apps don’t collapse because of complexity — they collapse because structure wasn’t designed to change safely. Curious: what’s the biggest “God file” you’ve ever inherited in Swift?