ionostafi.com

2024-10-16

Event driven architecture in iOS

Architecture of the M25 project

This is a continuation of previous article M25 project. For the M25 project I have chosen to go with Event Driven Architecture. In this article, I’ll focus mainly on the communication patterns and events. Other topics, such as testing, mocking, simulating events, integration testing, and microservices in an iOS app, will be briefly mentioned but not detailed or exemplified.

Definition

A defintion by Amazon. An event-driven architecture (EDA) uses events to trigger and communicate between decoupled services and is common in modern applications built with microservices. An event is a change in state, or an update, like an item being placed in a shopping cart on an e-commerce website. Events can either carry the state (the item purchased, its price, and a delivery address) or events can be identifiers (a notification that an order was shipped).

Motivation

For the M25 project, I want to go beyond what I worked with, what I saw in the community, and what is the default code structure for any iOS project. To challenge myself technically, I've chosen an event-based architecture. I want to use microservices-inspired patterns on the mobile by modularising the app into decoupled components and use an event bus for communication between them.

The idea was born when I started to think of a new architecture for the Crunchyroll iOS application, where I am working on full-time. At first, I only considered event-based communication between specific components. Later, when the idea evolved and I talked to multiple engineers in the company, someone mentioned what if we go full event-based. I was skeptical of the idea as event-driven systems can become difficult to manage if not carefully implemented. We haven't adoped the idea in my team.

Still, I decided to try the idea. I completed a proof-of-concept and converted an iOS app from the traditional way of writing apps to the event-driven architecture. Several key advantages convinced me to choose EDA for the M25:

Decoupling between components

Having a clean ViewModel without referencing one or multiple services makes it absolutely decoupled from the services. While using protocols to achieve similar decoupling is possible, it involves more boilerplate code—defining protocols, implementing them, writing mocks, and managing dependency injection (DI). With event-driven architecture, I only need the event, its implementation, and the subscription logic.

Reduced Dependency Injection Code

The driving force for me after I finished my first proof-of-concept is that I almost deleted the whole dependency injection code. Every service or ViewModel is created without any other dependencies because there is no need for a reference to communicate - communication is done through decoupled events. A traditional DI requires time or a good framework to make the DI code transparent so that developers don't waste time wiring objects manually but instead focus on business logic code - this is called convention over configuration. I haven't tried all the DI frameworks out there, but I used Swinject and it has some support for automatic code wiring based on files. It's not fully automated.

Simplified Testing

Instead of writing unit tests for each individual service function, which would be time-consuming, I’m focusing on integration testing. I simulate the entire system minus external dependencies, sending fake events to test ViewModel state transitions. External dependencies (like Apple frameworks or third-party SDKs) are abstracted behind facades, allowing me to control and mock them during tests.

For testing, the WHEN is the moment an event is sent (triggered by user or system actions), the GIVEN is the setup of fake events, and the THEN involves asserting the resulting ViewModel state.

Faster Development

I found that having less dependency injection code, fewer protocols, mocks, and unit tests boosts productivity. Instead of focusing on writing interfaces and figuring out how to segregate them, which is what many developers spend a decent amount of time on in VIPER architecture (as an example), I concentrate on the implementation of services, events, and the View. I’ve noticed that whenever I need to move code around in traditional architectures, I often have to re-write interfaces and re-wire components—things that I don’t need to do with an event-driven architecture (EDA).

Things to be aware of

Event driven communication between components comes with disadvantages, as in any decision in life, there is no good and bad, there is only a trade-off. Each choice should be evaluated based on the specific use case, the people involved, and other factors.

Some trade-offs I have to keep in mind:

Mindset shift

The call/response using direct object references is the request/response pattern. Programming using this pattern is following the traditional imperative paradigm. If I want to change the state of the UI, I call a method that loads data, I wait for the response and then update the UI. If a tap is happening, I call a method to change the system state, I wait for the response and then update the UI. In event driven architecture, I step into a declarative paradigm where I react to changes. If I want to update the UI, I am waiting for the desired event to happen. I don't know when that event will happen, but if it happens, I will update the UI.

Debugging

In the traditional procedural paradigm, If I have a problem and want to debug it I can put a breakpoint, and start following the calls using next step, step-in/out. While inspecting the state of the program at each step I can find the issue fast. With reactive paradigm, I need to find other ways of debugging. And one that I found to work pretty well is the logging of events. I can still use the debugger, but instead of a single break point, I will have to put breakpoints in places that trigger events I need to debug.

Events order

Observers receive events in the order of their subscription, meaning that the timing of when a component subscribes can impact the behavior of the system. I have to design code so that services, view models, and other components do not rely on a specific event order, as this can lead to brittle or confusing dependencies.

While these may seem like disadvantages at first, they're largely a matter of adapting your mindset and using appropriate tools. Any architecture can have debugging complexities, especially when concurrency is involved.

Comparison with other event bus implementations

A more traditional event bus implementation is by using a central "bus", an object that is used to send and listen to events. A perfectly usable solution. Such solutions can be found on github as an open source library. I have read the source code of the three top libraries and all of them are not using the Combine framework, I suppose it was created before Combine was released by apple.

The approach I take is to have a publisher (bus) for each event I am sending, this makes the listening very granular. I can listen to just one event or multiple events using CombineLatest. I can create my own channels customized to my needs, as the example with EventChannel.all to be able to listen to all events. I think that the channel solution can be extended to more needs.

If I choose to always use a single channel for events distribution, like traditional event buses, it means that somewhere in the stream there will be a filter by events. An operation that can be avoided easily, and if there is a route with less code and operations, then I will choose that route.

Simple event bus design using Swift and Combine

I found a simple, loveable, powerful implementation of an event. I tried a few different designs, but here is one I love very much. This one doesn't use a central event bus pattern; the "bus" is part of the event definition. Each event can be subscribed separately or combined with others using Combine publishers.

public protocol Event {
    static var publisher: PassthroughSubject<Self, Never> { get }
}

Events definition examples

// Example of an event without a message
public struct LoadPlanEvent: Event {
    public static let publisher = PassthroughSubject<Self, Never>()
}

// Example of an event with a message
public struct PlanProgressUpdatedEvent: Event {
    public static let publisher = PassthroughSubject<Self, Never>()
    public var planProgress: PlanProgress

    public init(planProgress: PlanProgress) {
        self.planProgress = planProgress
    }
}

Events can be sent from anywhere using the event definition to access the publisher.

LoadPlanEvent.publisher.send(.init())
PlanProgressUpdatedEvent.publisher.send(.init(planProgress: .empty()))

Examples of observing events in a SwiftUI ObservableObject

class PomodoroTimerViewModel: ObservableObject {
    @MainActor @Published var loaded = false
    @MainActor @Published var planProgress: PlanProgress = .empty()

    init() {
        LoadPlanEvent.publisher.map { _ in false }.assign(to: &$loaded)
        PlanProgressUpdatedEvent.publisher.map(\.planProgress).assign(to: &$planProgress)
    }
}

The ViewModel could be renamed to a Store. I am using the ViewModel name because I will use the MVVM architectural pattern and the store will be transformed to a ViewModel. ViewModel doesn't have reference to any service; it reacts to changes in the system by listening to the events it is interested in. In this case, it's LoadPlanEvent and PlanProgressUpdatedEvent. Those events could be sent by any part of the system; in my case, it would be one or multiple services to send those events.

Improved event bus design

With time, I noticed two things I needed from the events:

  • A way to hook into the stream and log the events (observability)
  • Slight repetition in code .publisher.send() is used on each event send.
  • Some messages have to be stored so I can directly check the last value that was sent for an event.

To solve the above, I made the following update to the protocol and added a default implementation.

public protocol Event {
    associatedtype S: Subject<Self, Never>
    static var subject: S { get }
    static func send(_: Self)
}

Now, the events can store either an PassthroughSubject or a CurrentValueSubject. The senders of the events can directly call send without having to access the subject, the usage becomes:

// current
LoadPlanEvent.send(.init())
PlanProgressUpdatedEvent.send(.init(planProgress: .empty()))

// previous
LoadPlanEvent.publisher.send(.init())
PlanProgressUpdatedEvent.publisher.send(.init(planProgress: .empty()))

I didn't implement the send method in each event, the event implementation remains almost unchanged, just replaced the publisher with subject. Instead, I created an extension for the publisher with default implementation to create a way to hook into the events.

public class EventChannel {
    public static let all = PassthroughSubject<any Event, Never>()
}

public extension Event {
    static func send(_ value: Self) {
        EventChannel.all.send(value)
        subject.send(value)
    }

    static var publisher: AnyPublisher<Self, Never> {
        subject.eraseToAnyPublisher()
    }
}

EventChannel.all is used to hook into all event streams. I am logging the events on the console for now. But I can create a service to listen and record all the event, so later I can simulate all those events helping me in testing.

Example using spotify to display playlist

The example is simplified to display the charactersistics of the EDA architecture and not technically how to fetch and use the spotify API. The spotify API is available through the use of web API or iOS SDK. The implementation of UI is simplified as well.

The View

struct ChoosePlaylistView: View {
    @EnvironmentObject var viewModel: ChoosePlaylistViewModel

    var body: some View {
        List(viewModel.playlists) { playlist in
            PlaylistView(playlist: playlist)
        }
        .overlay {
            if viewModel.isLoading {
                ProgressView().progressViewStyle(.circular)
            }
        }
        .onAppear {
            UpdatePlaylistsRequestedEvent.send(.init())
        }
    }
}

Each time this view appears, I am sending an event to notify the system that there is a need for refreshed playlists.

The View and ViewModel is one place where I am using object reference. View is referencing the ViewModel. ViewModel is created outside of the view, and added as an EnvironmentObject. All the ViewModels in the app are created at app launch and added to SwiftUI environment.

To instantiate all ViewModels at the app launch isn't what I want, but for now I haven't found another way to graciously handle the creation of ViewModels

The domain model

public struct Playlist: BaseObject {
    public let id: String
    public let songsCount: Int
    public let name: String
    public let image: URL?
}

The ViewModel

class ChoosePlaylistViewModel: ObservableObject {
    @MainActor @Published var playlists: [Playlist] = []
    @MainActor @Published var isLoading = true

    init() {
        PlaylistsUpdated.publisher
            .receive(on: DispatchQueue.main)
            .map(\.playlists)
            .assign(to: &$playlists)

        PlaylistsUpdated.publisher
            .receive(on: DispatchQueue.main)
            .map { _ in false }
            .assign(to: &$isLoading)

        UpdatePlaylistsRequestedEvent.publisher
            .receive(on: DispatchQueue.main)
            .map { _ in true }
            .assign(to: &$isLoading)
    }
}

The service

public class SpotifyService {
    public init() {
        /// ... Subscription to other events
        UpdatePlaylistsRequestedEvent.publisher.sink { [weak self] _ in
            self?.updatePlaylists()
        }.store(in: &bin)
    }

    private func updatePlaylists() {
        Task { [weak self] in
            guard let self = self else { return }
            let playlists = await getUsersPlaylists()
            PlaylistsUpdated.send(.init(playlists: playlists))
        }
    }

    private func getUsersPlaylists() async -> [Playlist] {
        do {
            // request the user's playlist from web api
            return playlist
        } catch {
            ErrorEvent.send(error)
            return []
        }
    }
}

Diagram

class diagram

Service is fully decoupled from the View-ViewModel structure. Service is sending Model objects through the use of events.

Follow up

If someone of you want to go and read more about event driven architecture, check this out, it's a nice source: The Ultimate Guide to Event-Driven Architecture Patterns

I the meantime, I am planning to read the book Practical Event-Driven Microservices Architecture by Hugo Filipe Oliveira Rocha.


tags: architecture ios