How we used Macros to Promote MVVM architecture adoption

Over the past few years, our team of iOS engineers has grown significantly (and continues to grow!), as has the size of the codebase. 

A line graph showing the increase in lines of code in the iOS app in the last ten years.

To accommodate this scale my team, Client Architecture, has been working hard to standardize around a consistent MVVM architecture. And while this effort has been mostly well received, during the initial rollout we received feedback that the new guidelines led to an annoying increase in boilerplate. At first we tried to cut back on repetitive code as much as possible with helper methods and shared utilities, but we eventually hit a wall because sometimes it isn’t possible to pare everything away. For this boilerplate which cannot be easily abstracted we’ve increasingly turned to a Swift feature called “Macros”.

What are Swift Macros?

At their most fundamental, Macros are tags that are attached to existing code to generate new code at compile time. The actual Macro implementation does this by operating on the abstract syntax tree (AST) of the source code they are attached to and generating a new tree which can either be inserted into the attached class/struct/method/etc, or alongside it. This process is fully integrated into the compiler, so generated code is treated exactly the same as manually written code, and thus benefits from all the same compiler optimizations and seamlessly fits into the build process. Given these performance optimizations and the ability to transform code based on structure and semantics rather than just the text, Macros are incredibly powerful when it comes to generating boilerplate.

Application to DataSource implementations

A visualization of our MVVM architecture.

While there are many possible applications for Macros we decided to start with the “DataSource” components in our architecture. In our MVVM architecture, “DataSources” act as a layer of abstraction over various sources of data — be it a local file, a database, a key-value store, a remote API, or in-memory storage. This allows the consumers, or Repositories as is the case in our architecture, to remain agnostic of the underlying data storage mechanisms, simplifying testing and making the code more resilient to changes in data storage strategy. While essential, DataSources often follow consistent patterns and share similar structure across implementations. As a result, they can involve a significant amount of repetitive code. This consistency makes them an ideal starting point for macro-based code generation, allowing us to reduce boilerplate while preserving the clarity and benefits of the abstraction.

For example, consider these two update methods in the same KeyValueDataSource:

public func updateValueA(valueA: Int, userID: DUOUserID) throws {
  let store = store(for: userID)
  try store.set(valueA, forKey: .valueAKey)
}

public func updateValueB(valueB: String, userID: DUOUserID) throws {
  let store = store()
  try store.set(valueB, forKey: .valueBKey)
}

These methods are almost identical, differing only in the value being set and its corresponding “key”. You might be wondering why we couldn’t write a generic updateValue<T>(...) method to condense these into a single method. This is a good question to ask since generics often are the simpler solution when possible, but it wouldn’t be so straightforward in this case since the updateValueA method uses the valueAKey to query the store, versus the updateValueB method which uses the valueBKey. You could hypothetically pass this key in as a parameter to the DataSource, but this would be exposing an implementation detail that we’d prefer to keep the DataSource consumers agnostic to. Given all this repetition and the difficulties of writing a generic utility function these methods are the perfect candidates for Macro code generation.

Implementing Swift Macros

In order for a Macro to work, it needs to be able to gather all the relevant context from the code it is attached to. For our KeyValueDataSources we were able to use the KeyValueStoreItem.Key extension where we define our key variables. Here is an example for an “avatar” key:

private extension KeyValueStoreItem.Key {
    static let avatar = Key<Avatar?>("avatar")
}

By attaching our GenerateKeyValueDataSource macro to this extension, we can automatically generate a full KeyValueDataSource implementation:

Our code with KeyValueDataSource implementation.

Macro benefits and drawbacks

By employing Macros, developers can both save engineering time and reduce the likelihood of manual errors. Because the macro-generated code is standardized and tested it is far less prone to bugs and one off errors. Additionally, updates or enhancements to logic in DataSources can be efficiently handled within the macro itself, instantly reflecting changes across all implementations. For these reasons Macro code is much more reliable than both standard script generated code and AI code.

While powerful, it’s also important to recognize that Swift Macros do have some drawbacks. For instance, the code they generate is not searchable in Xcode and they can add complexity for those unfamiliar with how they work. If it’s possible to reduce boilerplate via other more straightforward means like helper functions and classes, it’s probably best to try those first. However, for cases like the ones described in this post, they are a great solution. Unlike other code generation tools, they are well integrated into the build process, implemented natively in Swift, and operate over an abstract syntax tree rather than raw text. 

Build times

Oscar driving a yellow car and looking frustrated at a tortoise in the road.

When we first added the macros dependency we were seeing a roughly 10 to 20 second increase for our clean builds. While we never found a way to speed up the build times for the Macros package itself, we were able to substantially reduce the number of times the package needed to be built. We discovered that the key was to *not* import our macros package using the Swift Package Manager.

Instead we link the binary manually using the load-plugin-executable flag. The binary is generated via an explicit command in our makefile which only rebuilds the package binary when changes have been made to the package locally. Additionally, when a PR is merged into master that changes the macro package, we build and cache this new binary in S3 so that other engineers can simply download it from the bucket without needing to rebuild it themselves.

This pipeline pretty much ensures that you’ll never need to rebuild the binary unless you are directly working on the Macros package yourself. However there are still some hiccups in this process, for instance due to Macro’s restrictions on arbitrary names at global scope, it is sometimes the case that engineers will need to edit the macros package when adding a new reference to a given macro and therefore need to rebuild the entire package. For this reason we try to use naming conventions that allow the macro to either prefix or suffix the attached element's name.

Results

A line graph showing an increase in total total generated lines of code since July 2024.

We now have over 4,300 lines of Macro generated code in our iOS code base. That’s 4.3k lines of code that not only never needed to be written, but also code never needed to be reviewed or unit tested. This code will also be less likely to introduce new bugs and is much easier to refactor down the line. And while DataSources offered the most obvious starting point for Macros, we think there are plenty of other opportunities to keep expanding our usage and improving both our developer’s experience and our code quality. We’ve already begun to leverage macros for automatically generating protocols, designated initializers, and other boilerplate-heavy implementations, significantly streamlining our development workflows.

Swift Macros is new and not widely adopted yet. But we love trying emerging tools when they make our code cleaner and our team faster.

If that sounds like your kind of engineering, we’re hiring iOS engineers!

首页 - Wiki
Copyright © 2011-2025 iteam. Current version is 2.144.2. UTC+08:00, 2025-08-06 20:54
浙ICP备14020137号-1 $访客地图$