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
andRemoteClient
has a single responsibility, and a single reason to change. If the IO library changes, the logging shouldn’t change. If logging format changes, ourRemoteClient
shouldn’t care. If authorization rules change, neither logging norRemoteClient
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.
Comments powered by Disqus.