Aspect-Oriented Programming in Swift
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.