Home Managing Cross Concerns using Proxy Pattern
Post
Cancel

Managing Cross Concerns using Proxy Pattern

Cross concerns such as logging, instrumentation and authorization are like bad contractors. One needs them, but they tend to leave a mess. A cross concern violates the single responsibility principles which states that a class or a module should have one and only one reason to change. Violating this principle results in code that is difficult to understand with intertwined rules. The core business of the application gets buried under tons of unrelated code, which extends to tests as well. A test case will either test multiple unrelated concerns or duplicated for each concern.

In this post, I am exploring how proxy pattern can help ease this friction. The example given here is very trivial and meant to demonstrate the approach while not getting distracted by a complex business problem. In the end, we are going to go over the benefits of using this approach.

- We have a simple code, a RemoteClient that satisfies Client interface. It has one method RetrieveUser, which in turn calls some IO library. This code is easy to read and understand.

1
2
3
4
5
6
7
8
9
10
11
type Client interface {
	RetrieveUser(id int) (string, error)
}

type RemoteClient struct {
	io IO
}

func (r RemoteClient) RetrieveUser(id int) (string, error) {
	return r.io.Request(id)
}

We want to add some cross concern to this code. We will take logging as an example.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type RemoteClient struct {
	io IO
	log Logger
}

func (r RemoteClient) RetrieveUser(id int) (string, error) {
	r.log.Debug("Retrieving user for id: %d", id)
	user, err := r.io.Request(id)
	if err != nil {
		r.log.Error(err,"Failed to retrieve user for id: %d", id)
	}
	r.log.Debug("Successfully retrieved user for id: %d. User: %v", id, user)
	return user, err
}

The business logic is buried under tons of log statements. The only reason we have the error check here, is to write the appropriate log. This problem would have been even worse if we had other concerns. Note that RemoteClient now depends on logging. In this case it was by being a member of RemoteClient, but it could have been via a static import, it is still a dependency.

One solution for this problem is using the proxy pattern. We create a new type ClientLogger that satisfies the same interface RemoteClient does. Then each method of the new type ClientLogger delegates to the corresponding method in RemoteClient. That is ClientLogger.RetrieveUser calls RemoteLogger.RetrieveUser with the same exact arguments, and return the same results. However, we wrap the call to RemoteLogger.RetrieveUser with our cross concern.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type ClientLogger struct {
	Logger
	delegate Client
}

func (l ClientLogger) RetrieveUser(id int) (string, error) {
	l.Debug("Retrieving user for id: %d", id)
	user, err := l.delegate.RetrieveUser(id)
	if err != nil {
		l.Error(err, "Failed to retrieve user for id: %d", id)
	}
	l.Debug("Successfully retrieved user for id: %d. User: %v", id, user)
	return user, err
}

Since the code calling our RemoteClient depends on Client interface ,hopefully, we only need to make a minor change to the client instantiation from.

1
var client Client = RemoteClient{}

to

1
var client Client = ClientLogger{logger, RemoteClient{}}

If our application retrieves its implementation from a factory this change should be transparent to the business logic. Only the factory method will be updated.

If we have even more concerns, our code will not change. But our client instantiation would get just a little bit messy.

1
var client Client = ClientInstrumentation{instrumentation, ClientLogger{logger, RemoteClient{}}}

What did we gain?

  • Each of ClientLogger and RemoteClient has a single responsibility, and a single reason to change. If the IO library changes, the logging shouldn’t change. If logging format changes, our RemoteClient shouldn’t care. If authorization rules change, neither logging nor RemoteClient should care
  • We can have three different types of Clients, and we wouldn’t need to duplicate the cross concern code, also known as DRY (Don’t Repeat Yourself)
  • Significantly decrease the cognitive load. One can easily understand and navigate through the code without being hindered by cross concerns or jumping from one concern to another.
  • Each concern can be tested and verified independently.

Why write when you can generate!

GoWrap is a command line tool that would create wrappers based on your interfaces. So, you can use it to generate your cross concern handlers. It already has templates for most common concerns, but you can define your own. Gratitude goes to titpetric for pointing me out to it.

I think I would still write my wrappers instead of generating them because I don’t think generated wrappers are flexible enough, they might, however, be a good starting point.

This post is licensed under CC BY 4.0 by the author.
Contents

Vim Plugins

Working effectively with legacy code

Comments powered by Disqus.