Decorator Pattern for Cross-Cutting Concerns

abdul ahad
6 min readApr 25, 2024

--

Photo by Nino Liverani on Unsplash

What are Cross-Cutting Concerns?

When developing an app, there are non-functional requirements that can be littered around the whole code affecting multiple modules or components. Examples include logging, threading, security, performance monitoring, and error handling. we call them Cross-cutting concerns. The decorator pattern can be applied to add these cross-cutting concerns to existing objects without modifying their core functionality.

Example: Analytics

It is very common to use analytics as a cross-cutting concern that affects multiple parts of the app. The RemoteItemService is coupled with AnalyticsTracker to track analytics to track what values are returned from the loadItemmethod.

public class AnalyticsTracker {
static public func track(values:[String]){
print(values)
}
}

public protocol ItemService {
func loadItem() async -> [String]
}

public class RemoteItemService:ItemService{
public init(){}
public func loadItem() async -> [String] {
try? await Task.sleep(nanoseconds: 1)
let returnedValues = ["error","success"]
//track analytics
AnalyticsTracker.track(values: returnedValues)

return returnedValues
}

}

Threading

Another example that I want to introduce is threading. Threading is cross-cutting detail. How often do we see the code below where we dispatch to the main thread in the UI layer?

DispatchQueue.main.async {
self?.label.text = val
}

UI layer expects calls on the main thread. some services might be completed on background queues, like network and database requests. And some services might execute serially on the main thread, like an in-memory cache. But those are infrastructure details. The UI shouldn’t know about the provenance of the data. And the services shouldn’t know about the UI.

//loader completes in background thread
public class RemoteLoader:UserLoader {
public func fetchUserData(completion:@escaping (String) -> Void) {
DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
completion("user data loaded")
}
}

public init(){}

}
public class DispatchQueueViewController:UIViewController{

let loader:UserLoader
public lazy var label: UILabel = {
let label = UILabel()
label.text = "loading"
return label
}()
init(loader: UserLoader) {

self.loader = loader
super.init(nibName: nil, bundle: nil)

}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

public override func viewDidLoad() {
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)


//loader completes in background thread but ui needs to be updated on
//the main thread

loader.fetchUserData { [weak self ]val in
DispatchQueue.main.async {
self?.label.text = val
}

}

}


}

why cross-cutting concerns are bad?

The main issue with cross-cutting concerns is that they can lead to code that is difficult to maintain, understand, and modify. Here’s why they can be problematic:

Code Duplication: Without a centralized way to handle cross-cutting concerns, developers may end up duplicating code across various parts of the application. This duplication can lead to inconsistencies, bugs, and makes it harder to update or change these concerns later on.

Scattered Logic: When the logic for a concern like logging or authentication is spread across multiple modules or layers of an application, it becomes harder to manage and reason about. This can make debugging and troubleshooting more difficult.

Tight Coupling: Handling cross-cutting concerns directly within business logic can lead to tight coupling between these concerns and the core functionality of the application. This can make the code less flexible and harder to maintain, as changes to one concern may require modifications across multiple parts of the codebase.

Testing Complexity: Cross-cutting concerns that are intertwined with business logic can make testing more complex. It becomes challenging to test the core functionality in isolation without also testing the concerns, and vice versa.

Readability and Understandability: Code that is littered with cross-cutting concerns can be harder to read and understand.

Solutions

A good way to deal with cross-cutting concerns without increasing coupling/complexity is to use Decorators.

What is a Decorator?

According to GOF:

“Decorator is a structural design pattern that lets you attach new behaviors to objects by placing these objects inside special wrapper objects that contain the behaviors.”

The Decorator pattern shares the same interface with the decoratee (the object to which the behavior is added without modifying the object). It takes the decoratee and a new behavior as dependencies, enabling it to call both the decoratee and the behavior as needed. This design ensures that the client remains decoupled from the implementation details. Since both the decorator and the decoratee share the same interface, they appear identical to the client. Thus, the client is shielded from the underlying implementation complexities, facilitating seamless interaction with the decorated object.

let’s look at a real-world example.

Decoupling Analytics

We can use the decorator pattern to add tracking behavior without modifying the existing RemoteItemServiceclass.

//tracker protocol
public protocol Tracker {
func track(values:[String])
}
// implementation
public class AnalyticsTracker:Tracker {
public init(){}
public func track(values:[String]){
print(values)
}
}


public class AnalyticsDecorator:ItemService{
let decoratee:ItemService
let analyticsTracker:Tracker

public init(decoratee: ItemService, analyticsTracker: Tracker) {
self.decoratee = decoratee
self.analyticsTracker = analyticsTracker
}
public func loadItem() async -> [String] {
let values = await decoratee.loadItem()
analyticsTracker.track(values: values)
return values

}
}
public protocol ItemService {
func loadItem() async -> [String]
}

// service
public class RemoteItemService:ItemService{
public init(){}
public func loadItem() async -> [String] {
try? await Task.sleep(nanoseconds: 1)
let returnedValues = ["error","success"]
AnalyticsTracker.track(values: returnedValues)

return returnedValues
}

}

//view model
public class AnalyticsViewModel:ObservableObject {
@Published var values = [String]()
let itemService:ItemService

public init(itemService: ItemService) {
self.itemService = itemService
}

func load() async {
self.values = await itemService.loadItem()
}
}
//view
public struct AnalyticsView:View {
@StateObject var vm: AnalyticsViewModel

public var body: some View {
List(vm.values, id: \.self) { datum in
Text(datum)
}
.onAppear{
Task{
await vm.load()
}

}
}
}

public class AnalyticsUIComposer {
public static func instantiate(serive:ItemService) -> AnalyticsView {
let vm = AnalyticsViewModel(itemService: serive)
return AnalyticsView(vm: vm)
}
}

#Preview {
AnalyticsUIComposer.instantiate(serive: AnalyticsDecorator(decoratee: RemoteItemService(), analyticsTracker: AnalyticsTracker()))
}
  • You can use RemoteItemService directly to load items from a remote source.
  • If you want to add analytics-tracking functionality to the item-loading process, you can wrap an instance RemoteItemService with an AnalyticsDecorator instance.
  • The AnalyticsDecorator intercepts the item loading process, tracks the returned values, and then forwards the call to the decoratee (RemoteItemService in this case).

Overall, this pattern allows for the separation of concerns by enabling the addition of new functionality (analytics tracking in this case) to existing objects without modifying their code directly, thus promoting maintainability and extensibility.

Decoupling Threading

You can also use Decorator to add behavior to an existing class without changing it — in this case, we can add concurrency details (like Dispatch) to a Service or any other components without changing these components (thus, the original component is decoupled from concurrency details).



// decorator
class MainDispatchQueueDecorator:UserLoader {
let decoratee:UserLoader
init(decoratee: UserLoader) {
self.decoratee = decoratee
}

func fetchUserData(completion: @escaping (String) -> Void) {
decoratee.fetchUserData { status in
self.generatesMainThread {
completion(status)
}
}
}

func generatesMainThread(completion: @escaping () -> Void){
if Thread.isMainThread {
completion()
}else{
DispatchQueue.main.async {
completion()
}
}
}


}

//usage
let mainQueueDecorator = MainDispatchQueueDecorator(decoratee:UserLoader())

This way, we can freely decorate components in any way we want — sometimes we may decorate it to dispatch to the main queue… to a background queue… or any other specific queue without the component knowing about it.

This is powerful because we completely decoupled the component from threading, making it much simpler to implement, maintain, and test. Also, in a modular design, the Decorator implementations live in the Composition Root not with the Service or other modules. So the modules are decoupled from the Decorators and their behavior.

Thus the Services and other components are decoupled from concurrency, threading, and scheduling. You decorate them at the composition level. So you can achieve different results based on the composition you create. If, in a specific case, you want to dispatch later, like in the View layer, you create a composition with a Decorator at the View layer to dispatch to the main queue only then. You’re free to create any composition with the right threading strategy you want at composition time. You’re also free to change it at any time or in different contexts.

Conclusion

Overall, using the decorator pattern for logging or cross-cutting concerns promotes code modularity, reusability, and maintainability while adhering to important software design principles. It enables you to enhance the behavior of objects with minimal impact on their core functionality, leading to cleaner, more flexible, and more robust code.

Reference:

Github: https://github.com/abdahad1996/DesignPatterns_Bootcamp

--

--

abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning