Automate Circular Dependency Detection in your Node.js Project
Whether you are working on a large project, collaborating on a module or maybe just coding individually on a significant feature, there are chances you may introduce a circular dependency in your code. To keep the codebase maintainable and scalable, it’s important to avoid circular dependencies.
Usually, a circular dependency indicates bad code design, but there can be other instances as well. Let’s say there was already a dependency in your code base — A → B → C and E → F → A and if you did some changes in file C such that there is another dependency C → E that completed the circular link A → B → C → E → F → A and Voila! there is a new circular dependency in your system now.
Ideally, pre-deployment testing should catch the effects of circular dependencies without fail but in my case, I didn’t have a wholesome testing system in place with good code coverage.
Circular Dependencies Wreak Havoc
I vividly remember, on 3rd January 2023, I pushed a big feature to the production environment and Bang! A very high RPM order/update API started throwing a 500 Error — OrderClass is not defined. I had tested my feature using order/create API but had missed testing order/update. Manually Detecting Circular Dependencies is not intuitive. Even when I knew the faulty API, debugging it really took a lot of time. I had to put debug statements to exactly drill down to the imports spread across multiple files that were causing the issue.
I set out a goal that day that I just wanted one less thing to be worried about every time I set out to deploy something. These were the broad objectives -
- An automated solution to detect new circular dependencies in the project.
- The developer should be alerted in case their code has circular dependencies.
- This information is also available to the code reviewer.
The search began for a tool to find circular dependencies.
No Cycle — ES Lint Plugin
The circular dependency check is directly available in ES Lint, with an option to ignore defined circular dependencies as well, but I couldn’t readily use it because the number of circular dependencies I had to ignore was large.
With this, I realised that I needed the list of circular dependencies in a format in which they can be parsed and stored so that the circular dependencies of any two branches can be compared against each other.
Madge
I used madge amongst many other available packages for detecting circular dependencies because it is actively managed and has a very easy-to-use API.
Detecting Circular Dependencies
getCircularDependencies()
The function getCircularDependencies
returns all circular dependencies of the current branch, with an option to log them.
const getCircularDependencies = async (path = './', options = {}) => {
const { log: createLogFile = false, fileName } = options;
const result = await madge(path);
const circularDependencies = result.circular();
if (createLogFile) {
await generateCircularDependenciesLogFile(circularDependencies, fileName);
}
return circularDependencies;
};
masterCircularDeps.json
Let’s call the base branch master
. The file masterCircularDeps.json
contains the list of circular dependencies that are already present in the master
branch. The objective is to avoid pushing new circular dependencies into the system. Every time we calculate circular dependencies for the current branch, we ignore the dependencies that are already there in the master
branch. masterCircularDeps.json
is generated by running getCircularDependencies
on the master
branch. It should be committed to the git repository.
This file needs to be updated when a branch —
- reduces circular dependencies.
- introduces new circular dependencies that are known, carefully planned and tested which are required to be ignored.
findNewCircularDependencies()
The function findNewCircularDependencies
returns —
- the list of all the circular dependencies of the current branch —
branchCircularDependencies.
- the newly introduced circular dependencies —
newCircularDependencies
.
masterCircularDependencies
is the variable holding the circular dependencies of the master
branch. findNewCircularDependencies
compares every dependency of the current branch against masterCircularDependencies
. If it is unable to find the exact same dependency in masterCircularDependencies
it adds it to the list of newCircularDependencies
.
const findNewCircularDependencies = async () => {
const branchCircularDependencies = await getCircularDependencies('./', {
log: !isGithubActionEnv,
});
if (branchCircularDependencies.length === masterCircularDependencies.length
&& lodash.isEqual(masterCircularDependencies, branchCircularDependencies)) {
return { branchCircularDependencies, newCircularDependencies: [], };
}
const newCircularDependencies = [];
for (const dependency of branchCircularDependencies) {
const existingDependency = masterCircularDependencies.find((d) => lodash.isEqual(d, dependency));
if (!existingDependency) {
newCircularDependencies.push(dependency);
console.error('new-circular-dependency::', dependency.toString());
}
}
return { newCircularDependencies, branchCircularDependencies };
};
Notifications for new Circular Dependencies — Github Actions at Work
At first, I implemented this check as a pre-push hook in the project, but with 2500 files to process it took around 3 minutes for every push operation, which made the development experience bad. Also, it would have been very easy for the developer to bypass the pre-push hook and the code reviewer would not have any visibility into the circular dependency status of the pull request (PR).
So, I created a Github Action which is triggered whenever a PR is opened. You can configure the trigger point for your use case. It runs test/circular-dependency-check/index.js
via an npm script and comments on the PR in case a new circular dependency has been detected, or some circular dependency has been reduced. This comment triggers a notification to the developer who opened the PR.
You can find the whole code here.
test/circular-dependency-check/index.js
const GithubActionsCore = require('@actions/core');
const fs = require('fs');
const lodash = require('lodash');
const madge = require('madge');
const masterCircularDependencies = require('./masterCircularDeps.json');
const GITHUB_PR_FILE_COMMENT_CHAR_LIMIT = 60000;
const GITHUB_ACTION_NODE_ENV = 'gh-action';
const isGithubActionEnv = process.env.NODE_ENV === GITHUB_ACTION_NODE_ENV;
const FILE_CHAR_LIMIT = isGithubActionEnv ? GITHUB_PR_FILE_COMMENT_CHAR_LIMIT : 0;
const getCurrentGitBranchName = () => {
return new Promise((resolve) => {
try {
const { exec } = require('child_process');
exec('git rev-parse --abbrev-ref HEAD', (err, stdout, stderr) => {
if (err) {
throw err;
}
if (typeof stdout === 'string') {
const gitBranchName = stdout.trim();
return resolve(gitBranchName.replace(/\\|\//g, '-'));
}
throw new Error('Unable to get branch name');
});
} catch (e) {
return resolve('yourBranch');
}
});
};
const generateStringForNumber = (num = 1, options = {}) => {
const { baseLength = 5, radix = 36 } = options;
let str = num.toString(radix);
if (str.length < baseLength) {
str = new Array(baseLength - str.length + 1).join('0') + str;
}
return str;
};
const getSlicedText = (charLimit, text) => {
if (!charLimit) {
return text;
}
return text.slice(0, charLimit);
};
const generateCircularDependenciesLogFile = async (circularDependencies, fileName, characterLimit = 0) => {
if (!Array.isArray(circularDependencies) || circularDependencies.length === 0) {
return;
}
const fileNameToUse = !fileName ? await getCurrentGitBranchName() : fileName;
const randomString = generateStringForNumber(new Date().getTime());
const textToWrite = getSlicedText(characterLimit, JSON.stringify(circularDependencies));
const fileToWrite = `./test/circular-dependency-check/${fileNameToUse}-CircularDeps_${randomString}.log`;
return new Promise((resolve, reject) => {
fs.writeFile(fileToWrite, textToWrite, (e) => {
if (e) {
console.error('Error in generateCircularDependenciesLogFile', e && e.message);
return reject(e);
}
console.log('Wrote successfuly to', fileToWrite);
return resolve(fileToWrite);
});
});
};
const getCircularDependencies = async (path = './', options = {}) => {
const { log: createLogFile = false, fileName } = options;
const result = await madge(path);
const circularDependencies = result.circular();
if (createLogFile) {
await generateCircularDependenciesLogFile(circularDependencies, fileName);
}
return circularDependencies;
};
const findNewCircularDependencies = async () => {
const branchCircularDependencies = await getCircularDependencies('./', {
log: !isGithubActionEnv,
});
if (branchCircularDependencies.length === masterCircularDependencies.length
&& lodash.isEqual(masterCircularDependencies, branchCircularDependencies)) {
return { branchCircularDependencies, newCircularDependencies: [], };
}
const newCircularDependencies = [];
for (const dependency of branchCircularDependencies) {
const existingDependency = masterCircularDependencies.find((d) => lodash.isEqual(d, dependency));
if (!existingDependency) {
newCircularDependencies.push(dependency);
console.error('new-circular-dependency::', dependency.toString());
}
}
return { newCircularDependencies, branchCircularDependencies };
};
const run = async () => {
const branchName = await getCurrentGitBranchName();
const { newCircularDependencies, branchCircularDependencies } = await findNewCircularDependencies();
const newCircularDepsFilePath = await generateCircularDependenciesLogFile(newCircularDependencies, `new-${branchName}`, FILE_CHAR_LIMIT)
.catch((e) => {
console.error('Unable to write new circular dependencies file', e && e.message);
});
console.log(`Expected ${masterCircularDependencies.length} Circular Dependencies. Got ${branchCircularDependencies.length}`);
const isCircularDependencyCountReduced = branchCircularDependencies.length < masterCircularDependencies.length;
const isNewCircularDependencyIntroduced = newCircularDependencies.length > 0;
GithubActionsCore.setOutput('newCircularDepsFilePath', newCircularDepsFilePath);
GithubActionsCore.setOutput('isCircularDependencyCountReduced', isCircularDependencyCountReduced);
GithubActionsCore.setOutput('isNewCircularDependencyIntroduced', isNewCircularDependencyIntroduced);
// success cases
if (isCircularDependencyCountReduced && !isNewCircularDependencyIntroduced) {
console.log(`
Good Job !
You reduced Circular Dependencies from ${masterCircularDependencies.length} to ${branchCircularDependencies.length}.
Please update circular dependencies in test/circular-dependency-check/masterCircularDeps.json
`);
}
if (!isNewCircularDependencyIntroduced) {
return;
}
// error cases; // failure is marked in github actions to ensure steps are run to comment on the PR
if (isNewCircularDependencyIntroduced) {
console.error(`${newCircularDependencies.length} new circular dependencies detected.`);
}
return;
};
run().catch((e) => {
GithubActionsCore.setFailed(e && e.message || '✖ Failed due to an expected error!');
process.exitCode = 3;
});
Add it as a script in package.json. This enables the developer to run this on the local machine in case of any issues and skip waiting for the workflow to run with every change to check the status of circular dependencies.
package.json
{
...,
"scripts": {
"start": "node app.js",
"dev": "NODE_ENV=dev node app.js",
"validate-circular-dependencies": "node test/circular-dependency-check",
....,
},
}
.github/workflows/sanitize-pr.yml
name: ⚙️ Sanitize PR
on:
pull_request:
branches: [ master ]
types: [opened, synchronize, reopened]
env:
NODE_ENV: 'gh-action'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
circ-dep-check:
name: Circular Dependency Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: ⬇️ Checkout Project Repo
uses: actions/checkout@v3
- name: ⎔ Setup node
uses: actions/setup-node@v3
with:
node-version: <YOUR NODE VERSION>
cache: 'npm'
- name: 📥 Install Project Dependencies
run: npm ci
- name: Validate Circular Dependencies
id: nocirculardeps
run: npm run validate-circular-dependencies
- name: Comment PR notifying about reduced circular deps
uses: thollander/actions-comment-pull-request@v2
if: ${{ steps.nocirculardeps.outputs.isCircularDependencyCountReduced == 'true' && steps.nocirculardeps.outputs.isNewCircularDependencyIntroduced == 'false' }}
with:
message: |
:round_pushpin: This PR reduces circular dependencies :infinity:. Make sure to update them in test/circular-dependency-check/masterCircularDeps.json @${{ github.event.pull_request.user.login }}
- name: Comment PR notifying about new circular deps
uses: thollander/actions-comment-pull-request@v2
if: ${{ steps.nocirculardeps.outputs.isNewCircularDependencyIntroduced == 'true' }}
with:
message: |
:round_pushpin: Detected new circular dependencies :infinity:. Details in next comment @${{ github.event.pull_request.user.login }}
- name: Comment PR with new circular deps file
uses: thollander/actions-comment-pull-request@v2
if: ${{ steps.nocirculardeps.outputs.isNewCircularDependencyIntroduced == 'true' }}
with:
filePath: ${{ steps.nocirculardeps.outputs.newCircularDepsFilePath }}
- name: Set Job Failed if New Circular Dependency is introduced
if: ${{ steps.nocirculardeps.outputs.isNewCircularDependencyIntroduced == 'true' }}
run: exit 1
An Example Run
The new circular dependency introduced in the PR is —
[
[
"order/order.js",
"order/order-cash-settlement.js",
"order-event/assign-to-worker.js",
"worker/worker.js",
"worker-subscriber/delivered.js",
"order-event/delivered.js"
]
]
Here the circular linkage is —
order/order.js requires order/order-cash-settlement.js,
order/order-cash-settlement.js requires order/event-assign-to-worker.js,
order/event-assign-to-worker.js requires worker/worker.js,
worker/worker.js requires worker-subscriber/delivered.js,
worker-subscriber/delivered.js requires order-event/delivered.js
and order-event/delivered.js again requires order/order.js
And this creates a circular dependency.
I have kept the project under the covers for a long now, as I was trying to improve it even further, but with limited time and the workflow serving just well, I decided to share it.
Limitations and Future Scope
Github Comments have a character limit
The new circular dependencies are alerted via Github comments. The output can be really large sometimes and Github does not allow very large comments on PR, so the output of the new circular dependencies is limited.
Finding Changes that generated New Circular Dependencies can be difficult
In case the number of circular dependencies in a PR is large, it is really difficult to find exactly the files that have the changes that have caused the issue. I have been unable to get the workflow to just comment on the PR about the changed files that have brought new circular dependencies. I tried to get the changed files info via an action, but that slows down the workflow by another 3 minutes. There is a Github API though to get the info on changed files.
New Circular Dependencies generated while refactoring files with existing circular dependencies can go undetected
This was an interesting scenario that really caught me off-guard.
Madge uses the require
and import
statements in the files to create dependency graphs and not their actual placement — for example -
both of the variations of this file b.js
, which requires a.js
, will have the same effect on dependency graph generation in madge.
Variation 1 of b.js
const a = require('../a.js');
const foo = (bar) => {
a.baz(bar); // throws error
};
Variation 2 of b.js
const foo = (bar) => {
const a = require('../a.js');
a.baz(bar); // OK
...
};
Let’s assume the circular dependency is B → A → C → B.
While the function foo
in variation of b.js
throws an error — a is not defined — because of the circular dependency, variation 2 works as normal. Let’s assume Variation 2 b.js
was already in the master
branch, so the circular dependency will also be present in the ignore file masterCircularDeps.json
. If b.js
is now refactored to variation 1, the circular dependency will not change, because the require
statements have not changed, so it may go unnoticed and cause an issue. So if you are refactoring some files which already had some circular dependencies be extra careful to test and make sure they are resolved.
Circular Dependency Check is not Reusable
The Circular Dependency Check, an npm script now, could also be a Github Action. I tried creating one, but couldn’t get it to work out. There could be another method as well, maybe an npm package built on madge. The objective is that the code that detects new circular dependencies and sends alerts remains in one place, and the community can help each other and collaborate.
Give it a shot!
If your Node.js project does not have a check for circular dependencies, it is highly recommended you implement one according to your specific use case. Try out my solution and let me know how it worked for you. I would be happy to include your suggestions as well. Even if you don’t implement my solution, do share your experiences with what you chose to get rid of circular dependencies in your project. Looking forward to hearing your stories!
Further Reading
Dependency Cruiser
Dependency Cruiser — available as an npm package is a powerful tool to find circular dependencies and validate them against user-defined rules. It is also actively managed and you can really customize it in lots of ways. It supports ignoring validation on different granularities — from all files in a module to a defined circular dependency. I found it at a later stage of my project. It can be used as an alternative to madge.