elvis/portfolio
back to all posts
ToolingMarch 5, 2026·6 min read

I stopped trusting my own architecture diagram, so I generated one from the code

A weekend script that parses SignalHQ's own NestJS modules into a dependency graph, and why the diagram in the README couldn't be trusted anymore.

Somewhere around module twelve of SignalHQ, I opened the architecture diagram I'd drawn in the README to remind myself how two services actually talked to each other, and caught myself not fully trusting it. Not because it was wrong exactly, more because I genuinely couldn't remember if I'd updated it after the last refactor, and a diagram you're not sure is current is worse than no diagram, because it actively lies to you with confidence instead of just staying silent.

That's a bad feeling to have about your own project, so I spent a Saturday doing something slightly unhinged about it: instead of redrawing the diagram, I wrote a script to generate it directly from the actual NestJS source, so it would never be able to lie to me again, because it would never be drawn from memory in the first place.

The insight that made this worth doing at all

A hand-drawn architecture diagram describes what you believe the code does. It's written from memory, updated when you remember to, and it drifts the instant reality changes and you don't. A diagram parsed straight out of an abstract syntax tree describes what the code actually does, right now, because it has no memory to trust and nothing to forget — it just reads the file. That's the entire pitch, and once I framed it that way, it was obvious why my README diagram felt untrustworthy: it was a diagram of a version of SignalHQ that no longer fully existed.

NestJS turned out to be a genuinely good target for this, because its dependency injection system already forces every module to declare its relationships explicitly, in a decorator, in a predictable shape:

@Module({
  imports: [IncidentsModule, NotificationsModule],
  controllers: [AuditController],
  providers: [AuditService],
  exports: [AuditService],
})
export class AuditModule {}

That @Module decorator is basically a structured confession. It's already telling you exactly what depends on what — I didn't need to infer relationships from call sites scattered across the codebase. I just needed to read the decorator.

Reading the shape without running the code

The tool that makes this possible is the TypeScript Compiler API — the same machinery tsc itself uses to type-check your code, exposed as a library you can call directly. It parses a file into an AST without ever executing a line of it, which is exactly the property you want: a script that has to run your codebase to document it is fragile in exactly the way a hand-drawn diagram is fragile, just with extra steps.

import * as ts from "typescript";

const sourceFile = ts.createSourceFile(
  filePath,
  fileContents,
  ts.ScriptTarget.Latest,
  true
);

That gives back a tree, and walking it is just recursion — visit a node, check if it's the shape you care about, recurse into its children:

function findModuleDecorator(node: ts.Node): ts.Decorator | undefined {
  if (ts.isClassDeclaration(node) && node.decorators) {
    return node.decorators.find((d) =>
      ts.isCallExpression(d.expression) &&
      ts.isIdentifier(d.expression.expression) &&
      d.expression.expression.text === "Module"
    );
  }
  return undefined;
}

Once I had a @Module decorator node in hand, pulling the imports array out of it was just walking further into the same tree — find the property assignment named imports, read its array literal, collect the identifier text of each element. Mechanical, but genuinely satisfying in the way that finding the exact right tool for a job usually is.

Turning a list of edges into something worth looking at

Once every module in the project had been walked this way, I had, structurally, nothing more than a big list of moduleA depends on moduleB pairs. That's already more trustworthy than a hand-drawn diagram, but it's not readable — a list of forty edges doesn't tell your eyes anything a diagram does. So the last step was feeding that edge list into Graphviz's DOT format, which turns "describe your graph as text" into "get a laid-out diagram back":

function toDot(edges: [string, string][]): string {
  const lines = edges.map(([from, to]) => `  "${from}" -> "${to}";`);
  return `digraph SignalHQ {\n  rankdir=LR;\n${lines.join("\n")}\n}`;
}

Piped through dot -Tsvg, that text became an actual picture, and running it against the real project immediately surfaced something my hand-drawn version had quietly gotten wrong: a dependency from the audit module back into the incidents module that I genuinely didn't remember adding, and that had no business existing given how I'd designed the ownership boundaries. It wasn't a bug exactly — nothing broke — but it was architectural drift I'd been carrying around unknowingly, and the generated graph caught it in about four seconds flat, purely by refusing to draw anything I hadn't actually written.

Where this stays a weekend tool, on purpose

I want to be honest about the scope here, because it's tempting to describe a script like this as a whole platform once it works, and it isn't one. This is roughly two hundred lines that understands @Module decorators in a NestJS project and nothing else — no Spring support, no framework detection, no UI, just a script I run against SignalHQ's src/ folder when I want the truth instead of my memory of the truth. Generalizing it into something that auto-detects arbitrary frameworks would be a genuinely different, much bigger project, and I don't think it's one worth building until I actually need it for a second codebase.

What I actually learned

I don't think the lesson is "always generate your docs, never draw them by hand" — a rough whiteboard sketch during a design conversation is still the fastest way to think out loud with someone, and no AST parser replaces that. The lesson is narrower: any diagram whose accuracy depends on someone remembering to update it will eventually be wrong, silently, and you won't find out until the moment you trust it most. A diagram derived straight from the source can't drift, because there's nothing to remember. It just reads the file again.

That distinction sounds obvious written down like this. It didn't feel obvious the day I caught myself hesitating over my own README, unsure whether the picture in front of me was still true. That hesitation is the whole reason this script exists.