DEV Community

Alan
Alan

Posted on

Clean Dependency Graph and Identity of a Code

Clean dependency graph and Identity of a code
Next: Ending the decades of war between declarative and imperative code - Complexity minimization is a form of tail risk management - Functional paradigm brought up to component level

Satisfying my newfound obsession of pouring my thoughts, this time not in the form of code, I'm sharing one of four random things I realized while rewriting a large, crufty code base.

This article is about clean dependency graph and identity of a code.

I'm using typescript, TSX (the typescript counterpart of the-now-popular-in-the-web-community JSX), and a little dose of React in this article. I hope you can see it the same as any other codes, even read it like a story.

Clean Dependency Graph

DGPlain.svg

A dependency graph

The work that I'm doing is pretty rare in nature. One of the challenges we need to solve is to write a conveniently forkable code that is easy for git merging and is customizable - an intimidating combination.

Codes shown below are example codes analogous to the real one. Due to the real one being proprietary, I cannot show any of it (not that I want to).

Clean dependency graph yields flexible codes. A dependency graph is a directed graph representing dependencies between modules in your code. For instance, A renderer module A importing a class of shareable state B would be A->B.

A golden rule for a clean dependency graph is if it is roughly divided horizontally and vertically, it fulfills these premises:

  1. Modules in the same row have similar methods
  2. Modules in the same column have the same domain
  3. Modules are vertically sorted from the least volatile to the most volatile and arrows should never point down.

Modules in the same row should have similar methods

DGRow.svg

Modules in the same row has similar role for different domain.

Take the example of a group of API Calls modules:

// UserAPI.ts

export function fetchUserById({ userId }:{userId:string}){
  return decodeOrError(
    () => networkManager.withCredentials().get(`http://domain/some/path?userId=${userId}`),
    UserDecoder,
    UserDecoderError
  )
}
// DepotsAPI.ts

export function fetchBuildByBuildIds({ buildIds }:{buildIds: string[]}){
  return decodeOrError(
    () => networkManager
      .withCredentials()
      .get(`http://domain/api/builds/?buildIds=${buildIds.join(",")}`),
    BuildDecoder,
    BuildDecoderError
  )
}

// TagsAPI.ts
export function addTag({ tags },{tags: string[]}){
  return decodeOrError(
    () => networkManager
      .withCredentials()
      .post(`http://domain/api/builds/?buildIds=${buildIds.join(",")}`),
    Decoder.unknown,
    CommonDecodeError
  )
}

Three modules concerning three different domains are done alike and form a role for the modules, which in this case is to call API endpoint, decode, and guard and cast type. Development wise it's easy to copy and paste codes between modules with the same roles and their tests, thus reducing cognitive load. It applies to either a team of developers or a single developer.

Modules in the same column have the same domain

This is pretty straight forward and intuitive.

DGColumn.svg

The concept is similar to micro front-end where separation of concern is the key. It creates a clear definition of business logic, right from the model definition, to the presentation layer.

// BuildModel

export const BuildDecoder = createDecoder({ 
...
})
export type Build = Type<typeof Build>

// BuildAPICall

import { BuildDecoder } from "src/module/build/model"

export function fetchBuilds(){
  return decodeOrError(
    () => networkManager
      .withCredentials()
      .get(`http://domain/api/builds/`),
    Decoder.array(BuildDecoder),
    BuildDecoderError
  )
}

export function addBuild({ build }: Build){
  return decodeorError(
    () => networkManager
      .withCredentials()
      .post('http://domain/api/builds/', build),
    BuildDecoder,
    BuildDecoderError
  )
}

// BuildsViewState

import { StateManager } from "src/utils/state-manager"
import { Build } from "src/module/build/model"
import { fetchBuilds, addBuild } from "src/module/build/api"

type State = {isFetching: boolean, builds: Build[] | null, error: Error | null}

export class BuildsViewState extends StateManager<State>{
  state: State = {
    isFetching: boolean,
    builds: null,
    error: null
  }

  // override empty init method
  init(){
    try{
      if(this.state.isFetching) return
      this.setState({ isFetching: true })
      this.setState({ builds: await fetchBuilds(result => {
          if(result.error) throw result.error
          return result.response.data
        }) 
      })
    } catch(error) {
      this.setState({ error })
    } finally {
      this.setState({ isFetching: false })
    }
  }

  // inherits empty deinit method
}

// BuildViewPage

import { React } from "react"
import { Loading, CommonError } from "src/common/components/loading"
import { BuildViewState } from "src/utils/build/page/view"

export class BuildViewPage extends React.Component {
  sharedState: new BuildViewState();

  componentDidMount(){
    this.sharedState.init()
    this.sharedState.subscribe(() => this.setState({}))
  }

  componentWillUnmount(){
    this.sharedState.deinit()
  }

  render(){
    const { isFetching, builds, error } = this.sharedState.state
    return (
      <section>
        {isFetching && (
          <Loading>
            Loading your Builds. Please Wait.
          </Loading>
        )}
        {error && (
          <CommonError error={error} />
        )}
        {builds && builds.map(build => (
          <pre key={build.id}>
            {JSON,stringify(build, null, 2)}
          </pre>
        )}
      </section>
    )
  }
}

An intersection between the row and the column creates an identity of the module, say the app is a web app to manage builds of software versions and it has a BuildViewPage - BuildViewPage can be defined as a module which present view (role) of the build (domain).

The key is in the next rule.

Modules are vertically sorted from the least volatile to the most volatile and arrows should never point down.

These days, importing other modules is as easy as pressing alt+enter, and even some IDE support not-pressing-anything feature to do that. Let's call it import convenience. Import convenience pulls us away from contemplating on why and how do we import modules, or in my word, the art of keeping dependency graph clean.

This rule siphons the essence of importing, that less volatile module should not import more volatile module. Volatile here refers to being prone to changes.

DGViolationOfRule3.svg

Violation of Rule 3. A module imports from a more volatile module

Sometimes it is tempting to import anything without thinking of its consequences, it's never forbidden by compilers anyway. But notice that when a dependency module change, the dependent module may also change. The dependent may change explicitly (need a change of code), or implicitly (changes are inherited).

Let the fact below be true:

let B->A or A<-B means B depends on A

AND:
- A<-B
- B<-C
- B<-D
- B<-E

When A changes B, C, D, and E may also change.
A change in module A results in, at least 0 changes, at most 4 changes.
Which means at least 0 additional effort for a code change, at most 4 additional efforts for the code change. And an exact 5 additional efforts to test the changes.

So at least 2 unit of work, at most 10.

I might sound lazy for calculating a very little amount of changes, until when this rule is applied on a much grander scale, on a big project with a complicated dependency graph.

But it was only a small reason why I needed the rewrite. The reason was that the old code didn't have clear visibility of the graph.

"Rule 3 doesn't make sense"

In first glance, Rule 3 sometimes doesn't make sense.

For example, here is a file that stores information about Build. This file is imported everywhere, including helper modules, factory modules, presentational modules, shared state modules, etc.

// build/model.ts

type Build = {
  id: string,
  version: string,
  patches: Patch[]
}

type Patch = {
  srcUrl: string,
  position: number
}

type BuildResponse = {
  data: null,
  errorCode: number
} | { 
  data: Build[], 
  errorCode: null 
}

function deduceBuildError(errorCode){
  switch(errorCode){
    case 1000: return "Build not found"
    case 1001: return "Build corrupt"
    case 1002: return "Build being switched"
    default: return "Unknown Error"
  }
}

The twist is that deduceBuildError is used by the presentational module to render error message and there is a lot of requests for changes to the deduceBuildError function for UX reason. Although the change should only implicitly affect presentational module, it risks of other module being implicitly changed.

This is due to the corrupted Identity of the file build/model.ts. Being a model it should not have deduceBuildError which deduce error message based on the errorCode. Deducing build error message is simply not its role.

The correct way is to move it out of the model. It can be inside the presentational module since it is the only thing supposed to be affected by its change. The Identity of the presentational module is not corrupted because the role of the presentational module after the addition of deduceBuildError doesn't change. It can also be put into another file that resides right above presentational module.

The dirtiest kind of dependency graph

The dirtiest, worst kind of dependency graph, is to not have it and to have no guard from having a nonsensical kind of dependency, the circular dependency.

Having unclear or no dependency graph would mean:
No clue on where to add things
No clue on when to change things
No clear decision on when to apply DRY or to copy-paste
No clue on when to pull things out of modules
No rapid development
No productivity

Clean Dependency Graph in System Architecture Level

Dependencies happen between system architecture level. The likeliness of it being dirty is a lot less than codes as it moves slower than the change of code. Nevertheless, a misstep would cause problems that often happen to inter-component interactions, for example, bottlenecks on network calls.

Identity of a component is what you must focus on. Putting a feature not in a proper place, misnaming an endpoint, implicit behaviors, would make the system leans to a more dirty dependency graph.

More on this later, maybe.

Top comments (0)