While I was developing a personal Swift application I was having trouble picking a mobile architecture. I've worked with many different architectures in the past, but I wanted to try something new, since this was a personal project anyway. Might as well have some fun. So I decided to create my own architecture.
The architecture takes a lot from the RIBs architecture, with the idea of a builder and a router. Where the builder is responsible for creating the module and router with their respective dependencies. However instead of an interactor I am proposing the idea of a Module instead.
The architecture relies heavily on protocol definitions and generics. This allows for the architecture to be as extensible as possible. Instead of directly referencing a specific type, a protocol definition is used instead.
This will all be open-sourced once I clean up the code a bit more. I will also be creating a sample application.
The whole architecture leans on the idea of a single source of truth for data flow. Components with dependencies are created and passed along to their respective builders while mainting a parent component connection. These components are used to create Modules.
Its worth to mention ViewComponents here as well. They are only responsible for passing the Router its required dependencies. They do not maintain a dependency graph.
public protocol SomeComponent: Component {
var depA: DepA { get }
}
public class SomeComponentImpl: Component, RootComponent {
public var depA: DepA { DepA() }
}
Dependency lookup is done by traversing the component tree. This is done by using the parentComponent property and using breadth first search. Because dependencies are looked up at runtime using dynamicMemberLookup, this code is necessarily compile time enforced. This means that if a dependency variable name doesn't match directly or the dependency isn't in the tree it will crash at runtime. Dependencies are cached at their respective components to avoid unnecessary lookups.
public protocol ComponentProviding: AnyObject, ModuleComponent {
subscript(dynamicMember member: String) -> T { get }
}
@dynamicMemberLookup
public class Component: ComponentProviding {
public let parent: Component?
private var cachedProperties: [String: Any] = [:]
public init(parent: Component?) {
self.parent = parent
}
public subscript(dynamicMember member: String) -> T {
if let cached = cachedProperties[member], let val = cached as? T {
return val
}
var component: Component? = self
var tree: String = ""
while let comp = component {
tree += "\(tree.isEmpty ? "" : " -> ")\(String(describing: comp))"
let mirror = Mirror(reflecting: comp)
for c in mirror.children {
if c.label == member, let value = c.value as? T {
cachedProperties[member] = value
return value
}
}
component = component?.parent
}
fatalError("Cannot find \(member): \(String(describing: T.self)) in component graph: \(tree) ")
}
}
The builder is responsible for creating the Module and Router. It is passed the parent Component, the ModuleHolder, as well as the ModuleHolderContext. It will use those to contruct the module, router, and component dependency graph.
public protocol SomeModuleBuilding: ViewBuilding, ModuleBuilder {
}
public struct SomeModuleBuilder: SomeModuleBuilding {
public func buildRouter(component: T) -> R? where T : ViewComponent, R : Routing {
guard let c = component as? SomeViewComponentImpl else { return nil }
return SomeModuleRouter(component: c) as? R
}
public func build(parentComponent: Component,
holder: SomeModuleHolder?,
context: SomeModuleHolderContext) -> SomeModule {
let component = SomeModuleComponentImpl(parent: parentComponent,
depA: parentComponent.depA)
let module = SomeModule(holder: holder, context: context, component: component)
let viewComponent = SomeViewComponentImpl(module: module, moduleHolder: holder)
module.router = buildRouter(component: viewComponent)
return module
}
}
Modules contain code specific to their feature use case. This makes them highly testable since you only need to inject the required dependencies to build them as they are standalone. The only visible functions when this module is referenced are the ones in defined in its supporting protocol.
public protocol SomeModuleComponent: Component {
var depA: DepA { get }
}
public class SomeModuleComponentImpl: Component, SomeModuleComponent {
public var depA: DepA
public init(parent: Component?,
depA: DepA) {
self.depA = depA
super.init(parent: parent)
}
}
public protocol SomeModuleSupporting {
// public facing functions for the module
}
public final class SomeModule: SomeModuleSupporting, Module {
public weak var holder: ModuleHolder?
public var router: SomeRouter?
public let key: SupportedModules = .someModule
private let depA: DepA
public init(holder: ModuleHolder?, context: SomeModuleHolderContext, component: SomeModuleComponent) {
self.holder = holder
depA = component.depA
}
// public facing functions for the module
}
What you'll see as you read this is that the Router example uses SwiftUI. This is not necessarily a requirement but just what I've been using in my personal app. There will be a UIKit version available as well.
The Router is responsible for providing a UI for the Module. It contains routes for the UI to navigate to if there are actions taken by the user in the UI. A module is not required to have a router if there is no user facing actions required.
public protocol SomeViewComponent: ViewComponent {
var module: SomeModuleSupporting { get }
var moduleHolder: ModuleHolder? { get }
}
public struct SomeViewComponentImpl: SettingsViewComponent {
public var module: SettingsSupporting
public var moduleHolder: ModuleHolder?
}
public protocol SomeModuleRouting: Routing {}
public class SomeMOduleRouter: SomeModuleRouting, Logger {
public var logLevel: LogLevel = .high
private let moduleHolder: SomeModuleHolder?
private let component: SomeViewComponent
public init(component: SomeViewComponent) {
self.component = component
self.moduleHolder = component.moduleHolder as? SomeModuleHolder
if moduleHolder == nil {
log(type: .message, message: "No valid ModuleHolder to be found in \(#file)")
}
}
public func rootView() -> any View {
SomeRootView()
}
}
The introduction of the ModuleHolder is what I think makes this architecture unique. The ModuleHolder is a class that is a Module itself but also can contain other Modules. This allows for a module and router tree to be created allowing for any module or router to call any other module or router up the tree without having it injected as a dependency. This maintains the testability of the modules and routers. Without getting too much into the specific of the implementation, the ModuleHolder uses dynamicMemberLookup and specific SupportedModuleKey enum to look up the module in its supportedModules array.
public struct SomeModuleHolderContext: ModuleHolderContext {
public var parent: ModuleHolderContext? = nil
}
@dynamicMemberLookup
public class SomeModuleHolder: RootModule, Module, ModuleHolder {
public let holder: ModuleHolder? = nil
public typealias Context = SomeModuleHolderContext
public typealias Router = SomeRouter
public var key: SupportedModules = .some
public var router: Router?
public var supportedModules: [any Module] = []
private let component: SomeComponent
public required init(holder: ModuleHolder? = nil, context: RootModuleHolderContext, component: RootComponent) {
self.component = component
supportedModules = [
// Some modules here
// SomeModuleBuilder().build(parentComponent: component, holder: self, context: context)
]
router = SomeBuilder().buildRouter(component: SomeViewComponentImpl(moduleHolder: self,
depA: component.depA))
}
public func routeToRootView() -> any View {
router?.rootView() ?? ErrorView()
}
}
Module and router lookup is done using breadth first search on the module tree.
public extension ModuleHolder {
subscript(dynamicMember member: String) -> T? {
var holder: ModuleHolder? = self
while holder != nil {
if let moduleKey = SupportedModules(rawValue: member),
let m: T = holder?.supportedModules.first(where: { $0.key == moduleKey }) as? T {
return m
}
holder = self.holder
}
return nil
}
func module(for id: SupportedModules) -> T? {
let t: T? = self[dynamicMember: id.rawValue]
return t
}
func router(for id: SupportedModules, moduleType: M.Type) -> T? {
let t: M? = self[dynamicMember: id.rawValue]
let r = t as? (any Module)
return r?.router as? T
}
}
Below I will get into the specifics of how this architecture works and the role of the ModuleHolder.
I don't plan on wide-spread adoption of this framework. I just wanted to share my journey and what I've learned throughout the process of developing a personal app. A lot of developers struggle with which architecture to use when writing their apps. I'm here to say that there is no correct way, unless you're working on a team and have to follow a specific architecture, and this post is proof. Just have fun with it and continue to learn throughout the way! Don't stress.