A demonstration project showcasing my approach to building scalable, testable, and maintainable Swift apps via Clean architecture
ScreenRecording_12-26-2025.11-02-51_1-2.mp4
There are three layers: Data, Domain, and Presentation (feature-based or screen-based). In the current app, the Domain layer is implemented without use cases, as this extra separation is optional for me. In a real app, I would implement use cases if there were multiple data sources - for example, persisted data and an API. This allows a use case to coordinate multiple services and decide which data to take, removing duplicated logic from ViewModels
- Data: DTOs, API clients and service implementations. Only knows about Domain layer
- Domain: Domain entities, business rules and service protocols. Lives separately from other layers
- Presentation: SwiftUI views, ViewModels (state & event handling), navigation coordinator, design system. Only knows about Domain layer
- For state management, I use an approach inspired by the BLoC package from Flutter (which is also similar to MVI). Each view has a state, events, and a handler class that processes user actions into an updated state. I use the ViewModel suffix for these classes in Swift, as it is more convenient for me
- ViewModels should not depend on UI frameworks (including SwiftUI) or handle localization. There should be a clear separation of concerns - ViewModel is responsible for business, logic and state, not UI rendering, or presentation details or navigation
Clean architecture ensures unidirectional data flow (one way) keeping UI, business logic, and data sources clearly separated, predictable, and easy to test:
- User Action (event) ->
- View ->
- ViewModel ->
- Abstract interface (protocol) ->
- implementation is hidden (is known only for DI) and can be replaced with another one that conforms to the interface
- Updated state ->
- View
- The main architectural dependency injection (services) is implemented via protocols (see DependencyInjection.swift). ViewModels do not know anything about the concrete implementations of services - only about the service protocols
- There are also observable classes with purely view-related responsibilities, such as navigation, error presentation, and logging user events to an analytics service. These classes are needed only in the Presentation layer, so no additional abstraction is required. They are injected only into the UI and registered using Swift’s EnvironmentKeys. (see EnvironmentValues.swift)
- Coordinator is implemented using SwiftUI's NavigationStack in the main AppView. All application routes are declared in Routes.swift, and page construction and navigation logic are handled in Coordinator.swift
- There 2 types of errors in this project - API (data) and App (domain)
- For API errors, in real project I would implement an interceptor that catches and maps server responses to App (domain) errors
- App errors help enforce separation between layers, and their localization should be handled in the Presentation layer
- AppView is wrapped with ErrorOverlay, which is responsible for displaying one toast at a time
- The ErrorHandler is stored in the environment and should be injected into views where errors may be thrown
- List of characters with pagination, search & filters
- Settings to change localization, theme and reset all settings
- OnBoarding just to show another main app route switching :)
- API client
- Network connectivity checker
- System/Dark/Light theme switch
- Localization
- Analytics logger
- UI tests
- Unit tests
- Flavors