IOS Microapps Architecture — Part 4

Artem Kvasnetskyi
26 min readOct 13, 2023

--

This is the final part of a series of articles on Microapps architecture. In this part, we’ll finish our feature modules, create Foundation layer modules, and plug them all into the main application.

If you missed the previous parts, it’s best to start there:

  • First part
    Will give you an overview of what modular architecture is, why you need it, and how to implement it in iOS.
  • Second part
    Will focus on what Microapps Architecture is and how to implement it with Swift Package Manager. Also, this is the part where we started developing our app, so this part is a must-read.
  • Third part
    Will continue the development of our application, finish our first Feature module, and create our first Microapp. This part is a must-read.

Demo application

You can see an example of an already completed application on my GitHub (I’d be grateful for your subscription).

In this series of articles, we’ll create it from scratch, but if it’s easier for you to understand the code, you can do that.

The app contains a total of four screens:

  • Auth Select Screen (Part 2)
  • Sign Up Screen (Part 3)
  • Sign In Screen (Part 3)
  • Home Screen (This part)

In this part, we will create the Home screen, create fake business logic, and connect all features to the main app.

With this app, registered users can view a list of characters from the Rick and Morty universe.

All modules use the MVVM architecture implemented with Combine. The module related to authorization is written using SwiftUI, the main screen uses UIKit to demonstrate the variability of Microapps architecture.

In addition, the application uses some libraries. In this series of articles we will not create real business logic, so most of the libraries will not be used in the articles, but if you are interested in real business logic, you can see it in the demo application.

Well, let’s continue our application by creating Home screens.

Home screen

Like last time, let’s start with the resources we need for this module. Create folders as in the screenshot below. Add Strings file to en.lproj and swiftgen.yml to the Home folder.

Add the following content to the Localizable file:

// MARK: - Home
"Home.Title" = "Home";
"Gender.Prefix" = "Gender:";
"Species.Prefix" = "Species:";

The Swiftgen configuration will look like this:

strings:
- inputs: Resources/Process/Localizations/en.lproj
outputs:
templateName: structured-swift5
output: Resources/Generated/Localization.swift
params:
enumName: Localization

Now let’s update our Package.swift.

import PackageDescription

let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),
.library(name: "Home", targets: ["Home"]), // <---

// MARK: - UI
.library(name: "Resources", targets: ["Resources"]),
.library(name: "Views", targets: ["Views"]),

// MARK: - Utility
.library(name: "Navigation", targets: ["Navigation"]),
.library(name: "Entities", targets: ["Entities"]),
.library(name: "Base", targets: ["Base"])
],
dependencies: [
.package( // <---
url: "https://github.com/onevcat/Kingfisher",
.upToNextMajor(from: "7.9.1")
)
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base")
],
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")]
),
.target( // <---
name: "Home",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base"),
// Third Party
.byName(name: "Kingfisher")
],
path: "Sources/FeatureModules/Home",
resources: [.process("Resources/Process")]
),

// MARK: - Utility
.target(name: "Navigation", path: "Sources/UtilityModules/Navigation"),
.target(name: "Entities", path: "Sources/UtilityModules/Entities"),
.target(
name: "Base",
dependencies: [
.target(name: "Entities"),
.target(name: "Views")
],
path: "Sources/UtilityModules/Base"
),

// MARK: - UI
.target(
name: "Resources",
path: "Sources/UIModules/Resources",
resources: [.process("Process")]
),
.target(
name: "Views",
dependencies: [
.target(name: "Resources")
],
path: "Sources/UIModules/Views"
)
]
)

If you are familiar with the previous part of the articles, there is nothing new for you here, except the new “dependencies” parameter. With this parameter, we can connect third-party libraries to our Swift Package.

We have added Kingfisher as a third-party dependency to our Swift Package because we are going to display pictures in the UITableView. This great library simplifies the logic of loading and caching images. If you are not familiar with it yet, it’s okay, because you will be able to understand what’s going on in the future code.

Also, before you start the layout on UIKit, it wouldn’t be a bad idea to add a couple of extensions to simplify things. Let’s do it :)

Utility Modules

Extensions

Create the following path:
Sources/UtilityModules/Extensions/UIKit

In this module, we will store all of our Extensions. Let’s start with UIStackView. Create a file UIStackView+Extensions.swift and add the following code to it:

import UIKit

public extension UIStackView {
func setup(
axis: NSLayoutConstraint.Axis = .vertical,
alignment: Alignment = .fill,
distribution: Distribution = .fill,
spacing: CGFloat = .zero
) {
self.axis = axis
self.alignment = alignment
self.distribution = distribution
self.spacing = spacing
}

func addSpacer(_ size: CGFloat? = nil) {
let spacer = UIView()
spacer.backgroundColor = .clear
addArranged(spacer, size: size)
}

func addArranged(
_ view: UIView,
size: CGFloat? = nil
) {
addArrangedSubview(view)

guard let size else { return }

switch axis {
case .vertical:
view.heightAnchor.constraint(
equalToConstant: size
)
.isActive = true

case .horizontal:
view.widthAnchor.constraint(
equalToConstant: size
)
.isActive = true

default: return
}
}
}

Next to it, create UIView+Extensions.swift, and add the following code to it:

import UIKit

public extension UIView {
func addSubview(_ other: UIView, constraints: [NSLayoutConstraint]) {
addSubview(other)
other.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate(constraints)
}

func addSubview(
_ other: UIView,
withEdgeInsets edgeInsets: UIEdgeInsets,
safeArea: Bool = true
) {
if safeArea {
addSubview(other, constraints: [
other.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: edgeInsets.left),
other.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: edgeInsets.top),
other.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -edgeInsets.right),
other.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -edgeInsets.bottom)
])

} else {
addSubview(other, constraints: [
other.leadingAnchor.constraint(equalTo: leadingAnchor, constant: edgeInsets.left),
other.topAnchor.constraint(equalTo: topAnchor, constant: edgeInsets.top),
other.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -edgeInsets.right),
other.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -edgeInsets.bottom)
])
}
}

func addSubviewToCenter(_ other: UIView, width: CGFloat, height: CGFloat) {
addSubview(other, constraints: [
other.centerYAnchor.constraint(equalTo: centerYAnchor),
other.centerXAnchor.constraint(equalTo: centerXAnchor),
other.heightAnchor.constraint(equalToConstant: height),
other.widthAnchor.constraint(equalToConstant: width)
])
}

func rounded() {
rounded(min(bounds.width, bounds.height) / 2)
}

func rounded(_ radius: CGFloat) {
layer.cornerRadius = radius
layer.masksToBounds = true
}
}

Essentially all of these methods contain the logic for the UIKit layout. Now let’s update our Package.swift.

import PackageDescription

let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),
.library(name: "Home", targets: ["Home"]), // <---

// MARK: - UI
.library(name: "Resources", targets: ["Resources"]),
.library(name: "Views", targets: ["Views"]),

// MARK: - Utility
.library(name: "Navigation", targets: ["Navigation"]),
.library(name: "Entities", targets: ["Entities"]),
.library(name: "Base", targets: ["Base"]),
.library(name: "Extensions", targets: ["Extensions"]) // <---
],
dependencies: [
.package(
url: "https://github.com/onevcat/Kingfisher",
.upToNextMajor(from: "7.9.1")
)
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base")
],
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")]
),
.target(
name: "Home",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base"),
.target(name: "Extensions"), // <---
// Third Party
.byName(name: "Kingfisher")
],
path: "Sources/FeatureModules/Home",
resources: [.process("Resources/Process")]
),

// MARK: - Utility
.target(name: "Navigation", path: "Sources/UtilityModules/Navigation"),
.target(name: "Entities", path: "Sources/UtilityModules/Entities"),
.target(
name: "Base",
dependencies: [
.target(name: "Entities"),
.target(name: "Views")
],
path: "Sources/UtilityModules/Base"
),
.target(name: "Extensions", path: "Sources/UtilityModules/Extensions"), // <---

// MARK: - UI
.target(
name: "Resources",
path: "Sources/UIModules/Resources",
resources: [.process("Process")]
),
.target(
name: "Views",
dependencies: [
.target(name: "Resources")
],
path: "Sources/UIModules/Views"
)
]
)

Also, we lack navigation logic. Let’s create one.

Navigation

As you remember, for SwiftUI we use NavigationStore, with which we make push new screens. In the UIKit world, there is a wonderful pattern called Coordinator. Let’s implement it.

In the Navigation module, create a Coordinator.swift file, and add the following code to it:

import SwiftUI

public protocol Coordinator: AnyObject { // 1
var navigationController: UINavigationController { get set }

func start()
}

// MARK: -
public extension Coordinator { // 2
func push(_ viewController: UIViewController, animated: Bool = true) {
navigationController.pushViewController(viewController, animated: animated)
}

func pop(animated: Bool = true) {
navigationController.popViewController(animated: animated)
}

func popToRoot(animated: Bool = true) {
navigationController.popToRootViewController(animated: animated)
}
}

// MARK: - UIViewControllerRepresentable
public struct CoordinatorRepresentable: UIViewControllerRepresentable { // 3
// MARK: - Private Properties
private let coordinator: Coordinator

// MARK: - Init
public init(_ coordinator: Coordinator) {
self.coordinator = coordinator
}

// MARK: - Public Methods
public func makeUIViewController(context: Context) -> UINavigationController {
coordinator.start() // 3

return coordinator.navigationController
}

public func updateUIViewController(
_ uiViewController: UINavigationController,
context: Context
) {}
}
  1. Coordinator is a protocol of class that holds a reference to UINavigationController. By calling the start method the root controller is installed in UINavigationController.
  2. Also, like NavigationStore, Coordinator contains methods such as push and pop.
  3. Since our main application contains a SwiftUI Lifecycle, we need a SwiftUI interface to run the feature. To do this, we created CoordinatorRepresentable, which sets the root controller to the coordinator’s UINavigationController and turns it into a SwiftUI View.

Since we will need to display something in the table, let’s create an entity to display.

Entities

In the Entities module, create CharacterData.swift with the following code:

import Foundation

public struct CharacterData: Decodable {
public let gender: String
public let name: String
public let image: URL
public let species: String
public let status: String

public static let placeholder = CharacterData(
gender: "Male",
name: "Rick Sanchez",
image: URL(string: "https://rickandmortyapi.com/api/character/avatar/1.jpeg")!,
species: "Human",
status: "Alive"
)
}

This entity is a Decodable object that we are going to receive from the server and display in the list.

The data you can see in the demo app and the image link in the placeholder are taken from Rick and Morty API. This is a free and cool API that you can play with in your spare time.

Now we can switch to our Feature module.

Home Feature

Let’s first create the logic with Dependency.

Create a new file at the path:
FeatureModules/Home/Presentation/Home/HomeDependencies.swift

import Foundation
import Combine
import Entities

public struct HomeDependencies {
// MARK: - Internal Properties
var loadCharactersAction: () -> AnyPublisher<[CharacterData], NetworkError>

// MARK: - Init
public init(
loadCharactersAction: @escaping () -> AnyPublisher<[CharacterData], NetworkError>
) {
self.loadCharactersAction = loadCharactersAction
}
}

If you have read the previous article, you can see that the logic with dependencies is not different from the previous Authentication module. We create a structure that stores the closure with the business logic we need. In the case of the Home screen, this is simply loading character data or an error.

Now in the same folder, create HomeModel.

import Foundation
import Combine
import Entities

protocol HomeModel {
func getCharacters() -> AnyPublisher<[CharacterData], NetworkError>
}

final class HomeModelImpl {
// MARK: - Private Properties
private let dependecies: HomeDependencies

// MARK: - Init
init(dependecies: HomeDependencies) {
self.dependecies = dependecies
}
}

// MARK: - HomeModel
extension HomeModelImpl: HomeModel {
func getCharacters() -> AnyPublisher<[CharacterData], NetworkError> {
dependecies.loadCharactersAction()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

Also, like last time, our model simply interacts with the Dependencies object.

Now create a HomeViewModel in the same folder.

import Combine
import Foundation
import Base
import Entities

protocol HomeViewModel: ViewModel { // 1
var characters: AnyPublisher<[CharacterData], Never> { get }

func loadCharacters()
}

final class HomeViewModelImpl: BaseViewModel, HomeViewModel { // 1
// MARK: - Internal Properties
lazy var characters = charactersSubject.eraseToAnyPublisher()

// MARK: - Private Properties
private let charactersSubject = CurrentValueSubject<[CharacterData], Never>([])
private let model: HomeModel

// MARK: - Init
init(_ model: HomeModel) {
self.model = model
super.init()
}
}

// MARK: - Internal Methods
extension HomeViewModelImpl {
func loadCharacters() {
renderingState.render(.loading) // 2

model.getCharacters()
.sink { [weak self] completion in
guard case let .failure(error) = completion else {
self?.renderingState.render(.view) // 2
return
}

self?.renderingState.render(.error(error)) // 2

} receiveValue: { [weak self] response in
self?.charactersSubject.value = response
}
.store(in: &subscriptions)
}
}
  1. Our ViewModel contains a Combine Publisher with the CharacterData array that our UITableView should display, as well as a loadCharacters method that will run the load process. Also, note that in both the SwiftUI module and here, we use the ViewModel protocol and BaseViewModel. That is, there are no differences in ViewModel depending on the UI framework.
  2. Just like in the last feature module, due to our BaseViewModel base class, we can change the state of the View. And we don’t care which UI Framework is used, SwiftUI or UIKit. In my opinion, we did a great job :)

Now we can switch to our View. Let’s start with the subview. We need a UITableViewCell that we will display to the user. Create a new file at the following path:
Home/Presentation/Home/View/CharacterTVC.swift

Since our View, which is part of MVVM, will consist of a subview, UIView, and UIViewController — we will store them in this folder.

Add the following code to CharacterTVC.swift:

import UIKit
import Kingfisher
import Entities
import Extensions

final class CharacterTVC: UITableViewCell {
// MARK: - Private Properties
private let genderLabel = UILabel()
private let nameLabel = UILabel()
private let speciesLabel = UILabel()
private let statusLabel = UILabel()
private let characterImage = UIImageView()

// MARK: - Init
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
}

// MARK: - Internal Methods
extension CharacterTVC {
func setup(_ model: CharacterData) {
characterImage.kf.setImage(with: model.image)
statusLabel.text = model.status
nameLabel.text = model.name
genderLabel.text = "\(Localization.Gender.prefix) \(model.gender)"
speciesLabel.text = "\(Localization.Species.prefix) \(model.species)"
}
}

// MARK: - Private Methods
private extension CharacterTVC {
func commonInit() {
let labelsStack = UIStackView()
labelsStack.setup(axis: .vertical, spacing: C.LabelsStack.spacing)

statusLabel.font = UIFont.systemFont(ofSize: C.LabelsStack.statusFontSize, weight: .medium)
statusLabel.textColor = .lightGray

nameLabel.font = UIFont.systemFont(ofSize: C.LabelsStack.nameFontSize, weight: .bold)

speciesLabel.font = UIFont.systemFont(ofSize: C.LabelsStack.speciesFontSize)
speciesLabel.textColor = .gray

genderLabel.font = UIFont.systemFont(ofSize: C.LabelsStack.genderFontSize)
genderLabel.textColor = .gray


labelsStack.addArranged(statusLabel)
labelsStack.addArranged(nameLabel)
labelsStack.addArranged(speciesLabel)
labelsStack.addArranged(genderLabel)

let mainStack = UIStackView()
mainStack.setup(axis: .horizontal, spacing: C.MainStack.spacing)

let imageContainer = UIView(
frame: CGRect(
origin: .zero,
size: CGSize(
width: C.MainStack.imageSize,
height: C.MainStack.imageSize
)
)
)

imageContainer.backgroundColor = .clear
imageContainer.rounded()
imageContainer.addSubviewToCenter(
characterImage,
width: C.MainStack.imageSize,
height: C.MainStack.imageSize
)

mainStack.addArranged(imageContainer, size: C.MainStack.imageSize)
mainStack.addArranged(labelsStack)

addSubview(
mainStack,
withEdgeInsets: C.insets,
safeArea: false
)
}
}

// MARK: - Static Properties
private enum C {
static let insets = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)

enum LabelsStack {
static let spacing = 5.0
static let statusFontSize = 12.0
static let nameFontSize = 21.0
static let speciesFontSize = 16.0
static let genderFontSize = 16.0
}

enum MainStack {
static let spacing = 20.0
static let imageSize = 100.0
}
}

In this cell, we make the UI using our new extensions. We also created a setup method that takes the CharacterData we created earlier and sets the necessary data to the cell. As you can see, this is where we use Kingfisher to upload the image.

Now create HomeView.swift in the same folder.

import UIKit
import Extensions
import Entities

final class HomeView: UIView {
// MARK: - Private Properties
private let tableView = UITableView()
private var characters = [CharacterData]()

// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

// MARK: - Internal Methods
extension HomeView {
func show(characters: [CharacterData]) {
self.characters = characters
self.tableView.reloadData()
}
}

// MARK: - Private Methods
private extension HomeView {
func commonInit() {
setupLayout()
setupUI()
}

func setupUI() {
backgroundColor = .white
tableView.dataSource = self
tableView.delegate = self
tableView.backgroundColor = .white
tableView.register(
CharacterTVC.self,
forCellReuseIdentifier: C.cellReuseIdentifier
)
}

func setupLayout() {
addSubview(tableView, withEdgeInsets: .zero, safeArea: true)
}
}

// MARK: - UITableViewDataSource
extension HomeView: UITableViewDataSource, UITableViewDelegate {
func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
return characters.count
}

func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: C.cellReuseIdentifier,
for: indexPath
) as! CharacterTVC

let model = characters[indexPath.row]
cell.setup(model)

return cell
}
}

// MARK: - Static Properties
private struct C {
static let cellReuseIdentifier: String = "UITableViewCell"
}

In this UIView, we create and configure our UITableView. We only have one internal method, show(characters:), which we are going to use to update our table.

Now we can create our UIViewController. Create HomeViewController.swift in the same place.

import UIKit
import SwiftUI
import Base

final class HomeViewController<VM: HomeViewModel>: BaseViewController<VM> { // 1
// MARK: - Private Properties
private let contentView = HomeView()

// MARK: - Lifecycle
override func loadView() {
super.loadView()
view = contentView
}

override func viewDidLoad() {
super.viewDidLoad()
binding() // 2
title = Localization.Home.title
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.loadCharacters() // 2
}
}

// MARK: - Private Methods
private extension HomeViewController {
func binding() {
viewModel.characters
.sink { [weak self] characters in
self?.contentView.show(characters: characters) // 2
}
.store(in: &subscriptions)
}
}
  1. As you remember, we created a BaseViewController which contains all the code to render the state of our View, such as loading, alert, error, and view. We did this to avoid copying the code related to this in every future controller. Since BaseViewController takes in a generic ViewModel, we pass it in from HomeViewController.
  2. We create a subscription to characters from the ViewModel, then request their download, and when ready, update our UIView.

At this point our MVVM module is ready. Although we only have one screen in this feature, we will prepare it for expansion, including navigation. As we did last time, let’s create a ModuleFactory that will create each of our screens. Create a file at the following path:
FeatureModules/Home/Core/HomeModuleFactory.swift

import Foundation
import UIKit

public final class HomeModuleFactory {
// MARK: - Dependencies
public struct Dependencies {
let homeDependencies: HomeDependencies

public init(
_ home: HomeDependencies
) {
homeDependencies = home
}
}

// MARK: - Private Properties
private let dependencies: Dependencies
private weak var coordinator: HomeCoordinator? // <---

// MARK: - Init
init(_ dependencies: Dependencies, _ coordinator: HomeCoordinator) {
self.dependencies = dependencies
self.coordinator = coordinator
}
}

// MARK: - Internal Methods
extension HomeModuleFactory {
func getHome() -> UIViewController {
HomeViewController(
viewModel: HomeViewModelImpl(
HomeModelImpl(dependecies: dependencies.homeDependencies)
)
)
}
}

As you might have noticed, it’s very similar to our factory implementation for the Authentication feature with the only difference — instead of NavigationStore, we pass HomeCoordinator to the initializer. Also, it’s worth noting that the reference to the coordinator should be weak since the coordinator will be aware of HomeModuleFactory.

At this point, we have an error because we have not implemented HomeCoordinator yet. Let’s do that — create HomeCoordinator.swift next to it.

import Navigation
import UIKit

final class HomeCoordinator: Coordinator {
// MARK: - Internal Properties
var navigationController: UINavigationController
var factory: HomeModuleFactory! // 1

// MARK: - Init
init(_ navigationController: UINavigationController) {
self.navigationController = navigationController
}
}

// MARK: - Internal Properties
extension HomeCoordinator {
func start() { // 2
let vc = factory.getHome()
navigationController.setViewControllers([vc], animated: false)
}
}
  1. As I said before, our Coordinator will hold a reference to HomeModuleFactory. This is so that you can create the screens you want inside the coordinator. This is one of the few differences in modules between SwiftUI and UIKit.
  2. As you can see, HomeCoordinator contains only one method — start, which sets HomeScreen as root. If you have more screens, you can add a corresponding method to the coordinator, like “pushCharacterDetails” and call it from the desired ViewModel.

The only thing this feature lacks is an entry point into it. Let’s create HomeRoot.swift in the same folder.

import SwiftUI
import Navigation

public struct HomeRoot: View {
// MARK: - Private Properties
private let coordinator: Coordinator

// MARK: - Init
public init(dependencies: HomeModuleFactory.Dependencies) { // 1
let coordinator = HomeCoordinator(.init())
let factory = HomeModuleFactory(dependencies, coordinator)
coordinator.factory = factory // 2

self.coordinator = coordinator
}

// MARK: - Body
public var body: some View {
CoordinatorRepresentable(coordinator) // 3
}
}
  1. Like last time, we created the entry point to the module as a SwiftUI View, and passed all dependencies to init.
  2. Inside init we create a coordinator, factory with the created coordinator, then set the coordinator’s factory.
  3. As you may recall, we created CoordinatorRepresentable to turn Coordinator into a SwiftUI View. We use it in the body.

At this point our feature module is ready, you can create a Microapp for it just like we did last time and then you can publish this feature in TestFlight separately from the whole application. Congratulations!

There is not much left for us to complete this application — Foundation modules with the business logic we are going to use, and setting up the main application that will use the modules we have created. Let’s start with the Foundation modules.

Foundation Modules

Storage

Create a file at the following path:
Sources/FoundationModules/Storage/UserService/UserService.swift

Let’s update our Package.swift.

import PackageDescription

let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),
.library(name: "Home", targets: ["Home"]),

// MARK: - UI
.library(name: "Resources", targets: ["Resources"]),
.library(name: "Views", targets: ["Views"]),

// MARK: - Foundation
.library(name: "Storage", targets: ["Storage"]), // <---

// MARK: - Utility
.library(name: "Navigation", targets: ["Navigation"]),
.library(name: "Entities", targets: ["Entities"]),
.library(name: "Base", targets: ["Base"]),
.library(name: "Extensions", targets: ["Extensions"])
],
dependencies: [
.package(
url: "https://github.com/onevcat/Kingfisher",
.upToNextMajor(from: "7.9.1")
)
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base")
],
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")]
),
.target(
name: "Home",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base"),
.target(name: "Extensions"),
// Third Party
.byName(name: "Kingfisher")
],
path: "Sources/FeatureModules/Home",
resources: [.process("Resources/Process")]
),

// MARK: - Utility
.target(name: "Navigation", path: "Sources/UtilityModules/Navigation"),
.target(name: "Entities", path: "Sources/UtilityModules/Entities"),
.target(
name: "Base",
dependencies: [
.target(name: "Entities"),
.target(name: "Views")
],
path: "Sources/UtilityModules/Base"
),
.target(name: "Extensions", path: "Sources/UtilityModules/Extensions"),

// MARK: - Foundation
.target( // <---
name: "Storage",
dependencies: [
.target(name: "Entities")
],
path: "Sources/FoundationModules/Storage"
),

// MARK: - UI
.target(
name: "Resources",
path: "Sources/UIModules/Resources",
resources: [.process("Process")]
),
.target(
name: "Views",
dependencies: [
.target(name: "Resources")
],
path: "Sources/UIModules/Views"
)
]
)

Add the following code to it:

import Foundation
import Entities

public protocol UserService: AnyObject {
var isAuthorized: Bool { get }
var token: String? { get }
var refreshToken: String? { get }

func save(_ model: UserAuthData)
func clear()
}

This service will encapsulate working with Keychain, where we will store our user’s access and refresh token. As you can see, this module depends on Entities to be able to reach UserAuthData.

Now let’s add the implementation of this service. In our case, it will be a fake service, but you can see the implementation of the real one in the demo app. Create UserServiceImpl.swift in the same folder.

import Foundation
import Entities

final public class UserServiceImpl: UserService {
// MARK: - Public Properties
public var isAuthorized: Bool {
keychain[Key.token.rawValue] != nil
}

public var token: String? {
keychain[Key.token.rawValue]
}

public var refreshToken: String? {
keychain[Key.refreshToken.rawValue]
}

// MARK: - Private Properties
private var keychain = [String: String]()

// MARK: - Init
public init() {}
}

// MARK: - Public Methods
public extension UserServiceImpl {
func save(_ model: UserAuthData) {
keychain[Key.token.rawValue] = model.idToken
keychain[Key.refreshToken.rawValue] = model.refreshToken
}

func clear() {
Key.allCases.forEach { keychain[$0.rawValue] = nil }
}
}

// MARK: - Keys
private extension UserServiceImpl {
enum Key: String, CaseIterable {
case token = "secure_token_key"
case refreshToken = "secure_refresh_token_key"
}
}

In this service, we store our tokens in a dictionary called keychain. The implementation is very simple, so I will not explain it.

Well, we can store tokens, but we still don’t have a way to make requests to the network. Let’s fix that!

Networking

In the FoundationModules folder, create
Networking/AuthService/AuthService.swift

Let’s update our Package.swift.

import PackageDescription

let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),
.library(name: "Home", targets: ["Home"]),

// MARK: - UI
.library(name: "Resources", targets: ["Resources"]),
.library(name: "Views", targets: ["Views"]),

// MARK: - Foundation
.library(name: "Networking", targets: ["Networking"]), // <---
.library(name: "Storage", targets: ["Storage"]),

// MARK: - Utility
.library(name: "Navigation", targets: ["Navigation"]),
.library(name: "Entities", targets: ["Entities"]),
.library(name: "Base", targets: ["Base"]),
.library(name: "Extensions", targets: ["Extensions"])
],
dependencies: [
.package(
url: "https://github.com/onevcat/Kingfisher",
.upToNextMajor(from: "7.9.1")
)
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base")
],
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")]
),
.target(
name: "Home",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base"),
.target(name: "Extensions"),
// Third Party
.byName(name: "Kingfisher")
],
path: "Sources/FeatureModules/Home",
resources: [.process("Resources/Process")]
),

// MARK: - Utility
.target(name: "Navigation", path: "Sources/UtilityModules/Navigation"),
.target(name: "Entities", path: "Sources/UtilityModules/Entities"),
.target(
name: "Base",
dependencies: [
.target(name: "Entities"),
.target(name: "Views")
],
path: "Sources/UtilityModules/Base"
),
.target(name: "Extensions", path: "Sources/UtilityModules/Extensions"),

// MARK: - Foundation
.target( // <---
name: "Networking",
dependencies: [
.target(name: "Entities"),
.target(name: "Storage"),
],
path: "Sources/FoundationModules/Networking"
),
.target(
name: "Storage",
dependencies: [
.target(name: "Entities")
],
path: "Sources/FoundationModules/Storage"
),

// MARK: - UI
.target(
name: "Resources",
path: "Sources/UIModules/Resources",
resources: [.process("Process")]
),
.target(
name: "Views",
dependencies: [
.target(name: "Resources")
],
path: "Sources/UIModules/Views"
)
]
)

Add the following code to it:

import Combine
import Entities

public protocol AuthService {
func login(
email: String, password: String
) -> AnyPublisher<UserAuthData, NetworkError>

func registration(
email: String, password: String
) -> AnyPublisher<UserAuthData, NetworkError>
}

With this service, we will authenticate the user. Each of the methods can return a user object or a NetworkError for us.

Now we need to create a UserAuthData protocol structure that we can return from these methods. Next to it, create a Codable folder and UserData.swift in it.

import Foundation
import Entities

struct UserData: Decodable, UserAuthData {
public let idToken: String
public let refreshToken: String
}

You can now create AuthServiceImpl.swift next to AuthService.swift.

import Foundation
import Combine
import Entities

public struct AuthServiceImpl: AuthService {
// MARK: - Private Properties
private let baseURL: URL // 1

// MARK: - Init
public init(baseURL: URL) { // 1
self.baseURL = baseURL
}
}

// MARK: - Public Methods
public extension AuthServiceImpl {
func login(
email: String, password: String
) -> AnyPublisher<UserAuthData, NetworkError> {
print("Do some sign in request to \(baseURL)")

return Just( // 2
UserData(idToken: "idTokenStub", refreshToken: "refreshTokenStub")
)
.setFailureType(to: NetworkError.self)
.eraseToAnyPublisher()
}

func registration(
email: String, password: String
) -> AnyPublisher<UserAuthData, NetworkError> {
print("Do some sign up request to \(baseURL)")

return Just( // 2
UserData(idToken: "idTokenStub", refreshToken: "refreshTokenStub")
)
.setFailureType(to: NetworkError.self)
.eraseToAnyPublisher()
}
}
  1. Since in most of your projects, you will have to work with multiple environments and each environment may contain a different base URL, we pass it through the initializer.
  2. To keep this article simple, we won’t make any real requests, so we just send hardcoded UserData from these methods.

Next, we need to create a service to request our characters. Let’s make one!

In the Networking folder, create:
CharactersService/CharactersService.swift

import Combine
import Entities

public protocol CharactersService {
func getCharacters() -> AnyPublisher<[CharacterData], NetworkError>
}

This service will return an array of CharacterData or NetworkError for us. Create CharactersServiceImpl.swift next to.

import Combine
import Foundation
import Entities
import Storage

public struct CharactersServiceImpl: CharactersService {
// MARK: - Private Properties
private let baseURL: URL
private let userService: UserService

// MARK: - Init
public init(_ baseURL: URL, _ userService: UserService) { // 1
self.baseURL = baseURL
self.userService = userService
}
}

// MARK: - Public Methods
public extension CharactersServiceImpl {
func getCharacters() -> AnyPublisher<[CharacterData], NetworkError> { // 2
guard let token = userService.token else {
return Fail(error: .init("Token is nil"))
.eraseToAnyPublisher()
}

print("Do some characters request to \(baseURL) with token: \(token)")

return Just([.placeholder, .placeholder])
.setFailureType(to: NetworkError.self)
.eraseToAnyPublisher()
}
}
  1. Just like last time, we passed the base URL to init. In addition to it, we pass our UserService, since your future request will use the token to execute the request.
  2. Like last time, we won’t write the actual request logic. We just check that the token exists, if yes — we return an array from CharacterData.placeholder, if not — we return an error.

Do you think that’s it? Not quite :)

If you have read the previous articles (and you have, I believe you have), then you are already familiar with Dependency Injection. In this pattern, there is a concept of container. In simple words, it is a class that will be responsible for the creation, storage, and lifecycle of all our services. Let’s create it!

DI Container & Foundation Module Environments

Before we start, let’s remember what we need for our Network services. That’s right — base URL. We should not store it in this module, it should be passed from the application and depend on the environment. Let’s create interfaces for this!

Create a new file at the following path:
FoundationModules/AppContainer/AppConfiguration.swift

Now let’s add our new module to Package.swift.

let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),
.library(name: "Home", targets: ["Home"]),

// MARK: - UI
.library(name: "Resources", targets: ["Resources"]),
.library(name: "Views", targets: ["Views"]),

// MARK: - Foundation
.library(name: "AppContainer", targets: ["AppContainer"]), // <---
.library(name: "Networking", targets: ["Networking"]),
.library(name: "Storage", targets: ["Storage"]),

// MARK: - Utility
.library(name: "Navigation", targets: ["Navigation"]),
.library(name: "Entities", targets: ["Entities"]),
.library(name: "Base", targets: ["Base"]),
.library(name: "Extensions", targets: ["Extensions"])
],
dependencies: [
.package(
url: "https://github.com/onevcat/Kingfisher",
.upToNextMajor(from: "7.9.1")
)
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base")
],
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")]
),
.target(
name: "Home",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base"),
.target(name: "Extensions"),
// Third Party
.byName(name: "Kingfisher")
],
path: "Sources/FeatureModules/Home",
resources: [.process("Resources/Process")]
),

// MARK: - Utility
.target(name: "Navigation", path: "Sources/UtilityModules/Navigation"),
.target(name: "Entities", path: "Sources/UtilityModules/Entities"),
.target(
name: "Base",
dependencies: [
.target(name: "Entities"),
.target(name: "Views")
],
path: "Sources/UtilityModules/Base"
),
.target(name: "Extensions", path: "Sources/UtilityModules/Extensions"),

// MARK: - Foundation
.target( // <---
name: "AppContainer",
dependencies: [
.target(name: "Networking"),
.target(name: "Storage")
],
path: "Sources/FoundationModules/AppContainer"
),
.target(
name: "Networking",
dependencies: [
.target(name: "Entities"),
.target(name: "Storage"),
],
path: "Sources/FoundationModules/Networking"
),
.target(
name: "Storage",
dependencies: [
.target(name: "Entities")
],
path: "Sources/FoundationModules/Storage"
),

// MARK: - UI
.target(
name: "Resources",
path: "Sources/UIModules/Resources",
resources: [.process("Process")]
),
.target(
name: "Views",
dependencies: [
.target(name: "Resources")
],
path: "Sources/UIModules/Views"
)
]
)

Add the following code to AppConfiguratio.swift:

import Foundation

public enum AppEnvironment: String {
case development
case staging
case production
}

public protocol AppConfiguration {
var environment: AppEnvironment { get }
var storageBaseURL: URL { get }
var authBaseURL: URL { get }
}

This interface will contain all possible properties that will depend on the app and environment. In our case, it’s just the environment and two base URLs, but there could be more. For example, in the demo app, you will see more properties like authAPIKey and others.

Now we can move on to the DI container. Create AppContainer.swift in the AppContainer folder.

import Networking
import Storage

public protocol AppContainer: AnyObject {
var userService: UserService { get }
var authService: AuthService { get }
var characterService: CharactersService { get }
}

public final class AppContainerImpl: AppContainer {
// MARK: - Public Properties
lazy public private(set) var userService: UserService = UserServiceImpl()
lazy public private(set) var authService: AuthService = AuthServiceImpl(
baseURL: configuration.authBaseURL
)

lazy public private(set) var characterService: CharactersService = CharactersServiceImpl(
configuration.storageBaseURL,
userService
)

// MARK: - Private Properties
private let configuration: AppConfiguration

// MARK: - Init
public init(_ configuration: AppConfiguration) {
self.configuration = configuration
}
}

As mentioned above, this class will store all of our services. In our case, we just create all services as lazy to postpone the initialization time, but in real applications, it might be useful to manage the lifecycle of each service to be able to free memory when the service is not needed. You can use more complex implementations for this, or you can use off-the-shelf libraries. For example, in the demo app, you can see the implementation of the container using the wonderful library Swinject. They have marvelous documentation that is very easy to read. I use it in almost every project I do and have never regretted it yet.

Well, this concludes the implementation of our modules. Congratulations, you are very well done! You may not understand why we need a container yet, but it will become clearer when we connect all the modules to our main application. Let’s do just that. Close Swift Package and open the project file.

Main App

The work of setting up our application will consist of several steps:

  • Setting up the environment.
  • Creating the dependencies we are going to pass to our feature modules
  • Connecting the feature modules.

Although setting up the environment is a bit beyond the scope of this article, let’s briefly touch on this topic.

Environment

I like to configure the environment using xcode config. I find this approach very visual and easy to use. But it also has some disadvantages, for example, to work with cocoapods you have to do a bit more steps than what we will do in this article.

I am in no way adjusting this setup, if you are used to doing things differently, you can do it your way. But since the article is written by me, I will do it with xcode config.

Well, my app looks like this right now:

In the Application folder, create Configuration/Configurations. Now create the xcconfig file. Let’s create one single environment — production.

Add the following properties to it:

APP_ENVIRONMENT = production
STORAGE_BASE_URL = https:/$()/stubExample.com
AUTH_BASE_URL = https:/$()/stubExample.com

These will be our base URLs. In our case, they are not real, because the implementation is not real either, but you can see the real ones in the demo app. Now open the project file, and add this configuration.

The next step is to create a schema.

Next, we need to add our properties from xcconfig to plist. Update your plist with the following values.

This way we can get the properties from xcconfig in our application. Now create Configuration.swift in the Configuration folder.

import Foundation
import AppContainer

final class Configuration: AppConfiguration {
let environment: AppEnvironment
let storageBaseURL: URL
let authBaseURL: URL

init(bundle: Bundle) {
guard let infoDict = bundle.infoDictionary,
let environmentValue = infoDict[Key.environment] as? String,
let environment = AppEnvironment(rawValue: environmentValue),
let rawStorageBaseURL = infoDict[Key.storageBaseURL] as? String,
let rawAuthBaseURL = infoDict[Key.authBaseURL] as? String,
let storageBaseURL = URL(string: rawStorageBaseURL),
let authBaseURL = URL(string: rawAuthBaseURL) else {
fatalError("Config file error")
}

self.environment = environment
self.storageBaseURL = storageBaseURL
self.authBaseURL = authBaseURL
}
}

// MARK: - Static Properties
private enum Key {
static let environment = "APP_ENVIRONMENT"
static let storageBaseURL = "STORAGE_BASE_URL"
static let authBaseURL = "AUTH_BASE_URL"
}

With this initializer, we pull values from the plist, and store them in this class. At this point, we get an error that the AppContainer cannot be found. This is because we have not imported our modules into the application. Let’s do it!

Go to your target > Frameworks, Libraries, and Embedded Content > + > select AppContainer

The error should now disappear. And so, we have configured the environment.

Dependencies & Feature Modules Connecting

Let’s add our Authentication and Home modules just like we did with AppContainer.

Let’s create a Root folder in the Application folder. In it, we will store our single MVVM module, which will be responsible for switching features in the application.

Create Dependencies.swift in this folder.

import Foundation
import AppContainer
import Authentication
import Home

protocol Dependencies { // 1
var authDependencies: AuthModuleFactory.Dependencies { get }
var homeDependencies: HomeModuleFactory.Dependencies { get }
}

final class DependenciesImpl: Dependencies {
// MARK: - Private Propertes
private let container: AppContainer

// MARK: - Internal Properties
lazy var authDependencies = AuthModuleFactory.Dependencies( // 3
.init(
loginAction: container.authService.login(email:password:),
saveAction: container.userService.save(_:)
),
.init(
registration: container.authService.registration(email:password:),
saveAction: container.userService.save(_:)
)
)

lazy var homeDependencies = HomeModuleFactory.Dependencies( // 3
.init(loadCharactersAction: container.characterService.getCharacters)
)

// MARK: - Init
init() { // 2
let config = Configuration(bundle: .main)

container = AppContainerImpl(config)
}
}
  1. The first thing we do is to create the Dependencies protocol. It will store all the dependencies of each of our features.
  2. In the initializer, we create our Configuration, which stores all the properties we need to pass to the business logic container.
  3. We initialize each of the dependencies. This is where you can see how well the dependencies from Feature come together with the AppContainer. Theoretically, we could do without the AppContainer, but then we would have to write all this logic here.

The next step is to create RootViewModel.swift.

import Foundation

final class RootViewModel: ObservableObject {
// MARK: - Internal Properties
@Published var currentFeature: Feature // 1

let dependencies: Dependencies // 2

// MARK: - Init
init(
dependencies: Dependencies = DependenciesImpl(),
currentFeature: Feature = .auth
) {
self.dependencies = dependencies
self.currentFeature = currentFeature
}
}

// MARK: - Models
extension RootViewModel {
enum Feature { // 1
case auth
case home
}
}
  1. We have created a Feature enum. It stores all possible feature modules that we can switch between. The currentFeature property will be Published in order to redraw the View when our feature changes.
  2. Our RootViewModel creates and stores Dependencies.

Now it’s the turn of RootView.swift.

import SwiftUI
import Authentication
import Home

struct RootView: View {
// MARK: - Internal Properties
@StateObject var viewModel: RootViewModel

// MARK: - Body
var body: some View {
switch viewModel.currentFeature {
case .auth:
AuthRoot(
dependencies: viewModel.dependencies.authDependencies,
signInPassed: viewModel.currentFeature = .home,
signUpPassed: viewModel.currentFeature = .home
)

case .home:
HomeRoot(dependencies: viewModel.dependencies.homeDependencies)
}
}
}

This View is very simple, yet is a major part of our integration. Depending on the current RootViewModel.Feature, we show the required feature by passing the required dependencies to it. Also, we switch the feature by passing the required block to signInPassed and signUpPassed.

Well, the last step is to delete ContentView.swift as we don’t need it anymore, and update your App file.

import SwiftUI

@main
struct MicroappApp: App {
var body: some Scene {
WindowGroup {
RootView(viewModel: .init())
}
}
}

Now try to run your application and you will see the following:

Congratulations, you’ve created your first app with Microapps architecture!

Conclusion

If you have read all the articles and created your app in parallel, then you are great, I think it was worth it!

You saw not only an example of Microapps architecture implementation but also:

  • MVVM for SwiftUI and UIKit.
  • Dependency Injection.
  • Navigation implementation in SwiftUI using NavigationStack, and in UIKit using Coordinator.
  • Setting up environments for the application.

Of course, your architecture within modules, navigation, DI may vary from project to project, but this project will be a good basis for understanding what you need to do and how to modify it.

Also, just a reminder that you can see the real business logic in the demo app. I’d love for you to subscribe to me here and on my GitHub. Thank you!

--

--