The circularity hydra
and how to defeat it
Valeriy Kuzmin co-authored by Tamas Hegedus
Moonfare, Berlin, 2022
The problem
// ClassA.ts
import { B } from "./ClassB";
export class A extends B {
}
export const UsefulStuff = "Useful";
// ClassB.ts
import { UsefulStuff } from './ClassA';
console.log(UsefulStuff);
export class B {
}
Modules?
ReferenceError: Cannot access 'UsefulStuff' before initialization
The cause
In order to prevent an infinite loop, an unfinished copy of the a.js exports object is returned to the b.js module. b.js then finishes loading, and its exports object is provided to the a.js module.
Why bother?
- Circular dependencies in JS projects are endemic
- They create unpredictable, cross-domain errors at runtime
- They make code tightly coupled
- Amount of circular dependencies is a good indicator of code quality
At moonfare
Cycle around src/common/entity/users/Investor.ts
src/common/entity/users/Investor.ts
new src/common/services/QuestionnaireService/SuitabilityStatuses.ts
src/common/services/QuestionnaireService/QuestionnaireServiceInterface.ts
src/common/entity/users/Investor.ts
Some recipes
1. Fully nominal dependency
// ClassA.ts
import { B } from "./ClassB";
export class A extends B {
}
export const UsefulStuff = "Useful";
// ClassB.ts
import { UsefulStuff } from './ClassA';
console.log(UsefulStuff);
export class B {
}
Demo: example-1
1. Solution - lift
- Just lift the common dependency out
- Always apply when you can, it's the easiest way out
- Leads to better SRP and less coupling automatically
- Automatable: in WebStorm press 'Move members' (typically F6)
How
Pros & Cons
2. Coupled implementation dependency
Demo: example-2
2. Solution - full decouple
- Split the coupled implementation into 2
- Sometimes it's hard and requires test coverage
- May seem unnatural
- Same pros as 'fully nominal lift'
Pros & Cons
2. Solution - apply service locator
- Extract the interface
- Depend on inteface + get the impl via locator
- In locator, init normally
- Doesn't really fix the circularity, hides the problem
- Service locator ideally shouldn't be used
- May be a lot of trouble for tests
- Easiest to achieve, prepares code for DI due to interface-splitting
Pros & Cons
2. Solution - use event calling
- Refactor the call into an event publishing
- Bind the dependency in an event handler on some top level, like with locator
- Not always applicable if you need a result or to wait for completion
- Again, only hides the problem
- Hard to refactor & bad for readability
- But still leads to less coupling
Pros & Cons
3. God class (e.g. 'router' switch)
Demo: example-3-4
3. God class (e.g. 'router' switch)
Demo: example-3-4
Note: it's a subproblem of 2, and sometimes not a root cause
3. Solution: apply events or even commands
Explicit routing is an improper implementation of events, do it right!
Pros:
- Same as event calling
Cons:
- Same as event calling
- May need to use commands (harder to write) since here waiting for result may be necessary
4. Bad barrel file
// f/a.ts
export const a = "a";
// f/b.ts
export const b = "b";
// f/index.ts
import {a} from "./a";
import {b} from "./b";
export {a, b};
// or
export * from "./a";
export * from "./b";
“… Because the problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle. “ — Joe Armstrong, creator of Erlang progamming language
A fitting quote
Why people use barrels?
- Fake export control
- Mental grouping when too lazy to make a folder
- Desire to not write Button/Button.tsx
Why barrels are bad?
- Aggravating pattern for circularity, almost guarantees you cycles
- Extra effort that duplicates a directory for little win
- Plain doesn't work as export control without a linter
- Without proper setup, horrible for treeshaking and bundle/docker image size
- Requires EXTRA linting to make right
Solution: Instead of barrels, use...
- WS: auto-imports out of the box
- VSCode: add .jsconfig file
- VIM: https://github.com/Quramy/tsuquyomi
Come on, it's 2022, and you're not writing in a notepad
Solution: Instead of barrels, use auto-imports
Pros:
- You write code faster
- No useless code leading only to problems
Cons:
- Used without understanding, they will give you even more cycles
5. ORM entity dependency
- 'True' cycles
- A bit controversial
Demo: example-5
5. Solution - cut the link in one direction
- Choose which is the 'main' out of 2 models
- In the 'subordinate' model, replace the TS type binding with string name binding
- Specify 'unknown' instead of actual type if necessary
- May be inconvenient to use
- Requires unique model names
- But completely eliminates the bad joins possibility
- Nicely fits into DDD
- Less coupling as usual
Pros & Cons
5. Alternative solution - allow for models only
- Configure depcruiser with tsPreCompilationDeps=false for models folder
- Force-ignore cycles produced by models, if they only contain model files
- More complicated linting config
- May not be foolproof against cycles
- Does not protect again bad joins
- But, easier to use code and only needs to be configured once
Pros & Cons
6. Hacky workarounds - delayed require
import x from "bad";
x.doStuff();
// convert into
setTimeout(() => {
const x = require("bad");
x.doStuff();
}, 0)
7. Hacky workarounds - type ignore
tsPreCompileDeps = false | "specify"
7. Hacky workarounds - type ignore
Demo: example-7
The end!
Questions?
The circularity hydra
By Valeriy Kuzmin
The circularity hydra
- 188