Composition and separation of concerns
Lets talk a bit about SOLID or rather SRP (Single responsibility principle). How do you think does this piece of code follow SRP principle?
func (marshaller *binaryCommandHeaderDecoder) Decode(target interface{}) error {
if marshaller.reader.ActualPosition() <= 0 {
logger.Println("Error:", ErrReaderState)
return ErrReaderState
}
if header, ok := target.(*Header); ok {
header.Type = Type(marshaller.reader.ReadSingleByte())
header.Tick = marshaller.reader.ReadSignedInt(32)
header.PlayerSlot = marshaller.reader.ReadSingleByte()
logger.Printf("Header has been decoded. Content %+v", header)
if valid, err := header.Type.IsValid(); !valid {
logger.Println("Error:", err)
return err
}
} else {
var err = fmt.Errorf(
"incoming object has invalid type. Expected '*CommandHeader', actual %s",
reflect.TypeOf(target))
logger.Println("Error:", err)
return err
}
return nil
}
The answer is no, it doesn’t. There are two responsibilities for this piece of code: decoding some data and perform auditing (logging) for execution flow. In another words there are two reasons for modification: the first reason is changing the decode logic, and another can be a changes for content of log messages, for instance.
Is this a huge problem? In this particular case it is a not a big issue, but let’s imagine that we have some kind of caching, authorization, load balancing, retry mechanism built on top of the main logic:
func (c *Client) Do(r *http.Request) (res *http.Response, err error) {
r.Header.Add("Authorization", c.Token)
for i := 0; i <= c.Tolerance; i++ {
r.URL.Host = c.Backends[atomic.AddUint64(&c.robin, 1)%unit64(len(c.Backends))]
log.Printf("%s: %s %s", r.UserAgent, r.Method, r.URL)
start := time.Now()
res, err = http.DefaultClient.Do(r)
c.latency.Observe(time.Since(start))
c.requests.Add(1)
if err != nil {
time.Sleep(time.Duration(i) * c.Backoff)
continue
}
break
}
return res, err
}
We just wanted to send a simple request, didn’t we? :)
I see at least three cons of this approach:
- code duplication
- code tangling
- painful code testing
Separation of concerns
Logging, authorization, load balancing, retries, caching are nothing more than cross-cutting concerns. And all this “concerns” need to be decoupled from main logic.
This is where composition (functional and object oriented) comes into place. Instead of placing all stuff into a single class or function we can decouple logic into a small composable pieces.
Looks like an onion, right? :)
Here is how it can be implemented in object oriented manner:
interface IVariantDao
{
Task<Variant> Get(Guid id);
}
internal class VariantDao : IVariantDao
{
private readonly IDocumentDbClient _client;
public VariantDao(IDocumentDbClient client)
{
_client = client;
}
public Task<Variant> Get(Guid id)
{
return _client.GetAsync(id);
}
}
internal class LoggingVariantDao : IVariantDao
{
private readonly ILogger _logger;
private readonly IVariantDao _origin;
public LoggingVariantDao(
ILogger logger, IVariantDao origin)
{
_logger = logger;
_origin = origin;
}
public async Task<Variant> Get(Guid id)
{
Variant result = await _origin.Get(id);
_logger.LogDebug("The following variant has been retrieved: {variant}", result);
return result;
}
}
internal class CachingVariantDao : IVariantDao
{
private readonly IMemoryCache _cache;
private readonly IVariantDao _origin;
public CachingVariantDao(
IMemoryCache cache, IVariantDao origin)
{
_cache = cache;
_origin = origin;
}
public async Task<Variant> Get(Guid id)
{
if (!_cache.TryGetValue(id, out Variant entry))
{
entry = await _origin.Get(id);
}
return entry;
}
}
static void Main(string[] args)
{
IVariantDao dao = new CachingVariantDao(
new IMemoryCache(...),
new LoggingVariantDao(
new Logger(...),
new VariantDao(new CosmosDbClient(...))));
}
And the next example shows how it can be implemented in a functional manner using golang and functional types:
type Decoder interface {
Decode(target interface{}) error
}
// function type which implements `Decoder` interface
type DecodeFunc func(target interface{}) error
func (f DecodeFunc) Decode(target interface{}) error {
return f(target)
}
// represents decorator for `Decoder` interface
type Decorator func(Decoder) Decoder
// the function which returns `Decorator` implementation
func Logging(logger *log.Logger) Decorator {
return func(origin Decoder) Decoder {
return DecodeFunc(func(target interface{}) error {
err := origin.Decode(target)
if err != nil {
logger.Println("Error:", err)
} else {
logger.Printf("%s -> Result: %s\n", reflect.TypeOf(origin), string(target))
}
return err
})
}
}
// function which composes all decorators using variadic arguments
func Decorate(marshaller Decoder, decorators ...Decorator) Decoder {
var decorated = marshaller
for _, decorator := range decorators {
decorated = decorator(decorated)
}
return decorated
}
// usage
var decoder = Decorate(
command.NewDecoder(reader),
Logging(
log.New(
io.MultiWriter(os.Stderr, logOut), prefix, log.LstdFlags,
),
)
)
...
var decoder = Decorate(
command.NewDecoder(reader),
Logging(...),
Caching(...),
Authorization(...)
)
Summing up
Composition is a more natural way for object oriented and functional paradigms, so you can easily compose things without original code changes.
Props:
- code simplicity
- simple testing
- following SOLID (SRP, OCP)
- code reusability
Limitations:
- composable parts have to satisfy contract (interface)
Enjoy :)