Decorating Microservices
The Decorator pattern is a great fit for modifying the behaviour 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 a better separation of concerns. It might even be necessary because the target service might be outside your control. This text looks at how a decorator can be implemented as a service, particularly 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 . 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 interface EmailServiceInterface
, as in the
following 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 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 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'll 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.
We need 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 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. A decorator might generally
modify the API by adding or hiding data fields to its types. Jolie supports
adding data fields that the decorator can use 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 receives calls from a trusted Intranet and automatically injects the necessary credentials to access the target service before forwarding the call.
We Provide consulting, implementation, and management services on DevOps, DevSecOps, Cloud, Automated Ops, Microservices, Infrastructure, and Security
Services offered by us: https://www.zippyops.com/services
Our Products: https://www.zippyops.com/products
Our Solutions: https://www.zippyops.com/solutions
For Demo, videos check out YouTube Playlist: https://www.youtube.com/watch?v=4FYvPooN_Tg&list=PLCJ3JpanNyCfXlHahZhYgJH9-rV6ouPro
If this seems interesting, please email us at [email protected] for a call.
Relevant Blogs:
Manage Microservices With Docker Compose
Monoliths to Microservices: Untangling Your Spaghetti
Microfrontends: Microservices for the Frontend
Recent Comments
No comments
Leave a Comment
We will be happy to hear what you think about this post