IOS Microapps Architecture — Part 2

Artem Kvasnetskyi
17 min readOct 6, 2023

--

This is the second part of a series of articles on Microapps architecture. If you missed the first part — you can read it here. It will give you an overview of what modular architecture is, why you need it, and how to implement it in iOS.

In this part, we will focus on what Microapps Architecture is and how to implement it with Swift Package Manager. Also, we will start building our app where we already have one screen, resources, some reusable UI elements, and a base for navigation.

Part three is now available to read!

Well, let’s go. We looked at ways to implement modularity using Framework, Git Submodule, and Subproject. Let’s start this article with how to implement modularity with Swift Package.

Modularity with Swift Package

All you need to do is to create a local package and connect it to the project. To create a package you need to select:

File New Package Add Package into project → Select the folder where your package will be stored

Once you have created a module, you can import it into your project. In order to do this, just select:

Target General Frameworks, Libraries, and Embedded Content Add button Select Module

Now you can import this module into your project and use it. Congratulations!

I think with our knowledge of modularity and how to implement it, we can finally move on to the main topic — Microapps architecture.

Jeez finally he decided to get to the main topic

Jeez finally he decided to get to the main topic

Microapps Architecture

Microapps architecture is one approach to modularity. This approach differs from the others in that it uses applications for specific modules, called micro-applications. They are used as a tool for fast development and testing.

As well as in the case of Modular Architecture, this is an abstract pattern. It does not work like MVC or MVVM. Within each module, you can use the desired architecture, which will vary depending on the specific functions of the application. For example, you may have modules written in UIKit with MVP architecture, and SwiftUI modules written in MVVM.

Conventionally, the modules in this architecture can be divided into several layers.

  • Utility
    Contains logic that will hardly ever be changed. The most common ones are extensions, helpers, formatters, protocols, base classes, and more.
  • Foundation
    Contains business and low-level logic. This includes networks, local databases, and other managers/services.
  • UI
    Contains reusable UI and resources. This may include View, ViewControllers, Assets, Localizations, etc.
  • Feature
    Contains the features of the application. It also contains the UI and resources that will not be reused. Each feature module can have one or more specialized micro-applications that the teams work on to get quick feedback on the development and testing changes.

Pros and cons

This architecture contains all the same pros as the modular architecture, but beyond that:

  • Clear layers
    It becomes clear which module is responsible for which logic.
  • Easy Scalability
  • Microapps for release
    Possibility to release features separately from the application for further testing.

But nothing is perfect, so we also have some cons:

  • More code
    In this architecture, you will have to write more code. Some of the code will be boilerplate, so it is important to use different tools to automate some of the work.
  • Difficulty in understanding each individual module
    For a newcomer to a project, it can be hard to understand what modules are already in the project, what they are responsible for, what needs to be done, and what has already been done. This is why it is important to write at least a brief documentation for each module in Microapps architecture.

Well, we’ve gone over the theory, it’s time to see how it works in practice.

Microapps Architecture with Swift Package

When starting a Microapps architecture implementation with Swift Package, we need to decide how we will store our modules. For example, you can store all Utility modules in one package and Foundation in another, or you can store all modules in one package.

The difficulty with the first approach is that if you need some module from Foundation in a Utility module, while another Utility module needs some logic from Foundation module, you will get a circular dependency error.

Theoretically, the Utility, Foundation, and UI modules could be put in a separate package from the Feature package, but I don’t see the point. That’s why I keep all modules in one Swift Package, but you can implement it differently if you see the point.

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 (This part)
  • Sign Up Screen (Part 3)
  • Sign In Screen (Part 3)
  • Home Screen (Part 4)

In this part, we will start building our application and create the Auth Select Screen.

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, create a project and a local Swift Package as described above, then let’s start by creating the first screen — Auth Select.

AuthSelect Screen

In the Sources folder, create a FeatureModules folder and Authentication in it. Also, you can delete the default folders.

Sources — stores all Swift Package resources.
FeatureModules — this will contain all feature modules.
Authentication — this will be our first feature module.

Project structure

As you may have noticed, our Package contains errors. Let’s fix them. To do this, go to the Package.swift file, and make the following changes:

let package = Package(
name: "Modules",
platforms: [.iOS(.v16)], // 1
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]), // 2
],
targets: [
// MARK: - Feature
.target(
name: "Authentication", // 3
path: "Sources/FeatureModules/Authentication" // 4
)
]
)
  1. Platforms that this package supports. In our case, it will be IOS 16.
  2. The products of our package. You can think of products as project files that contain targeting. In our case, it is Authentication, which contains the Authentication target.
  3. Target that will be used by the Authentication product. In fact, each target will be a module.
  4. In this target, we need to specify the path to the content. Otherwise, Swift Package will treat FeatureModules as a single module instead of Authentication.

When using the Microapps architecture, more often than not you will open your package instead of your project file. So let’s do it that way.

Open your package, in the Authentication folder, create a Presentation folder where we will store our MVVM modules. In its turn, create the AuthSelect folder for the first screen, and create the AuthSelectView SwiftUI view file.

The code for your View at this point will look like this:

import SwiftUI

struct AuthSelectView: View {
// MARK: - Body
var body: some View {
VStack {
Spacer()
image()
Spacer()
buttons()
}
.padding(.horizontal)
}
}

// MARK: - Private Methods
private extension AuthSelectView {
@ViewBuilder
func image() -> some View {
Some image needed
.resizable()
.scaledToFit()
}

@ViewBuilder
func buttons() -> some View {
Button(Localization needed) {
// Do smth
}

Button(Localization needed) {
// Do smth
}
}
}

// MARK: - Preview Provider
struct AuthSelectView_Previews: PreviewProvider {
static var previews: some View {
AuthSelectView()
}
}

As we can see, we lack resources — pictures, and localizations. Fortunately, we can add them.

Resources

As I mentioned earlier, resources that belong to a specific Feature and are not reused anywhere can be stored directly in the Feature module. If the resource can be overused — we should put it in a separate UI layer module.

Let’s start with the resources that will only be used in the Authentication feature.

Feature Resources

In the Authentication folder, create a Resources folder, and in that folder create a Process folder. In this folder, create an xcassets file called Images.

Also, create a folder Localizations in the Process folder, in it, we will store our localizations. For a package, it is important to understand what language the strings file belongs to. To do this, you need to name the folders according to the language you want to use. In our case, it is only English.

In your Images add an image called app_image (if you are the creator of this image — email me and I will credit you as the author). In your Localizable file, paste the following content:

// MARK: - Reusable
"Credentials.Email.Title" = "Email";
"Credentials.Password.Title" = "Password";
"Credentials.Password.Confirm.Title" = "Confirm Password";

// MARK: - Sign In
"Sign.In.Title" = "Sign In";

// MARK: - Sign Up
"Sign.Up.Title" = "Sign Up";

Now we need to update our Package.swift a bit.

import PackageDescription

let package = Package(
name: "Modules",
defaultLocalization: "en", // 1
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")] // 2
)
]
)
  1. To use localizations in Package we need to specify the defaultLocalization parameter. In our case it is English.
  2. To work with resources in your target, you need to specify a file or path to your resources. This will create a Bundle.module through which you can retrieve your resources in the future.

In addition to .process, there is also .copy. Simply put, the process is suitable for most of the resources you will be working with and is the best way to work with resources. Copy is not as optimized as process, but can be used with types that process does not support. You can find more detailed information at the link.

You can now use your resources in the package!

// ...
// MARK: - Private Methods
private extension AuthSelectView {
@ViewBuilder
func image() -> some View {
Image("app_image", bundle: .module)
.resizable()
.scaledToFit()
}

@ViewBuilder
func buttons() -> some View {
Button {
// Do smth
} label: {
Text("Sign.In.Title", bundle: .module)
}
Button {
// Do smth
} label: {
Text("Sign.Up.Title", bundle: .module)
}
}
}
// ...

But we can go further, and start using Swiftgen, which will turn our resources into enum automatically. As I mentioned earlier, in this architecture it is important to automate work and this tool is great for us to simplify the work with resources. I won’t go into the details of how Swiftgen works — you can read the documentation.

I created a Generated folder and swiftgen file in Resources.

xcassets:
- inputs: Process/Images.xcassets
outputs:
templateName: swift5
output: Generated/Assets.swift
params:
enumName: Assets

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

After that, swiftgen created Assets and Localisation enums for me, which I can use as follows:

// ...
// MARK: - Private Methods
private extension AuthSelectView {
@ViewBuilder
func image() -> some View {
Assets.appImage.swiftUIImage
.resizable()
.scaledToFit()
}

@ViewBuilder
func buttons() -> some View {
Button(Localization.Sign.In.title) {
// Do smth
}

Button(Localization.Sign.Up.title) {
// Do smth
}
}
}
// ...

This approach may not be needed in Xcode 15, but I haven’t had a chance to test this yet.

Well, the next step wouldn’t be a bad idea to create some sort of design system. For example, use some kind of font and colors that can be reused throughout the project.

As a rule, colors, images, and localizations that can be used between modules are stored in the UI layer. Let’s create one!

UI Modules — Reusable resources

Create the following folders in the Sources folder:

  • Fonts
    In the project we will use the Montserrat font, you need to download it and place it in the Fonts folder.
  • Localizations
    In this localization file, we will store localization that can be reused. Create a strings file in the en.lproj folder, and add the following contents to it for the future.
// MARK: - General
"Error" = "Error";
"Ok" = "OK";
"Done" = "Done";
"Cancel" = "Cancel";
  • Colors
    Create Colors.xcassets in the Process folder. You can download the colors here.

Like last time, I’ll be using Swiftgen for resources, you can skip this if it’s easier for you. If not, here is the configuration:

xcassets:
inputs: Microapp/Modules/Sources/UIModules/Resources/Process/Colors.xcassets
outputs:
templateName: swift5
output: Microapp/Modules/Sources/UIModules/Resources/Generated/Colors.swift
params:
enumName: Colors
publicAccess: 1

strings:
inputs: Microapp/Modules/Sources/UIModules/Resources/Process/Localizations/en.lproj
outputs:
templateName: structured-swift5
output: Microapp/Modules/Sources/UIModules/Resources/Generated/ReusableLocalization.swift
params:
enumName: ReusableLocalization
publicAccess: 1

fonts:
inputs: Microapp/Modules/Sources/UIModules/Resources/Process/Fonts
outputs:
templateName: swift5
output: Microapp/Modules/Sources/UIModules/Resources/Generated/Fonts.swift
params:
publicAccess: 1

You can put it wherever you like, but since this configuration is a reusable resource, I put it in a folder along with the project file.

When all of these steps are completed, you should have something like this.

The next step is to edit our Package.swift.

let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),
// MARK: - UI
.library(name: "Resources", targets: ["Resources"]) // 1
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
dependencies: [ // 3
// UI
.target(name: "Resources")
],
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")]
),
// MARK: - UI
.target( // 2
name: "Resources",
path: "Sources/UIModules/Resources",
resources: [.process("Process")]
)
]
)
  1. Add a new product called Resources.
  2. Add a new target called Resources.
  3. Add Resources as a dependency in Authentication for later use.

UI Modules — Reusable UI elements

Also, it would be nice to have your own style for buttons, for this purpose, we will create another UI module called Views.

This module will be dependent on Resource module, as we need the colors we will be using. Edit your Package.swift file as follows:

let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),
// MARK: - UI
.library(name: "Resources", targets: ["Resources"]),
.library(name: "Views", targets: ["Views"]) // <---
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views") // <---
],
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")]
),
// MARK: - UI
.target(
name: "Resources",
path: "Sources/UIModules/Resources",
resources: [.process("Process")]
),
.target( // <---
name: "Views",
dependencies: [
.target(name: "Resources")
],
path: "Sources/UIModules/Views"
)
]
)

I won’t explain what we did, as we’ve already done a similar thing above. In Views folder, create AppButtonStyle.swift.

import SwiftUI
import Resources

public struct AppButtonStyle: ButtonStyle {
// MARK: - Internal Properties
@Environment(\.isEnabled) var isEnabled

// MARK: - Private Properties
let fontSize: CGFloat

// MARK: - Init
public init(fontSize: CGFloat = 16) {
self.fontSize = fontSize
}

// MARK: - Body
public func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(
FontFamily.Montserrat.medium.swiftUIFont(
fixedSize: fontSize
)
)
.padding()
.frame(maxWidth: .infinity)
.foregroundColor(
isEnabled ? Colors.neutral100.swiftUIColor : Colors.neutral80.swiftUIColor
)
.background {
Capsule()
.fill(
isEnabled ? Colors.neutral15.swiftUIColor : Colors.neutral30.swiftUIColor
)
}
}
}

This will be the style of the buttons in our app. As you can see, we can import the Resources module, and use components from it.

Now we can add this button style to our screen.

import SwiftUI
import Views // <---

struct AuthSelectView: View {
// MARK: - Body
var body: some View {
VStack {
Spacer()
image()
Spacer()
buttons()
}
.padding(.horizontal)
}
}

// MARK: - Private Methods
private extension AuthSelectView {
@ViewBuilder
func image() -> some View {
Assets.appImage.swiftUIImage
.resizable()
.scaledToFit()
}

@ViewBuilder
func buttons() -> some View {
Button(Localization.Sign.In.title) {
// Do smth
}
.buttonStyle( // <---
AppButtonStyle()
)

Button(Localization.Sign.Up.title) {
// Do smth
}
.buttonStyle( // <---
AppButtonStyle()
)
}
}

As far as I’m concerned, the screen looks great!

Well, now we can create a ViewModel for this screen. Since there is no business logic here, we will not have a Model. Next to AuthSelectView, create an AuthSelectViewModel.

import Foundation
import Combine

protocol AuthSelectViewModel: ObservableObject {
func signInTapped()
func signUpTapped()
}

final class AuthSelectViewModelImpl: AuthSelectViewModel {

}

// MARK: - Internal Methods
extension AuthSelectViewModelImpl {
func signInTapped() {

}

func signUpTapped() {

}
}

As you’ve already guessed, we need navigation, which we don’t have.

Well, let’s do it.

Navigation

To begin with, we need all possible paths. Create a new file with the following path:
Authentication/Core/Route.swift

To it, we will add enum:

enum Route: Hashable {
case signIn
case signUp
}

It describes all possible routes on the module. In our case these are two screens — Sign In and Sign Up.

The next step is to create an object that will store our Route stack, and encapsulate methods for navigation such as push, and pop. Since this object can be reused in other modules, let’s create it as a separate module.

Create a new module with the following path:
Sources/UtilityModules/Navigation/NavigationStore.swift.

In it, add the following code:

import Foundation

public final class NavigationStore<Route: Hashable>: ObservableObject {
// MARK: - Public Properties
@Published public var route = [Route]() // 1

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

// MARK: - Public Methods
public extension NavigationStore {
func pop() {
route.removeLast() // 2
}

func push(_ route: Route) {
self.route.append(route) // 3
}

func popToRoot() {
route.removeAll() // 4
}
}
  1. The route property will hold all possible Routes. Route is a generic object that must be Hashable. In fact, it will be our Route enum, which we will pass to NavigationStack. If you are not familiar with SwiftUI NavigationStack yet, you can read the documentation.
  2. When the last element is removed from the stack, our screen will be released.
  3. When we add something to the stack, our NavigationStack will see it and add a screen to the navigation.
  4. Removes everything from the stack.

Don’t forget to update our Package.swift

let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),
// MARK: - UI
.library(name: "Resources", targets: ["Resources"]),
.library(name: "Views", targets: ["Views"]),
// MARK: - Utility
.library(name: "Navigation", targets: ["Navigation"]) // <---
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation") // <---
],
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")]
),
// MARK: - Utility
.target(name: "Navigation", path: "Sources/UtilityModules/Navigation"), // <---
// MARK: - UI
.target(
name: "Resources",
path: "Sources/UIModules/Resources",
resources: [.process("Process")]
),
.target(
name: "Views",
dependencies: [
.target(name: "Resources")
],
path: "Sources/UIModules/Views"
)
]
)

Now we can continue with our Authentication module. We can’t be sure that our screen will always be the first, maybe something will change over time. Let’s create an AuthRoot that will always be the starting point of the module.

Create it at the following path Authentication/Core/AuthRoot.swift.

In it, we will add the following code:

import SwiftUI
import Navigation

public struct AuthRoot: View { // 1
// MARK: - Private Properties
@StateObject private var navigation: NavigationStore<Route> // 2

// MARK: - Init
public init() { // 1
let navigation = NavigationStore<Route>() // 2
_navigation = StateObject(wrappedValue: navigation)
}

// MARK: - Body
public var body: some View {
NavigationStack(path: $navigation.route) {

}
}
}
// MARK: - Preview Provider
#Preview {
AuthRoot()
}
  1. Note that this is the first screen with public access and init. This is done so that we can access AuthRoot, and start the module.
  2. We created a NavigationStore object with our Route enum as a StateObject so that NavigationStack can observe changes in it.

Now we need to add our screen to NavigationStack, but as you remember we haven’t finished View and ViewModel yet, let’s finish it. Go back to our AuthSelectViewModel and add the following code to it:

import Foundation
import Combine
import Navigation

protocol AuthSelectViewModel: ObservableObject {
func signInTapped()
func signUpTapped()
}

final class AuthSelectViewModelImpl: AuthSelectViewModel {
// MARK: - Private Properties
private let navigation: NavigationStore<Route> // 1

// MARK: - Init
init(navigation: NavigationStore<Route>) { // 1
self.navigation = navigation
}
}

// MARK: - Internal Methods
extension AuthSelectViewModelImpl {
func signInTapped() {
navigation.push(.signIn) // 2
}

func signUpTapped() {
navigation.push(.signUp) // 2
}
}

// MARK: - Placeholder
extension AuthSelectViewModelImpl {
static let placeholder = AuthSelectViewModelImpl(navigation: .init()) // 3
}
  1. We pass the navigation object we created to our ViewModel for further interaction.
  2. We call the push method from navigation in the corresponding methods.
  3. I like to create static properties for later use in SwiftUI Canvas. You can ignore this if you want.

Now we can move on to View.

import SwiftUI
import Views

struct AuthSelectView<VM: AuthSelectViewModel>: View { // 1
// MARK: - Internal Properties
@StateObject var viewModel: VM // 1

// MARK: - Body
var body: some View {
VStack {
Spacer()
image()
Spacer()
buttons()
}
.padding(.horizontal)
}
}

// MARK: - Private Methods
private extension AuthSelectView {
@ViewBuilder
func image() -> some View {
Assets.appImage.swiftUIImage
.resizable()
.scaledToFit()
}

@ViewBuilder
func buttons() -> some View {
Button(Localization.Sign.In.title) {
viewModel.signInTapped() // 2
}
.buttonStyle(
AppButtonStyle()
)

Button(Localization.Sign.Up.title) {
viewModel.signUpTapped() // 2
}
.buttonStyle(
AppButtonStyle()
)
}
}

// MARK: - Preview Provider
#Preview {
AuthSelectView(viewModel: AuthSelectViewModelImpl.placeholder)
}
  1. In order to work with ViewModel as a protocol we need to add it as a generic, which we do. Why do we need ViewModel as a protocol? You are familiar with the last SOLID principle, and you know that wherever protocols can be used, they should be used. From real practice, there are often cases where View is not changed, but the logic of Model or ViewModel can be changed. By using protocols, you can easily reuse a View with different logic.
  2. We call the required ViewModel methods when the buttons are tapped.

Finally, our screen is complete. Now we can create it. Theoretically, you can do this directly in AuthRoot, but I prefer to put the logic of creating objects in separate entities. Create AuthModuleFactory.swift at the following path:
FeatureModules/Authentication/Core/AuthModuleFactory.swift

His only responsibility will be to create screens:

import Foundation
import SwiftUI
import Navigation

final class AuthModuleFactory {
// MARK: - Private Properties
private let navigation: NavigationStore<Route> // 1

// MARK: - Init
init(_ navigation: NavigationStore<Route>) { // 1
self.navigation = navigation
}
}

// MARK: - Internal Methods
extension AuthModuleFactory {
@ViewBuilder
func getAuthSelect() -> some View {
AuthSelectView(
viewModel: AuthSelectViewModelImpl(
navigation: self.navigation // 2
)
)
}
}
  1. We pass NavigationStore to our factory to initialize our ViewModels with navigation in the future.
  2. We have created a method that creates an AuthSelectView with AuthSelectViewModelImpl.

Now let’s add our factory to AuthRoot.

import SwiftUI
import Navigation

public struct AuthRoot: View {
// MARK: - Private Properties
@StateObject private var navigation: NavigationStore<Route>

private let factory: AuthModuleFactory // 1

// MARK: - Init
public init() {
let navigation = NavigationStore<Route>()
_navigation = StateObject(wrappedValue: navigation)
factory = AuthModuleFactory(navigation) // 1
}

// MARK: - Body
public var body: some View {
NavigationStack(path: $navigation.route) {
factory.getAuthSelect() // 2
.navigationDestination(for: Route.self) { route in // 3
switch route {
case .signIn:
Text("TODO: Sign In Screen")

case .signUp:
Text("TODO: Sign Up Screen")
}
}
}
}
}
// MARK: - Preview Provider
#Preview {
AuthRoot()
}
  1. We create our factory in the initializer by passing navigation to it.
  2. We set AuthSelect, which we create from our factory, as a root View.
  3. We add the navigationDestination modifier to our root View, in which we handle route-related events. At the moment we don’t have the Sign In and Sign Up screens, so I’ve added some text there.

Congratulations, we have completed our screen, navigation, and entry point for the module. You can download the preview and see it working.

Conclusion

In this part, we learned what Microapps architecture is in theory.

In terms of practice, we have learned:

  • How to create new modules in Swift Package.
  • How to work with resources, such as localization, images, and fonts, in Microapps architecture.

Beyond that, we created our first screen and created a good base for future navigation to the future screens.

In the next part, we will continue creating the Sign In and Sign Up screens, finalize the Authentication feature, and create our first Microapp.

I’d love for you to subscribe to me here and on my GitHub. Thank you!

References

Meet the microapps architecture
Microapps architecture in Swift. SPM basics.
Microapps architecture in Swift. Resources and localization.
Microapps architecture in Swift. Feature modules.

--

--