NestJS —Externalize cron expressions in a .env file

Mohamed I.
3 min readFeb 14, 2023

--

source: https://kinsta.com/

Hello there internet 👋

If you’ve landed here, you’ve probably heard of NestJS. If not, it is a Node.js framework for building efficient, scalable, and maintainable server-side applications. It uses TypeScript and offers a modular architecture with powerful features such as middleware, controllers, services, and providers. NestJS integrates well with popular libraries and tools, and provides a simple and intuitive way to structure an application, making it a great choice for building RESTful APIs, GraphQL APIs, and more.

One of the features of Nestjs is task scheduling. Thanks to the @nestjs/schedule, you can schedule any function to execute at a fixed date/time as the following :

import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';

@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);

@Cron('0 9 * * 1') // Or use any predefined expressions provided by the CronExpression enum
handleCron() {
this.logger.debug('Called every Monday at 9AM.');
}
}

A simple yet powerful codebase to send a weekly newsletter, dump some data from a db to a CSV file, activate your coffee machine every morning ☕️, you name it.

The issue with above code is that the cron expression passed to the `@Cron` decorator is not refactorable (ie. extracting it to a variable), so you’re not able to do something as the following 👇

//...

@Injectable()
export class TasksService {
// ...
private readonly MY_CUSTOM_CRON_EXPRESSION = '0 9 * * 1';

@Cron(this.MY_CUSTOM_CRON_EXPRESSION)
// ^^^^ Object is possibly 'undefined'.ts(2532)
handleCron() {
// ...
}
}

Since the`@Cron` decorator is executed at compile-time, it coudn’t manage to access to `this` which is, in the other hand, only defined at runtime… Too bad.

You might be tempted to opt for a constant declared outside the `TasksService` injectable class like so:

//...
const MY_CUSTOM_CRON_EXPRESSION = '0 9 * * 1';

@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);

@Cron(MY_CUSTOM_CRON_EXPRESSION)
handleCron() {
//...
}
}

This hack actually does the job… but it isn’t quite a good practice because the constant here is not scoped to the class itself. Besides, you couldn’t extract it to an environment variable since those are not yet initialised in that particular scope 😢.

The most elegant solution — if I may say so — that I have found after going through the official documentation and asking around in the official NestJS Discord server was to use dynamic crons + onModuleInit() lifecycle hook method to instantiate that cron job right after modules initialization.

Let’s dive into the code without further ado 😺

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';

@Injectable()
export class TasksService implements OnModuleInit {
private readonly logger = new Logger(TasksService.name);

constructor(
private schedulerRegistry: SchedulerRegistry,
) { }

onModuleInit() {
this.addCronJob(`make_coffee`, process.env.MY_CUSTOM_CRON_EXPRESSION, this.makeCoffee.bind(this));
}

makeCoffee() {
this.logger.debug('☕🥄');
}

/**
* Adds a dynamic cron job.
*
* @param name - the cron job name.
* @param cronExpression - a cron expression.
* @param callback - the function that will handle the actual actions of the cron job.
*/
addCronJob(name: string, cronExpression: string, callback: () => Promise<void>) {
const job = new CronJob(`${cronExpression}`, () => {
callback();
});

this.schedulerRegistry.addCronJob(name, job);
job.start();

this.logger.log(`The cron job ${name} has been added with the following cron expression : ${cronExpression}.`);
}
}

So the first thing you should do, is to inject a `SchedulerRegistry` into the service class to be used in the `addCronJob` method which acts, roughly speaking, like a dynamic cron job factory.

This method takes 3 parameters ;

  • `name: string`: A name that identifies our dynamic cron job,
  • `cronExpression: string`: A cron expression hooked to the cron job. The interesting thing here is that we can pass env variables since we’re in the right scope 🎉,
  • `callback: () => Promise<void>`: the actual function that will be triggered depending on the `cronExpression`.

Notice that `OnModuleInit()` comes from the OnModuleInit interface implemented by the service class. You should know that implementing that interface is optional (since it goes away after TS -> JS transcompiling) but highly recommended for better code readability.

I hope this trick will be useful to you guys 😃

Happy coding 👨‍💻 !

--

--