Architecture Decisions I Would Avoid in a New SwiftUI App
Starting a new SwiftUI app is dangerous in a funny way: everything is still easy.
There is no legacy code. There are no painful dependencies. There are no awkward flows. A few views, a couple of @State properties, some async calls, and the app feels wonderfully simple.
That simplicity is real, and we should protect it. But some early decisions make the app feel simple by hiding complexity in places where it will be expensive to remove later.
These are the architecture decisions I would avoid when starting a new SwiftUI app today.
#I Would Avoid Global Services by Default
Global services are tempting.
Analytics.shared.track("screen_viewed")
APIClient.shared.getProducts()
SessionStore.shared.currentUser
They make call sites short. They also make dependencies invisible.
When a feature uses global services, previews become harder, tests become more stateful, and reuse becomes more complicated. The feature can no longer clearly say what it needs.
Prefer injecting dependencies at feature boundaries:
ProductsView(
viewModel: viewModel,
onAppear: {
await getProducts.execute()
}
)
The app can still own long-lived instances. They just do not need to be global.
#I Would Avoid Putting Business Dependencies in the Environment
SwiftUI's environment is powerful. It is also easy to misuse.
This looks clean:
@Environment(\.productsRepository) private var productsRepository
But if productsRepository defines the feature's behavior, hiding it in the environment can make the feature harder to understand.
The environment is great for values that really are environmental: locale, color scheme, dismiss actions, feature flags, app-wide context, and sometimes navigation or session state.
For feature-specific business dependencies, I would start with initializers.
This keeps the dependency graph visible and works naturally with the composition root.
#I Would Avoid ViewModels That Own Navigation Too Early
For small flows, a ViewModel changing navigation state may be fine.
But I would avoid making it the default.
final class LoginViewModel: ObservableObject {
@Published var path: [Route] = []
}
This couples login behavior to app navigation. Over time, the login feature can learn too much about onboarding, tabs, deep links, and post-login destinations.
I prefer ViewModels that expose feature outputs:
enum LoginOutput {
case didLogin
case didRequestPasswordReset
}
The parent or composition layer decides where those outputs go.
This keeps screens reusable in different flows.
#I Would Avoid Starting With a DI Container
DI containers can be useful. I still would not start there.
In a new app, plain Swift is usually enough:
struct CompositionRoot {
func makeRootView() -> some View {
ProductsFeatureFactory(
repository: makeProductsRepository()
)
.makeProductsView()
}
}
Start with initializer injection, factories, and a composition root. If construction becomes repetitive later, then consider whether a container solves a real problem.
Adding a container too early can make the app harder to debug because dependency resolution moves from compiler-visible construction to registration and lookup.
I would rather have boring construction code than magical construction code.
#I Would Avoid Over-Abstracted Networking
Networking code often attracts generic abstractions too early.
protocol Endpoint {
associatedtype Response
var path: String { get }
var method: HTTPMethod { get }
}
This can be useful in the right app. But if the abstraction appears before the app has real networking pressure, it can become a framework inside the app.
I would start smaller:
protocol HTTPClient {
func get<T: Decodable>(_ path: String) async throws -> T
}
Then let real use cases shape the next abstraction.
Architecture should respond to pressure. It should not predict every possible future on day one.
#I Would Avoid One App-Wide ViewModel
An app-wide ViewModel can become a soft global object.
final class AppViewModel: ObservableObject {
@Published var user: User?
@Published var products: [Product] = []
@Published var cart: Cart?
@Published var selectedTab: Tab = .home
}
Some app-level state is legitimate. Session state, selected tab, and deep-link handling may belong near the root.
But feature state should usually stay with the feature.
If products, cart, search, checkout, onboarding, and settings all live in one app object, every feature becomes coupled to every other feature's state.
Prefer smaller feature models and explicit communication between boundaries.
#I Would Avoid Making Every Type Generic
Generics can make Swift code powerful and precise. They can also make app architecture harder to read.
struct ProductsFeatureAdapter<
Repository: ProductsRepository,
Tracker: AnalyticsTracking,
Scheduler: Scheduling
> { ... }
Sometimes this is worth it. Often, existential dependencies are easier to understand:
final class ProductsFeatureAdapter {
private let repository: any ProductsRepository
private let analytics: any AnalyticsTracking
}
In app code, readability is a feature. Use generics where they buy something concrete.
#I Would Avoid Treating MVVM as the Whole Architecture
MVVM tells you something about the relationship between a view and its state. It does not tell you where dependencies are created, how features communicate, where data policy lives, how navigation is owned, or how modules depend on each other.
That is why "we use MVVM" is not enough.
A SwiftUI app can use MVVM and still have:
- hidden global dependencies
- massive ViewModels
- unclear data boundaries
- scattered navigation
- untestable construction
MVVM is useful, but it is not the architecture.
This is why I treat MVVM as a useful local pattern rather than the complete architectural story.
#What I Would Do Instead
For a new SwiftUI app, I would start with:
- feature folders
- initializer injection
- a small composition root
- feature factories when construction grows
- explicit view inputs and ViewModel outputs
- repositories only where data boundaries matter
- use cases only where application operations deserve names
- protocols only where substitution is useful
That is not a lot of machinery. It is mostly a set of habits that keep decisions visible.
#What You Should Remember
The early architecture decisions that hurt later usually have one thing in common: they hide dependencies.
Global services hide dependencies. Environment abuse hides dependencies. App-wide ViewModels hide ownership. Premature containers hide construction. Over-abstracted networking hides simple behavior behind a framework.
Keep the first version boring, explicit, and easy to change. That is usually the best architecture a new SwiftUI app can have.