Portrait

I2H3

Notes from work as an app developer for iPhones, iPads and Macs.

RSS Icon GitHub Mastodon

Workers Instead of Managers

Breaking down individual features of domain-oriented managers into dedicated implementations improves developer experience.

I know that the worker pattern is a preoccupied concept which is around way longer than what I cooked up. The worker pattern I am going to describe in this post actually can be traced back to an initial misunderstand of the widely known worker pattern. How does ChatGPT describe the worker pattern?

The worker pattern is a design pattern used in software engineering to efficiently handle concurrent or parallel tasks. It involves a pool of worker threads or processes that asynchronously execute tasks submitted by clients. Key components include task submission, a task queue, worker threads/processes, task execution, and concurrency control. This pattern helps manage concurrency, optimize resource utilization, scale dynamically, and enable asynchronous processing in various software systems.

We get that for free with Swift and Foundation in form of structured and modern concurrency features. And that is not what I am going to describe.

Something that always stuck out to me in the iOS and macOS developer world were those “managers” everywhere. I knew them before under slightly different name as services, based on service oriented architecture. The stuff which neither not related to data persistence level nor to view templating or such. They usually unite a lot of features related to a specific topic and if the code is less tidy, even more because someone did not know where to put it else or was in a haste.

Let’s assume the following type. It intentionally is kept simple.

struct AccountManager {
    func add(_ account: Account) {}
    func remove(_ account: Account) {}
}

Depending on your project the the topic, or better said “domain”, of your manager this can escalate quickly into an implementation spanning hundreds or thousands of lines. At least I have seen that often enough. Distributing individual parts into dedicated source files and extensions of the implementation type helps only slightly and in very specific use cases. The members of a database abstraction usually are mostly and functionally isolated from each other. Other pieces maybe have many side effects, internal dependencies and complicated calls back and forth. And there may be a lot of private helper methods not even visible on the surface.

What I am doing with that is to isolate individual features or tasks of a business process and move them into a dedicated implementation.

struct AddAccountWorker {
    func add(_ account: Account) {}
}

struct RemoveWorker {
    func remove(_ account: Account) {}
}

This helps to maintain isolation of the individual implementations and keep them short so the context to read and understand is smaller. In my opinion, it eases understanding of code. But this is not the end yet. It can be simplified even more.

/// Universal worker interface.
protocol Worker {
    associatedtype ExecutionResult
    func main() async throws -> ExecutionResult
}

struct AddAccountWorker: Worker {
    func main() async throws -> Void {}
}

struct RemoveAccountWorker: Worker {
    func main() async throws -> Bool {}
}

Introducing a protocol to define common requirements, it can be ensured that all workers have the same interface on the surface and somewhat work the same way.

Beyond this post and in practice I combined this with other patterns like dependency injection and signposting. A worker factory automatically sets up and returns workers which easily can be implemented as a mock for tests, too. Logging and signposting for the main implementation of a worker is automatically taken care of in a wrapping method call. Also, my workers are immutable, stateless and one-time-throwaway objects.

Overall it turned out to be a nice improvement over domain-specific managers which blow up too quickly. Testing and setting up individual workers is much simpler. Organizing business logic in a project larger than just an example piece works well this way. The predictable and common lifecycle pattern helps developers to understand what is going on more quickly. And the lack of state to manage reduces potential for bugs.