Building Scalable iOS Apps: A Modern MVVM + Coordinator Architecture with Service Dependency Injection
A comprehensive guide to implementing a production-ready proven iOS architecture that scales
I've wanted to write this post for several years to share what I've learned and help others build better apps. Full disclosure: Claude.ai helped me get started by analyzing my example codebase, though I've since heavily revised the text.
Building maintainable iOS applications requires more than just writing code that works. It requires a resilient architecture that separates responsibilities, enables testability, and scales with your team. After years of iterating and refining my iOS architecture, I've settled on a proven combination (15+ apps and counting, more than 1 million daily users): MVVM (Model-View-ViewModel) + Coordinator pattern with Service Dependency Injection.
In this article, I'll walk you through the complete architecture of a production iOS app, explaining how these patterns work together to create a maintainable, testable, and scalable codebase. Some of you might say that this is not how MVVM or Coordinators should be implemented, but again this is my implementation of the pattern.
What you'll learn:
- How MVVM, Coordinators, and Services work together
- Implementing dependency injection with Resolver
- Managing navigation flows with the Coordinator pattern
- Building reactive ViewModels with Combine
- Structuring a modular codebase
Before diving into the solution, let's acknowledge the common pain points in iOS development:
- Massive View Controllers: UIViewController classes that handle everything—networking, business logic, navigation, and UI updates
- Tight Coupling: Components that are difficult to test in isolation or require deep knowledge of the inner workings of one another
- Navigation Spaghetti: View controllers directly presenting other view controllers, creating tangled dependencies
- State Management: Difficulty managing and propagating state changes across the app
- Testability: Code that's hard to unit test due to tight coupling
Sound familiar? Let's see how our architecture solves these issues.
Our architecture consists of three main layers:
┌─────────────────────────────────────────────────┐ │ View Layer │ │ (UIViewController + UIView + Storyboards/XIBs) │ └─────────────────────────────────────────────────┘ ↕ ┌─────────────────────────────────────────────────┐ │ ViewModel Layer │ │ (Business Logic + Data Transform) │ └─────────────────────────────────────────────────┘ ↕ ┌─────────────────────────────────────────────────┐ │ Service Layer │ │ (Networking, Storage, Business Services) │ └─────────────────────────────────────────────────┘ Navigation Flow Managed By: ┌─────────────────────────────────────────────────┐ │ Coordinator Pattern │ │ (AppCoordinator → Module Coordinators) │ └─────────────────────────────────────────────────┘
The Coordinator pattern moves navigation complexity by extracting all navigation logic from view controllers. Instead of view controllers presenting other view controllers directly, they delegate navigation decisions to a coordinator. This does not solve the complexity of knowing where in the flow the app is, but it moves it out of the view controllers.
Here's how the coordinator hierarchy works in practice:
The coordinator tree mirrors your app's navigation structure:
AppCoordinator (Root) ├── LoginCoordinator ├── OnboardingCoordinator └── TabBarCoordinator ├── DashboardCoordinator ├── SearchCoordinator ├── ProductsCoordinator └── MoreCoordinator ├── PinCoordinatorType └── OIDAuthorizationCoordinatorTypeWhat I learned along the way is to invert the ownership relationship. When I first experimented with coordinators, I kept running into memory leaks from retain cycles. The key insight: coordinators live alongside UIKit, not above or below it. View controllers reference their coordinators strongly, so when a flow is removed, the coordinator deallocates naturally.
When to create a new Coordinator: As a rule of thumb, create a new coordinator whenever you introduce a new UINavigationController that manages a stack of view controllers. However, some coordinators can reuse their parent's navigation controller if it fits the navigation flow naturally.
- Separation of Concerns: View controllers focus only on displaying UI
- Reusable Flows: Coordinators can be reused across different parts of the app
- Testable Navigation: Navigation logic can be unit tested independently
- Deep Linking: Coordinators handle routing via route(to:) method
Every flow has at least one view model, some have more depending on their complexity. It comes down to what feels natural to separate into distinct view models. In our pattern we can share the view model instances between several view controller, but all view controllers have at most one view model. This pattern lets us hold flow state in the view model and every view controller should be flow agnostic. So view models hold only flow and transient state. Keep that in mind for later.
View controllers bind to ViewModel publishers reactively:
Services encapsulate all data operations—networking, parsing from network objects to model objects, persistence, business logic that isn't view-specific and wrapping 3rd party frameworks. Using services with observable properties keeps all view controllers' UI synchronized with the latest data. When you update something in one place, all listeners to that service automatically reflect the change.
- Single Responsibility: Each service handles one domain
- Testability: Easy to mock for unit tests
- Reusability: Services used across multiple ViewModels
- Centralized Logic: Business rules in one place
Dependency Injection is the glue that holds everything together. We use Resolver for compile-time safe DI.
All dependencies are registered at app launch:
- .application: Single instance for the entire app lifetime (e.g., network service)
- .shared: Shared instance, recreated when all references are released
- .unique: New instance every time (default)
Code Organized
The app is organized into numbered modules for clear hierarchy:
customerapp/Sources/Modules/ ├── 001_Application/ # App delegate, root coordinator ├── 002_TabBarController/ # Main tab bar ├── 003_Common/ # Shared components ├── 010_Splash/ # Splash screen ├── 020_Login/ # Login flow │ ├── ViewController/ │ │ ├── LoginViewController/ │ │ │ ├── LoginViewController.swift │ │ │ └── LoginViewController.storyboard │ │ └── ForgotPasswordViewController/ │ ├── ViewModel/ │ │ ├── LoginViewModelType.swift │ │ └── LoginViewModel.swift │ └── Routing/ │ └── LoginCoordinator.swift ├── 030_Onboarding/ # Onboarding flow ├── 040_Dashboard/ # Dashboard module ├── 050_Search/ # Search functionality ├── 070_Products/ # Product catalog ├── 080_More/ # Settings/More tab └── 090_Pin/ # PIN authenticationEach module typically contains:
- ViewController/: View controllers with associated storyboards/XIBs
- ViewModel/: ViewModel protocols and implementations
- Routing/: Module-specific coordinator
- Cell/: Custom UITableViewCell/UICollectionViewCell (with XIBs)
- View/: Custom UIView components
Let's trace a complete user action from input to UI update:
1. User enters search text
2. ViewModel processes request
3. Service makes network request
4. Network layer executes request
5. ViewModel publishes results
6. View observes and updates UI
7. User taps a product → Coordinator handles navigation
This architecture is highly testable. Every service can be injected as a mock instance, every view model can be injected as a mock instance.
- View controllers only handle UI
- ViewModels contain business logic
- Services handle data operations
- Coordinators manage navigation
- Each layer can be tested independently
- Easy to mock dependencies
- ViewModels testable without UI
- New features added as self-contained modules
- Clear boundaries between components
- Team members can work on different modules independently
- Predictable structure across the app
- Easy to find and fix bugs
- Refactoring is safer with protocols
- Services shared across features
- Coordinators can be reused for similar flows
- ViewModels can power multiple views
Problem: Creating a ViewModel for every tiny UI component Solution: Use ViewModels for features with business logic, not for static content
Problem: ViewModels handling too much logic Solution: Extract complex logic into dedicated services
Problem: Single coordinator handling too many transitions Solution: Break into child coordinators for different flows
Moving an existing app to this architecture? Here's how:
- Set up Resolver
- Register existing services
- Gradually replace manual dependencies
- Identify networking and data logic
- Create service protocols
- Move logic from view controllers to services
- Start with complex view controllers
- Create ViewModel protocol
- Move business logic to ViewModel
- Bind view to ViewModel with Combine
- Create AppCoordinator
- Extract navigation logic
- Create module-specific coordinators
- Remove direct view controller transitions
- Organize by feature modules
- Create clear module boundaries
- Extract shared components
This architecture has served us well in production apps with teams of smaller sizes, 2-3 developers. It strikes a balance between structure and pragmatism, providing clear guidelines of where the responsibilites should lie.
Key Takeaways:
- MVVM separates presentation logic from views
- Coordinators handle all navigation, keeping view controllers focused
- Services encapsulate data operations and business logic
- Dependency Injection enables testability and loose coupling
- Reactive programming with Combine we keep the UI in sync across the app
The investment in setting up this architecture pays dividends as your app grows. Your team will thank you when they can:
- Add new features without breaking existing code
- Test components in isolation
- Navigate the codebase with confidence
- Refactor without fear
Sample Code: The complete implementation is available in the repository
Libraries Used:
- Resolver - Dependency Injection
- Combine (Native) - Reactive Programming
- Swift Concurrency (Native) - Async/Await
Further Reading:
What architecture patterns do you use in your iOS apps? Share your experiences in the comments below!
Follow me for more iOS development insights and architecture patterns.
.png)

