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

  • 139