SwiftUI, MVVM-C, Modularisation and Dependency Injection

The article aims to demonstrate the MVVM-C pattern including with Navigation, Network and Dependency Injection

Serhan Khan
18 min readJun 10, 2024

Note: This example uses NavigationStack from swiftUI which is supported by iOS 16+

During my carrier I worked with many design patterns such as VIPER, MVC, MVVM but MVVM-C has different reputation on my side. I really like idea of decoupling the navigation from the View and Business Logic.

Before starting I would like to thank for reading and I hope it will be a good example for iOS developers on structure decision or at least I hope it can be a guidance for someone out there.

In this article I would like not get into about structure selection of our applications (because apple developer forms emphasis we should avoid using MVVM pattern). Also please refer this article from Micheal Long he wrote article about how to pick a pattern.

When I was looking for a MVVM-C way for SwiftUI I came across with this repository. This pattern is kind of following a TCA pattern with different manner. So, we will use modular architecture as TCA refers.

Before main article please refer source code that I prepared for this specific article.

Let’s take a look at components of MVVM-C pattern:

  1. Model: Model will represent the data is needed to be demonstrated on SwiftUI views. In this specific case the models are separated in two different parts (DTO and Models). DTOs are responsible to retrieve data from the network call and map it to Models, that would be used by SwiftUI views.
  2. Coordinators are responsible to handle the navigation for dedicated Feature (will be explained) and also responsible for Dependency injection.
  3. Views are responsible to represent the data from Models and also get interactions from users.
  4. ViewModels are responsible to make the network calls, handle the view states (such as loading, showing error, etc). Also view models are also responsible to keep the business logic of the views.
  5. Repositories are responsible to trigger the network calls and return the datas as DTO.

We are starting with Fonts, Colours and Images that are using in the entire application. I named this module as “System Design” and I created separated module for this by integrating the Swiftgen library for generation of the resources (so this will be an example for you how to create a new package for your dedicated folder).

First things first I created a folder named as “Foundation” and this is how my foundation folder looks like:

Figure 1 — Folder structure

As you can see foundation folder is not part of the root folder and it is separated folder. Foundation folder will contain our core needs such as Network layer, Router (for navigation), Domain objects, System Design and etc.

In order to add new package for Foundation folder you should apply the following steps.

First we need to click on “File” on Xcode and select “New -> Package” option.

Figure 2 — Selection new swift package

When new window opens we should select “Library” option and continue to next step which IMPORTANT.

Figure 3 — How to add swift package
  1. It is important here to select the Location same as your group.
  2. After that we need to indicate Project where it says “Add to” (this indicates the target that swift package will belong to) and lastly the Group where we would like to add this freshly created Swift package.
  3. Do not forget to uncheck the source control check box. Otherwise git would not recognise the changes.

If you do not follow these steps you might run to the issue on location of the generated Swift package. So, the package should be inside the group where you created it (which is a main project).

When you navigate to the folder (where ever you saved your project) it should look like this.

Figure 3 — How should folder structure look like

When you navigate to “System Design” package.swift file we will need to do some adjustments for package to be available to root project (this applies to almost all packages unless they are referring each other which I will explain later on this article for network layer).

Since I started explaining SystemDesign package (module), I would like to mention about how I managed to use swiftgen with this package. I created two different folders inside the system design one is for Supporting files where the other is for Generated files.

Figure 4 — Resources Folder Structure

I am supporting English for this specific project so I created Supporting folder contains en.lproj folder relatively for english language and we do have to create the localizable.string files under each folder that represents the language folders. If you are going to support multiple language you would need to add same files for each specific language and also please add your language under the Project.

For Colors and Images we need to create relatively assets and for fonts we need to add the otf extended files. With this setup we need to two more adjustments.

  1. Package.swift file
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "SystemDesign",
defaultLocalization: "en",
platforms: [
.iOS(.v16),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "SystemDesign",
targets: ["SystemDesign"]),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "SystemDesign",
resources: [
.process("Supporting/Images.xcassets"),
.process("Supporting/Colors.xcassets"),
.process("Supporting/Fonts/"),
.process("Supporting/en.lproj/Localizable.strings"),
.process("Supporting/tr.lproj/Localizable.strings"),
]),

.testTarget(
name: "SystemDesignTests",
dependencies: ["SystemDesign"]),
]
)

The swiftgen yaml file looks like this:

plist:
inputs: YourProject/Supporting/Info.plist
outputs:
- templateName: runtime-swift5
output: YourProject/Generated/Info.swift
strings:
inputs: Foundation/SystemDesign/Sources/SystemDesign/Supporting/en.lproj/Localizable.strings
filter: .+\.strings$

outputs:
- templateName: structured-swift5
params:
publicAccess: true
output: Foundation/SystemDesign/Sources/SystemDesign/Generated/Strings.swift

xcassets:
inputs:
- Foundation/SystemDesign/Sources/SystemDesign/Supporting/Images.xcassets
- Foundation/SystemDesign/Sources/SystemDesign/Supporting/Colors.xcassets

outputs:
- templateName: swift5
output: Foundation/SystemDesign/Sources/SystemDesign/Generated/Assets.swift
params:
publicAccess: true
fonts:
inputs: Foundation/SystemDesign/Sources/SystemDesign/Supporting/Fonts/

outputs:
- templateName: swift5
params:
publicAccess: true
output: Foundation/SystemDesign/Sources/SystemDesign/Generated/Fonts.swift

It is almost all about the swiftgen file and configuration. So far we covered how to modularise our application, how to add new package as a separated module and also how to separate our assets such as colours, images, fonts and plist file by using swiftgen.

Creation of Router and Network Packages

For navigation we have separated package under the foundation called “Router”. I won’t repeat how do we add the new package under the foundation folder.

So, here is the Router classes code looks like:

// The Swift Programming Language
// https://docs.swift.org/swift-book

import SwiftUI

public class AnyIdentifiable: Identifiable {
public let destination: any Identifiable

public init(destination: any Identifiable) {
self.destination = destination
}
}

public final class Router: ObservableObject {
@Published public var navPath = NavigationPath()
@Published public var presentedSheet: AnyIdentifiable?

public init() {}

public func presentSheet(destination: any Identifiable) {
presentedSheet = AnyIdentifiable(destination: destination)
}

public func navigate(to destination: any Hashable) {
navPath.append(destination)
}

public func navigateBack() {
navPath.removeLast()
}

public func navigateToRoot() {
navPath.removeLast(navPath.count)
}
}

We are using navigation stack so, it is important to our destination (enum) should inherit hashable so that we can trigger the router method navigate and hashable.

The router class will be initiated at the root project under the App.swift class and we will have to pass it as environment object so that we can use it as reference in every part of our application. Additionally we need to adjust the package.swift file of the Router package as follows:

// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "Router",
platforms: [
.iOS(.v16),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "Router",
targets: ["Router"]),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "Router"),
.testTarget(
name: "RouterTests",
dependencies: ["Router"]),
]
)

As next part I will write about how we will include the network manager (layer) as separated package and use it in entire application. So please go ahead and create new package named as “Network”.

For network layer I will share all code here for you so that you can use it in your projects as separated package.

As mentioned above in “System Design” package we need to create a new swift package in Foundation folder.

Under “Foundation” folder we will have a new package called Network please refer the folder and file structure below:

Figure 5 — Network Package Folder Structure

We will need to create new folders accordingly in order to keep our folder and file structure as clean as we can.

So APIClientService folder will have ApiClientService.swift class and Mappable.swift class.

//
// ApiClientService.swift
//
//
// Created by Serhan Khan on 04/01/2024.
//

import Foundation
import Logger

public enum APIError: Error {
case invalidEndpoint
case badServerResponse
case networkError(error: Error)
case parsing(error: Error)
}

public typealias APIResponse = (data: Data, statusCode: Int)

public protocol IAPIClientService {
func request(_ endpoint: EndpointType) async -> Result<APIResponse, APIError>
func request<T: Decodable>(_ endpoint: EndpointType, for type: T.Type, decoder: JSONDecoder) async throws -> T
func request<T, M: Mappable>(_ endpoint: EndpointType, mapper: M) async throws -> T where M.Output == T
}

public extension IAPIClientService {
func request<T: Decodable>(_ endpoint: EndpointType, for type: T.Type) async throws -> T {
try await request(endpoint, for: type, decoder: JSONDecoder())
}
}

public final class APIClientService: IAPIClientService {
public struct Configuration {
let baseURL: URL?
let baseHeaders: [String: String]

public init(baseURL: URL?, baseHeaders: [String: String]) {
self.baseURL = baseURL
self.baseHeaders = baseHeaders
}

public static let `default` = Configuration(baseURL: nil, baseHeaders: [:])
}

private let logger: ILogger
private let configuration: Configuration

public init(logger: ILogger, configuration: Configuration = .default) {
self.configuration = configuration
self.logger = logger
}

private func request(_ endpoint: EndpointType, completion: @escaping (Result<APIResponse, APIError>) -> Void) {
guard let request = buildURLRequest(from: endpoint) else {
completion(.failure(.invalidEndpoint))
return
}

let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
self?.logger.log(request: request, data: data, response: response as? HTTPURLResponse, error: error)

if let error = error {
completion(.failure(.networkError(error: error)))
return
}

guard let data = data, let httpResponse = response as? HTTPURLResponse,
(200 ..< 400).contains(httpResponse.statusCode)
else {
completion(.failure(.badServerResponse))
return
}

completion(.success((data, httpResponse.statusCode)))
}

task.resume()
}

public func request(_ endpoint: EndpointType) async -> Result<APIResponse, APIError> {
await withCheckedContinuation { continuation in
request(endpoint, completion: { result in
continuation.resume(returning: result)
})
}
}

public func request<T>(
_ endpoint: EndpointType,
for _: T.Type,
decoder: JSONDecoder = JSONDecoder()
) async throws -> T where T: Decodable {
let response = await request(endpoint)
switch response {
case let .success(result):
do {
let modelResponse = try decoder.decode(T.self, from: result.data)
return modelResponse
} catch {
if let decodingError = error as? DecodingError {
logger.log(level: .error, message: "❌ Decoding error: \(decodingError.detailErrorDescription)")
}

throw APIError.parsing(error: error)
}
case let .failure(failure):
throw failure
}
}

public func request<T, M: Mappable>(_ endpoint: EndpointType, mapper: M) async throws -> T where T == M.Output {
let responseModel: M.Input = try await request(endpoint, for: M.Input.self)
return try mapper.map(responseModel)
}

private func buildURLRequest(from endpoint: EndpointType) -> URLRequest? {
let host = endpoint.baseUrl?.host ?? configuration.baseURL?.host
guard let host = host else { return nil }

var components = URLComponents()
components.scheme = "https"
components.host = host
components.path = endpoint.path

if let urlQueries = endpoint.urlQueries {
var queryItems: [URLQueryItem] = []
for item in urlQueries {
queryItems.append(URLQueryItem(name: item.key, value: item.value))
}

components.queryItems = queryItems
}

guard let url = components.url else { return nil }

var request = URLRequest(url: url)
request.httpMethod = endpoint.httpMethod.rawValue

let endpointHeaders = endpoint.headers ?? [:]
let mergedHeaders = configuration.baseHeaders.merging(endpointHeaders) { (_, new) in new }
request.allHTTPHeaderFields = mergedHeaders

switch endpoint.bodyParameters {
case let .data(data):
request.httpBody = data
case let .dictionary(dict, options):
let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: options)
request.httpBody = jsonData
case let .encodable(object, encoder):
let data = try? encoder.encode(object)
request.httpBody = data
default:
break
}

return request
}
}

ApiClientService class imports logger package where you can find the example code in this repo (under Foundation -> Logger). The “ApiClientService.swift” class is responsible to trigger network call with modern swift concurrency way (async/await).

We are going to use own Mappable protocol in order to achieve mapping process from Data to Model (which will be shown in the following parts of the article).

The code below, triggers the network call as using async/await.

public func request<T, M: Mappable>(_ endpoint: EndpointType, mapper: M) async throws -> T where T == M.Output {
let responseModel: M.Input = try await request(endpoint, for: M.Input.self)
return try mapper.map(responseModel)
}

However, the following code is triggered in the nutshell:

 public func request<T>(
_ endpoint: EndpointType,
for _: T.Type,
decoder: JSONDecoder = JSONDecoder()
) async throws -> T where T: Decodable {
let response = await request(endpoint)
switch response {
case let .success(result):
do {
let modelResponse = try decoder.decode(T.self, from: result.data)
return modelResponse
} catch {
if let decodingError = error as? DecodingError {
logger.log(level: .error, message: "❌ Decoding error in \(T.self): \(decodingError.detailErrorDescription)")
}
throw APIError.parsing(error: error)
}
case let .failure(failure):
throw failure
}
}

Our Mappable protocol as follow and as mentioned before this protocol helps us to map swift network call outcome (data) to model.

//
// Mappable.swift
//
//
// Created by Serhan Khan on 04/01/2024.
//

import Foundation

// Mappable protocol for mapping from Data to Model
public protocol Mappable {
associatedtype Input: Decodable
associatedtype Output

func map(_ input: Input) throws -> Output
}

Apart from mappable protocol I included HelperMacros package in order to have default Init methods for structs. The init macro is called “Default Init”. Please refer code below:

//
// DefaultInitMacro.swift
//
//
// Created by Serhan Khan on 09/03/2024.
//

import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

// swiftlint:disable:next private_over_fileprivate
fileprivate struct Argument {
let argumentName: PatternSyntax
let argumentType: TypeSyntax
let initializer: ExprSyntax?
}

/// Implementation of the `DefaultInit` macro, which takes struct/class and provide public default initialize for it.
/// Note that you need to declare the type explicit for the variable you want to add to the `init`.
/// For example
///
/// @DefaultInit
/// struct Person {
/// let name: String
/// }
///
/// will expand to
///
/// struct Person {
/// let name: String
/// public init(
/// name: string
/// ) {
/// self.name = name
/// }
/// }
///
public struct DefaultInit: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax, // Type enum decleration
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
if !declaration.is(StructDeclSyntax.self) && !declaration.is(ClassDeclSyntax.self) {
throw MacroError.notStructOrClass
}

let members = declaration.memberBlock.members
var arguments: [Argument] = []

let declarations = members
.compactMap { $0.decl.as(VariableDeclSyntax.self) }

for decl in declarations {
let bindings = decl.bindings
let specifier = decl.bindingSpecifier

for binding in bindings {
let argumentName = binding.pattern
guard let type = binding.typeAnnotation?.type.trimmed ?? bindings.last?.typeAnnotation?.type.trimmed else {
// Throw
return []
}

let defaultParam = binding.initializer?.value
if specifier.text == "var" || defaultParam == nil {
arguments.append(.init(argumentName: argumentName, argumentType: type, initializer: defaultParam))
}
}
}

let initializer = try InitializerDeclSyntax(generateInitialCode(arguments: arguments)) {
for index in 0..<arguments.count {
ExprSyntax("self.\(arguments[index].argumentName) = \(arguments[index].argumentName)")
}
}

return [DeclSyntax(initializer)]
}

fileprivate static func generateInitialCode(
arguments: [Argument]
) -> SyntaxNodeString {

var initialCode: String = "public init("
for argument in arguments {
if let initializer = argument.initializer {
initialCode += "\n\(argument.argumentName): \(argument.argumentType) = \(initializer),"
} else {
initialCode += "\n\(argument.argumentName): \(argument.argumentType),"
}
}
initialCode = String(initialCode.dropLast(1))
initialCode += "\n)"
return SyntaxNodeString(stringLiteral: initialCode)
}
}

// swiftlint:disable:next private_over_fileprivate
fileprivate enum MacroError: Error, CustomStringConvertible {
case notStructOrClass

var description: String {
switch self {
case .notStructOrClass:
return "@DefaultInit only applied for struct or class"
}
}
}

“DefaultInit” macro is included to avoid code duplication for init methods in the structures and we will be using in our mappable structs in order to pass the client necessary variables that UI needs.

So in this case our main response models will take place in DomainData folder and Domain folder will only include the models that UI requires to show.

Figure 6 — Domain Package.

And as you can see our protocols of the repositories are in the Domain side (which provides information to Client app) and repo implementations are inside the DomainData.

So far I wanted to mention you about the key points of the structure. From now on I will demonstrate some code snippets to you I achieved the MVVM-C with dependency injection and I will finalise my article with this implementations.

So, when we go to application class which you are able to see from the source code (MarvelSquad App):

We define the necessary configuration classes such as Network Layer, Router, Logger and etc.

First things first do not forget to include your swift package (i.e Network) in to your client application under the Targets -> General -> Frameworks, Libraries, and Embedded Content.

Figure 7 — Frameworks, Libraries, and Embedded Content

As mentioned above MarvelSquad App class code as follows:

//
// MarvelSquadApp.swift
// MarvelSquad
//
// Created by Serhan Khan on 01/01/2024.
//

import SwiftUI
import Logger
import Network

@main
struct MarvelSquadApp: App {
let configuration: Configuration
init() {
let logger = Logger(label: "MarvelSquad")
let apiClientService = APIClientService(logger: logger,
configuration: .init(baseURL: URL(string: PlistFiles.apiBaseURL),
baseHeaders: ["Content-Type": "application/json"]))
configuration = .init(logger: logger,
apiClientService: apiClientService,
publicKey: PlistFiles.publicKey,
privateKey: PlistFiles.privateKey)
}
var body: some Scene {
WindowGroup {
MainTabView()
.environmentObject(configuration)
}
}
}

Configuration class includes all necessary classes that are needed to be initialised by client app.

class Configuration: ObservableObject {
let logger: ILogger
let apiClientService: IAPIClientService
let publicKey: String
let privateKey: String
init(logger: ILogger, apiClientService: IAPIClientService, publicKey: String, privateKey: String) {
self.logger = logger
self.apiClientService = apiClientService
self.publicKey = publicKey
self.privateKey = privateKey
}
}

Here logger is used to log out the network call results or any message that you would like to print in the logger window.

ApiClientService will be passed to repository classes within dependency injection hierarchy so, we will use only one instance of each class.

This project includes two different tabs one is for characters ( Heros) and other one for Comics (for Heros) so when we take a look at the MainTabView we would be able to observe more detailed dependency injection passing with EnvironmentObject. Thanks to swiftUI’s environment object wrapper we can use default dependency injection.

import SwiftUI
import Router
import SystemDesign
import Comics

struct MainTabView: View {
@State private var selection = 0
@ObservedObject private var router = Router()
var body: some View {
TabView(selection: $selection) {
CharactersTabCoordinator()
.tabItem {
Image(systemName: "person.circle")
.foregroundStyle(Color.black)
Text(L10n.herosTabTitle)
}
.tag(0)
.toolbarBackground(Asset.Colors.white.swiftUIColor, for: .tabBar)
ComicsTabCoordinator()
.tabItem {
Image(systemName: "book.circle")
.foregroundStyle(Color.black)
Text(L10n.comicsTabTitle)
}
.tag(1)
.toolbarBackground(Asset.Colors.white.swiftUIColor, for: .tabBar)
}
.environmentObject(router)
}
}

#Preview {
MainTabView()
}

Router is initialised in this class and passed as environment object to the coordinators. Router class as given above is responsible to handle push, pop process within the project.

Here is important to understand relationship between the coordinators. The CharactersTabCoordinator & ComicsTabCoordinator are generated in client side and each coordinator holds navigation logic within their own Features.

For this specific example I do not have interaction between two coordinators however it is important to understand that when we are going to have interaction between two coordinators, this will go related ComicsTabCoordinator or CharacterTabCoordinator by subscribing the router nav path variable. Please refer this code snippet.

Lets dive into ComicsTabCoordinator side and see example of network call and navigation.

Our “ComicsTabCoordinator” will look like this:

import SwiftUI
import Router
import Comics

struct ComicsTabCoordinator: View {
@EnvironmentObject var configuration: Configuration
@EnvironmentObject var router: Router
var body: some View {
NavigationStack(path: $router.navPath) {
ComicsCoordinator(dependecies: .init(
apiClient: configuration.apiClientService,
publicKey: configuration.publicKey,
privateKey: configuration.privateKey))
.toolbar(.visible, for: .tabBar)
}.environmentObject(router)
}
}

#Preview {
CharactersTabCoordinator()
}

As you can see we are injecting the Configuration which will be parsed by the ComicsCoordinator and router which will again would be injected by ComicsTabCoordinator in order to handle the navigation within the “Features -> Comics” package.

As mentioned above we are using navigation stack and we are listening the changes for router navigation path variable changes in order to navigate back and forth between Characters or other features (which we don’t have any relationship currently).

When we take a look at ComicsCoordinator we can see that the ComicsCoordinator takes place in Features folder as a separated feature package. This logic applies pretty much in TCA, it refers modular based implementation for a specific feature.

import SwiftUI
import Router
import Network
import Domain
import DomainData

enum ComicDestination: Hashable {
case comicDetail(comic: Comic)
}
public struct ComicsCoordinator: View {
@EnvironmentObject
private var router: Router
private let dependecies: Dependecies
public init(dependecies: Dependecies) {
self.dependecies = dependecies
}
public var body: some View {
ComicsListView(dependecies:
.init(publicKey: dependecies.publicKey,
privateKey: dependecies.privateKey,
comicRepository: ComicRepository(apiClientService: dependecies.apiClient)))
.navigationDestination(for: ComicDestination.self, destination: { destination in
switch destination {
case let .comicDetail(comic):
ComicDetailView(comic: comic)
}
})
}
}

public extension ComicsCoordinator {
struct Dependecies {
let apiClient: IAPIClientService
let publicKey: String
let privateKey: String
public init(apiClient: IAPIClientService, publicKey: String, privateKey: String) {
self.apiClient = apiClient
self.publicKey = publicKey
self.privateKey = privateKey
}
}
}

Above code demonstrates also how we are handling the navigation within the Comics package that is actually handled by the Router package.

Please also see the folder structure for Comics feature:

Figure 8 — Comics Feature Package Folder Structure

Comics coordinator injects the ComicsListView where we pass the dependencies that are required by the ComicsListViewModel, because we have repository which is responsible the make a network call related with the Comics.

import SwiftUI
import CommonUI
import Router
import Domain
import SystemDesign

public struct ComicsListView: View {
@EnvironmentObject private var router: Router
@StateObject private var viewModel: ComicsListViewModel
init(dependecies: ComicsListViewModel.Dependecies) {
_viewModel = .init(wrappedValue: ComicsListViewModel(dependecies: dependecies))
}
public var body: some View {
ZStack {
switch viewModel.state {
case .loading:
ProgressView()
case .display(let data):
List(data, id: \.id) { character in
CharacterItemView(name: character.name, imageUrl: character.image)
.onTapGesture {
router.navigate(to: ComicDestination.comicDetail(comic: character))
}
}
}
}.task {
await viewModel.fetch(limit: 10, offset: 0)
}.screenBackground(with: Asset.Colors.white.swiftUIColor)
.navigationTitle(L10n.comicsTabTitle)
}
}

We can observe that ComicsListViewModel initialised within the constructor of the ComicsListView as StateObject and also it is important to understand we are injecting the Router variable in order to trigger the navigation destination which is listened in the ComicsCoordinator.

So in the nutshell Coordinator is responsible to create the necessary Repository (ComicsRepository) class and pass it as a parameter to View and the ViewModel .

Note: ComicsListView is injecting the CommonUI framework (which is a separated SPM in the core folder. Please find the package.swift file inside the Comics package.

// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "Comics",
platforms: [
.iOS(.v16)
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "Comics",
targets: ["Comics"]),
],
dependencies: [
.package(path: "../Core/CommonUI"),
.package(path: "../Foundation/Network"),
.package(path: "../Foundation/Domain"),
.package(path: "../Foundation/Router"),
.package(path: "../Foundation/SystemDesign")
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "Comics",
dependencies: [
"Network",
"CommonUI",
"Domain",
.product(name: "DomainData", package: "Domain"),
"Router",
"SystemDesign"
]
),
.testTarget(
name: "ComicsTests",
dependencies: ["Comics"]),
]
)

Our ComicsListViewModel for example is responsible to inject the repository and trigger the network call for comics. Unfortunately the gateway marvel api requires the api keys explicitly so I have defined them in the info.plist so that I don’t expose them outside world.

//
// ComicsListViewModel.swift
//
//
// Created by Serhan Khan on 29.05.24.
//

import Foundation
import Domain

final class ComicsListViewModel: ObservableObject {
private let repository: IComicRepository
private let publicKey: String
private let privateKey: String
@Published var heros: [Comic] = []
@Published var showError: Bool = false
struct Dependecies {
let publicKey: String
let privateKey: String
let comicRepository: IComicRepository
}
init(dependecies: Dependecies) {
self.repository = dependecies.comicRepository
self.privateKey = dependecies.privateKey
self.publicKey = dependecies.publicKey
}
enum State {
case loading, display(data: [Comic])
}
@Published
var state: State = .loading
@MainActor
func fetch(limit: Int, offset: Int) async {
let timeStamp = Date().timeIntervalSince1970
let hash = "\(timeStamp)\(privateKey)\(publicKey)"
state = .loading
do {
let comics = try await self.repository.fetchComics(limit: limit, offset: offset, apiKey: publicKey, timeStamp: timeStamp, hash: hash)
state = .display(data: comics)
} catch {
print("error = \(error)")
self.showError = true
}
}
}

And last but not least our comic repository code looks like this:

import Foundation
import Domain
import Network

public final class ComicRepository: IComicRepository {
private let apiClientService: IAPIClientService
public init(apiClientService: IAPIClientService) {
self.apiClientService = apiClientService
}
public func fetchComics(limit: Int,
offset: Int,
apiKey: String,
timeStamp: Double,
hash: String) async throws -> [Comic] {

do {
let result = try await apiClientService.request(
APIEndpoints.fetchComincsEndpoint(limit: limit,
offset: offset,
apiKey: apiKey, timeStamp: timeStamp, hash: hash.MD5()),
mapper: ComicsDataWrapperMapper())
return result
} catch {
throw error
}
}
}

In this specific article I wanted to mention about the key points of MVVM-C structure for SwiftUI however as I mentioned at the beginning of my article I am not after discuss which structure is really goes with the project, as there are my factors to decide about the structure decision such as team experience, collaboration of the team and on which direction the project would grow, what is the minimum support iOS version and etc.

If you are going to have a small project I would recommend to you give this structure a try, I am aware of some of the things would not fit your requirements but it is always to see the cons and pros of each structure so that we can improve our skills step by step.

The creators of the structure argue that we can also apply this specific structure in large scale project but I would say it might be a bit tricky to create the base requirements for this structure.

It is good to decouple the coordinators, viewModels, views, and modular based features so that we can increase the unit testing coverage but sometimes I had issue with my git to merge the code because we need to be careful on how we are adding the swift packages and how we are including them since sometimes gitignore is having issue to recognise the changes.

Please find the updated source code here:

If you like to contact with me and if you have suggestions please do not hesitate to contact.

Note: In part two I will explain how we can achieve unit testing, so plase stay tuned.

--

--