How to Implement the Decorator Pattern in Microservices

Written by fmontesi | Published 2022/10/06
Tech Story Tags: microservices | parametric | jolie | programming | decorators | modularity | programming-languages | software-development

TLDRThe Decorator pattern is a great fit for modifying the behaviour of a microservice. Native language support can help with applying it quickly and modularly. In this text we look at how a decorator can be implemented as a service, in particular one that sits between clients and a target service.via the TL;DR App

The Decorator pattern is a great fit for modifying the behavior of a microservice. Native language support can help with applying it quickly and modularly.

The Decorator pattern is used to modify the behavior of a target component without changing its definition. This idea turns out to be pretty useful in the context of microservices because it can give you better separation of concerns. It might even be a necessary device because the target service might be outside of your control. In this text, we look at how a decorator can be implemented as a service, in particular one that sits between clients and a target service.

Example: an E-Mail Service

Let's introduce an example, as a reference for our discussion.

Say that we have access to an e-mail service that offers the following API (loosely inspired by this post, which by the way is a nice read about object decorators). It consists of three operations: send an e-mail, list the e-mails of a user, and download the content of an e-mail. We write our examples in Jolie, a service-oriented programming language.

interface EmailServiceInterface {
RequestResponse:
  send(SendRequest)(void),
  listEmails(ListRequest)(EmailInfoList),
  downloadEmail(DownloadEmailRequest)(Email)
}

Decorating an Operation

Suppose that the e-mail service is available to us as emailService. We want to write a decorator with this logic: whenever send is called, we check if we have sent an “important” e-mail (e.g., the subject contains a specific keyword telling us that the e-mail is important, or the addressee is in a special list); if an important e-mail has been sent successfully, we backup its content by calling another service, for indexing and safe-keeping. Conceptually, we want the following architecture.

One way of writing our decorator is to code a service that reimplements all operations offered in the interface EmailServiceInterface, as in the next snippet.

service EmailServiceDecorator {
  // Output ports to access the target e-mail service, the backup service, and a library to check if e-mails are important
  outputPort emailService { ... }
  outputPort backupService { ... }
  outputPort important { ... }
  
  // Access point for clients
  inputPort EmailServiceDecorator {
    location: "socket://localhost:8080" protocol: MyProtocol // e.g., http
    interfaces: EmailServiceInterface // exposed API
  }

  // Implementation
  main {
    // Offer three operations: send, listEmails, and downloadEmail
    [ send( request )( response ) {
      send@emailService( request )()
      check@important( { subject = request.subject, to = request.to } )( important )
      if( important ) {
        backup@backupService( request )()
      }
    } ]

    [ listEmails( request )( response ) {
      listEmails@emailService( request )( response )
    } ]

    [ downloadEmail( request )( response ) {
      downloadEmail@emailService( request )( response )
    } ]
  }
}

The code for send does what we have previously described. The code for listEmails and downloadEmail is, however, a pure boilerplate: we’re just forwarding requests and responses between the client and the target e-mail service. Not only is this a bit annoying, but it also means that this approach wouldn't scale well if the target service had many operations.

Down with the Boilerplate: Aggregation

Building gateways (or proxies, load balancers, caches, etc.) is so natural in a microservice architecture that Jolie offers linguistic constructs to deal with these features effectively.

A particularly convenient primitive for creating a decorator is aggregation. In our example, we can rewrite the last code snippet as follows.

service EmailServiceDecorator {
  // Output ports to access the target e-mail service, the backup service, and a library to check if e-mails are important
  outputPort emailService { ... }
  outputPort backupService { ... }
  outputPort important { ... }
  
  // Access point for clients
  inputPort EmailServiceDecorator {
    location: "socket://localhost:8080" protocol: MyProtocol // e.g., http
    aggregates: emailServiceInterface // Aggregates instead of interfaces!
  }

  main {
    [ send( request )( response ) {
      send@emailService( request )()
      check@important( { subject = request.subject, to = request.to } )( important )
      if( important ) {
        backup@backupService( request )()
      }
    } ]
  }
}

Notice the usage of the aggregates instruction in the input port. It means that our service is a proxy to the e-mail service now: all client calls to operations offered by the e-mail service are now automatically forwarded to it. Furthermore, we can refine the behavior of specific operations. Here, we defined a custom behavior for the send operation, with our logic for backups. No boilerplate anymore!

Decorating Multiple Operations

Some decorators change the behavior of many operations in a uniform way. For instance, suppose that we want to write a decorator that keeps track of all events: whenever an operation is called, we write this in an external log.

Here's a naive implementation for our e-mail service (we're going to improve on it in a minute).

service EmailServiceDecorator {
  // Output ports to access the target e-mail service and a logger service
  outputPort emailService { ... }
  outputPort logger { ... }
  
  // Access point for clients
  inputPort EmailServiceDecorator {
    location: "socket://localhost:8080" protocol: MyProtocol // e.g., http
    interfaces: EmailServiceInterface // exposed API
  }

  // Implementation
  main {
    // Offer three operations: send, listEmails, and downloadEmail
    [ send( request )( response ) {
      send@emailService( request )()
      log@logger( request )()
    } ]

    [ listEmails( request )( response ) {
      listEmails@emailService( request )( response )
      log@logger( request )()
    } ]

    [ downloadEmail( request )( response ) {
      downloadEmail@emailService( request )( response )
      log@logger( request )()
    } ]
  }
}

Ouch, boilerplate again! Since we're modifying the behavior of every operation, we have to reimplement all of them. However, as already mentioned, the change is uniform: it is the same for all operations.

What we need is the capability of writing that logging code just once, and applying it to the entire API of the e-mail service. Jolie offers courier processes that can be applied to entire interfaces to achieve this. Here is what our code can look like by using a courier.

service EmailServiceDecorator {
  // Output ports to access the target e-mail service and a logger service
  outputPort emailService { ... }
  outputPort logger { ... }
  
  // Access point for clients
  inputPort EmailServiceDecorator {
    location: "socket://localhost:8080" protocol: MyProtocol // e.g., http
    aggregates: emailService // aggregate emailService
  }

  // A courier for input port EmailServiceDecorator
  courier EmailServiceDecorator {
    [ interface EmailServiceInterface( request )( response ) ] { // Apply to all operations offered by the e-mail service
      forward( request )( response ) // forward is a primitive: it forwards the message to the aggreated (decorated) service
      log@logger( request )()
    }
  }
}

No boilerplate again! In a courier, we can receive a message for any operation in a given interface, and then use the forward primitive to forward the message to the target service that we are decorating. We then invoke the logger.

Conclusion

People familiar with functional programming might recognize the idea of writing reusable uniform behavior (parametricity, generics, type erasure, etc.). Aggregation and courier processes extend this idea to entire APIs. But even if you're not using a language that supports these features, you can still benefit from the pattern by writing the implementation of each operation by hand.

The e-mail example is quite simple. In general, a decorator might modify the API by adding or hiding data fields to its types. Jolie supports adding data fields which can then be used by the decorator and are automatically removed by the forward primitive when calls are forwarded to the target service (hiding can be done manually, but a dedicated primitive is planned for future release).

An example where data fields are added is a decorator that requires an API key, checks that it's valid, and if so forwards the request to the target service (without the API key, which the target service doesn't need to worry about). Dually, an example of a decorator that hides a data field is one that receives calls from a trusted Intranet and automatically injects the necessary credentials to access the target service before forwarding the call.


Also published here.


Written by fmontesi | Professor of CS, Author of Introduction to Choreographies. Talks about programming, choreographies, and microservices.
Published by HackerNoon on 2022/10/06