Part 1: Refactor a legacy Worker Base - Part 1 - The long lasting pain
The refactoring solution I presented in this post is the solution that I wrote in C#. It doesn’t mean you cannot do this in Nodejs. It is just because the team is migrating away from Nodejs to C#. We are familiar with these tools and they are already available as standard pattern in C#.
First thing first: An IOC Container
We learnt this at university. Why the heck did we forget this? Is it because the program language allows us to make this mistake so easily or is it because of the community that encourages the bad behaviors everywhere?
As I mentioned earlier, scope management of the legacy codebase is awful. We actually used a function to surround the scope of a message and the derived class is actually just a collection of function, not a scope container. Every time we want to activate a new method, we have to pass all the parameters downstream.
class WorkerBase {
async start() {
let message;
do {
message = await pullMessages(1);
const context = this.buildContext(message);
// this processMessage function wrap the scope of a message
await this.processMessage(message, context);
} while (message != null);
}
}
// Worker service 1
class Worker1 extends WorkerBase {
myProp = 1;
async processMessage(message, context) {
logic1(message, context);
logic2(message, context);
myProp++; // this will mutate myProp and affect other message
}
logic1(message, context) {}
logic2(message, context) {}
}
We wrote JS in an OOP way but didn’t apply the OOP best practices!
The solution is so simple and is a standard pattern, supported everywhere in C# world. Simply use any IOC container to create, resolve components and manage the scope. Here is a simple example with MediatR and Autofac
The WorkerBase
class
public class WorkerBase<TMessage>
{
private readonly ILifetimeScope _scope;
public WorkerBase(ILifetimeScope scope)
{
_scope = scope;
}
public async Task Start()
{
TMessage message;
do
{
var message = await PullMessages<TMessage>();
await ProcessMessage(message);
} while (message != null);
}
private async Task ProcessMessage(TMessage message)
{
// use the IOC container to wrap an inner scope around message handler process
await using var innerScope = _scope.BeginLifetimeScope();
// build context metadata
var workerContext = innerScope.Resolve<MessageHandlerContext>();
workerContext.MessageId = message.MessageId;
// send to MediatR handler
var mediator = innerScope.Resolve<IMediator>();
await mediator.Send(message.Value);
}
}
and then in our MediatR Handler
class, simply resolve the context data in the constructor
// The instance of this class is actually a container just for this Message scope
public class Worker1
{
// The Request is also the model for the Message body
public class Request : IRequest
{
public Guid ClientId { get; set; }
}
public class Handler : IRequestHandler<Request>
{
private readonly MessageHandlerContext _context;
// resolve scope item from here
public Handler(MessageHandlerContext context)
{
_context = context;
}
public async Task<Unit> Handle(Request request, CancellationToken cancellationToken)
{
Logic1(); // no need to pass all data downstream
Logic2();
}
private void Logic1()
{
var messageId = _context.MessageId;
// other logic...
}
private void Logic2()
{
var messageId = _context.MessageId;
// other logic...
}
}
}
Of course, you need to register these items as scoped components instead of singleton ones, for example
// register with Autofac
builder.RegisterType<MessageHandlerContext>().AsSelf().InstancePerLifetimeScope();
The benefits?
- Scope management becomes dead easy, even for nested scopes.
- The components are loosely coupling, that means you can easily switch the implementation in different environments (Prod, Staging, Test)
- Since the components are managed by the IOC Container, which is independent from the business logic, we can easily even switch the IOC Container logic to embed the Handler class logic into a different runtime (Http Server, Timer worker, One-of script, Lambda function,…)
To be continued…
Part 3: Refactor a legacy Worker Base - Part 3 - A Better Design