Aspect-Oriented Programming in Swift

Luis Recuenco
The Swift Cooperative
9 min readMar 4, 2024
Photo by Elena Mozhvilo on Unsplash

Introduction

Aspect-oriented programming (AOP from now on) is all about adding cross-cutting concerns to our code base in a scalable manner.

I still remember the first time I came across the term “Aspect Oriented Programming”. It was quite a long time ago, Swift didn’t exist yet, and we were happily writing Objective-C code. ARAnalytics was one of the first libraries to adopt the AOP paradigm to simplify adding analytics across our code base.

Taking a closer look at the code, we can see that implementing AOP in Objective-C is just implementing method swizzling, as Peter Steinberger stated in his Aspects library.

Think of Aspects as method swizzling on steroids.

Unlike Objective-C, Swift is a really strict, statically-typed language with not many capabilities to intercept messages and change behavior at runtime. In practice, what this means is that we have to set some foundations of good design in our code so the impact of applying cross-cutting concerns to our codebase is minimal. We are going to need two main ingredients:

  • Some sort of dependency injection capabilities so we can apply late-binding and change the underlying implementation at will.
  • The decorator pattern, which is the interception design pattern that allows us to change the behavior that we want at runtime.

Let’s start!

AOP in Swift: towards a good design

A very common approach to model our application is to have a single “store/repository/service/aggregate” that bundles all the capabilities around a specific domain model in our application. If our application is about managing todos, we’ll have some sort of “TodoService”. If our app is about managing TV Shows, we’ll have a “ShowService”. You get the idea. While that approach is a perfectly valid one for lots of medium-sized apps, it can be problematic for other, more complex applications. As with most things in software, everything depends.

Imagine a ShowService that looks like this:

protocol ShowService {
func allShows() async -> [Show]
func allEpisodes(for show: Show) async -> [Episode]
func markEpisodeAsWatched(episode: Episode) async
func markShowAsWatched(_ show: Show, until episode: Episode?) async
}

As we said, even if most things in software are subjective and open to discussion depending on the specific context, it’s nice to have some “principles” that guide us. And those principles are usually the SOLID ones.

ISP violation

As most big interfaces, it's very likely that ShowService will break the Interface Segregation Principle, by forcing clients to implement methods that they don’t need. It’s very common to see this when implementing mocks for instance, with methods like XCTFail to verify that they aren’t called.

struct ShowServiceMock: ShowService {
var shows: [Show]

func allShows() -> [Show] {
shows
}

func allEpisodes(for show: Show) -> [Episode] {
XCTFail("Shouldn't be called")
return []
}

func markEpisodeAsWatched(episode: Episode) {
XCTFail("Shouldn't be called")
}

func markShowAsWatched(_ show: Show, until episode: Episode?) {
XCTFail("Shouldn't be called")
}
}

SRP violation

When violating ISP, it’s quite common to violate SRP as well. SRP is all about cohesion and having to change for just one reason. As the number of methods grow in our ShowService interface, it’ll be harder and harder to keep that cohesion and SRP.

OCP violation

Any new feature related to TV Shows will require adding a new method to ShowService, forcing every implementation of the service to accommodate that change and fix the compilation error.

Big interfaces tend to be bottlenecks

At the end of the day, having big interfaces like this will lead to bottlenecks when adding new behavior related to that domain model. Any new change will lead to compilation errors and recompilations all across our app. One of the mantras of good software design is to depend on modules that are more stable than you (they change less often). In our dependency graph, the leaf modules should be the most stable ones, as they will force recompilation of the whole graph whenever they change. If we take a closer look at ShowService, we are making the whole app depend on a highly unstable module that changes very often. So yes, a recipe for disaster 😅. FWIW, this kind of bottleneck it’s also very common in backend and microservices architecture, and it’s called “The Entity Service Antipattern”.

First approach: CQRS

A first approach would be to separate our big interface into two, distinct interfaces: one for reading, and one for writing. This is called Command Query Responsibility Segregation.

// Reads
protocol ShowQueryService {
func allShows() async -> [Show]
func allEpisodes(for show: Show) async -> [Episode]
}

// Writes
protocol ShowCommandService {
func markEpisodeAsWatched(episode: Episode) async
func markShowAsWatched(_ show: Show, until episode: Episode?) async
}

This separation usually makes sense because the needs we are going to have for reading are quite different from the ones we are going to have for writing. For instance, some security policies will only apply when changing state (writing) and not when just reading.

But as you can imagine, this is not a big improvement. We’ve scoped better the impact of change, but we still have two “big interfaces” that will change often. We can do better.

Second approach: small services

An obvious approach would be to “go extreme” and have all the different capabilities as different protocols.

protocol AllShowsService {
func allShows() async -> [Show]
}

protocol AllEpisodesService {
func allEpisodes(for show: Show) async -> [Episode]
}

protocol MarkEpisodeAsWatchedService {
func markEpisodeAsWatched(episode: Episode) async
}

protocol MarkShowAsWatchedService {
func markShowAsWatched(_ show: Show, until episode: Episode?) async
}

If you are used to working with clean architecture and use cases, these are exactly that.

It has some pros and cons. Having a protocol per method could lead to an explosion of interfaces. But it also has the main advantage of complying with some of the SOLID principles we talked about previously. We now comply with ISP (and most likely SRP as well). Also, when adding a new capability, we only have to create a new tuple (interface, implementation), without breaking a lot of the code we have and minimizing the impact of recompilations. So it’s not all bad. Remember, it all depends. Software is all about trade-offs.

But thinking about “Aspects”, we’ll have to implement one implementation per protocol for each aspect we need. For instance, if we want to implement a “Logging Aspect”, we’ll need a AllShowsLoggingService and another MarkShowAsWatchedLoggingService, etc… So, it doesn’t seem like an approach that scales nicely to implement cross-cutting concerns in our app.

Let’s go with the third, and final approach.

Third approach: a unified Service interface

If we want to implement AOP in a scalable manner, we need to have a single interface/seam for all our services that we want to apply “aspects” to.

protocol Service<Input, Output> {
associatedtype Input
associatedtype Output

func callAsFunction(input: Input) async throws -> Output
}

Just for ergonomics reasons, let’s also have this.

extension Service where Input == Void{
func callAsFunction() async throws -> Output {
try await callAsFunction(input: ())
}
}

Having a single interface, we can have something like this.

class AllShowsService: Service {
func callAsFunction(input: Void) async throws -> [Show] {
// Implementation...
}
}

class MarkShowAsWatchedService: Service {
func callAsFunction(input: (show: Show, episode: Episode?)) async throws {
// Implementation...
}
}

typealias AllShowsServiceType = Service<Void, [Show]>
typealias MarkShowAsWatchedServiceType = Service<(show: Show, episode: Episode?), Void>

class ViewModel<AllShowsService: AllShowsServiceType, MarkShowAsWatchedService: MarkShowAsWatchedServiceType> {
private let allShowsService: AllShowsService
private let markShowAsWatchedService: MarkShowAsWatchedService

init(allShowsService: AllShowsService, markShowAsWatchedService: MarkShowAsWatchedService) {
self.allShowsService = allShowsService
self.markShowAsWatchedService = markShowAsWatchedService
}

func markAllShowsAsWatchedButtonTapped() async {
do {
let allShows = try await allShowsService()
await withThrowingTaskGroup(of: Void.self) { group in
for show in allShows {
group.addTask {
try await self.markShowAsWatchedService(input: (show, nil))
}
}
}
} catch {
print("Some error happened", error)
}
}
}

Now, our ViewModel can be created as long as we inject two inputs:

  • Service<Void, [Show]>
  • Service<(show: Show, episode: Episode?), Void>

As Service is a single interface, we can have just an implementation of that interface per aspect to apply it to any service across our app.

Let’s implement some aspects then! As you will see, all aspects will be very similar, following the decorator pattern

Logging aspect

A very normal and useful aspect is simply to add some logging capabilities to our services. It can be implemented as simply as follows:

class LoggingService<Decoratee: Service>: Service {
typealias Input = Decoratee.Input
typealias Output = Decoratee.Output

let decoratee: Decoratee

init(decoratee: Decoratee) {
self.decoratee = decoratee
}

func callAsFunction(input: Input) async throws -> Output {
let output = try await decoratee(input: input)
dump(output)
return output
}
}

Premium user aspect

Another, very useful cross-cutting concern in the application could be to only allow to perform some actions in case the user is premium. By injecting a simple () async -> Bool function to abstract the way we check whether the user is premium or not, we can have our PremiumService decorator like this:

class PremiumService<Decoratee: Service>: Service {
struct Error: Swift.Error {}

typealias Input = Decoratee.Input
typealias Output = Decoratee.Output

let decoratee: Decoratee
let isPremiumUser: () async -> Bool

init(decoratee: Decoratee, isPremiumUser: @escaping () async -> Bool) {
self.decoratee = decoratee
self.isPremiumUser = isPremiumUser
}

func callAsFunction(input: Input) async throws -> Output {
guard await isPremiumUser() else {
throw Error()
}
return try await decoratee(input: input)
}
}

Caching aspect

Another common aspect is caching or memoization. By simply constraining the Service Input to be Hashable and the Output to be Codable, we can implement it for all our services.

private(set) var cache: [AnyHashable: Data] = [:]

class CachingService<Decoratee: Service>: Service where Decoratee.Output: Codable, Decoratee.Input: Hashable {
typealias Input = Decoratee.Input
typealias Output = Decoratee.Output

let decoratee: Decoratee

init(decoratee: Decoratee) {
self.decoratee = decoratee
}

func callAsFunction(input: Input) async throws -> Output {
if let cachedData = cache[input] {
return try JSONDecoder().decode(Output.self, from: cachedData)
}

let output = try await decoratee(input: input)
cache[input] = try JSONEncoder().encode(output)
return output
}
}

Delay Aspect

Network Link Conditioner is a very useful tool to simulate different types of network conditions and force our internet connection to be very bad. We could leverage aspects to do exactly that.

class DelayService<Decoratee: Service>: Service {
typealias Input = Decoratee.Input
typealias Output = Decoratee.Output

let decoratee: Decoratee
let duration: UInt64

init(decoratee: Decoratee, nanoseconds duration: UInt64) {
self.decoratee = decoratee
self.duration = duration
}

func callAsFunction(input: Input) async throws -> Output {
try await Task.sleep(nanoseconds: duration)
return try await decoratee(input: input)
}
}

Or we could have another aspect to always force an error, for instance.

Aspects are composable

Once we have all our aspects in place, we can simply modify our view model to inject the type of service we want, decorated by any number of aspects.

let viewModel = ViewModel(
allShowsService: DelayService(decoratee: LoggingService(decoratee: AllShowsService()), nanoseconds: 5 * NSEC_PER_SEC),
markShowAsWatchedService: LoggingService(decoratee: MarkShowAsWatchedService())
)

As you can see, we have chained different aspects together, as they are composable.

But the important thing is that adding decorators is something that we can do from the main module of the app, where we presumably build the whole object graph, via some sort of composition root pattern. That way, we can change the behavior of our services by adding new cross-cutting concerns without touching and recompiling any of those modules.

Ergonomics

While chaining decorators can be a little bit cumbersome, we can leverage extensions to improve ergonomics and have a more idiomatic way of composing them.

extension Service {
var withLogging: LoggingService<Self> {
LoggingService(decoratee: self)
}

func withDelay(nanoseconds duration: UInt64) -> DelayService<Self> {
DelayService(decoratee: self, nanoseconds: duration)
}
}

// Instead of this
DelayService(decoratee: LoggingService(decoratee: AllShowsService()), nanoseconds: 5 * NSEC_PER_SEC),

// We can now do this
AllShowsService()
.withLogging
.withDelay(nanoseconds: 5 * NSEC_PER_SEC)

Automating decorators

Sourcery is a great meta-programming tool to automate a lot of boilerplate code. As you can imagine, decorators are a great candidate to apply this tool, so we have some sort of “template” for each aspect and let Sourcery generate the different implementations. This is especially useful when we want to apply the very same aspect to lots of different interfaces. Take a look here.

Conclusion

While it’s true that I miss Objective-C dynamism for these kinds of tasks, I see the lack of runtime support to allow easy AOP in Swift as an opportunity to apply good design principles to unlock it.

Enabling AOP doesn’t necessarily make your code better, the same way that having testable code doesn’t mean it’s good code. It’s more the other way around. Not being able to test our code is a problem. Being able to test it doesn’t mean anything. We can see AOP in a similar way.

We did an interesting exercise to unlock AOP capabilities in our code. But let’s not forget the complexity we’ve added. Finding the balance between added complexity and loose coupling to adapt better to the future is one of the most difficult things we have to do as developers. This post is simply a tool. As always, use the right tool for the job.

--

--

Luis Recuenco
The Swift Cooperative

iOS at @openbank_es. Former iOS at @onuniverse, @jobandtalent_es, @plex and @tuenti. Creator of @ishowsapp.