Plugin architecture: how we made Grafema extensible (and why it matters)
Grafema supports Express, Next.js, and NestJS out of the box. But every company has internal frameworks, custom ORMs, homegrown API patterns. “Out of the box” always means “the frameworks we happened to implement.”
The question is what happens when you hit the edge.
With CodeGraphContext, the answer is: open an issue and wait. The parser is hardcoded. Adding support for a new framework requires a change to the core library.
With Grafema, the answer is: write a plugin. Or have your AI agent write it. Here’s how that works.
The extensibility problem
Static analysis tools face a scaling problem. The number of frameworks, libraries, and custom patterns in the wild is effectively unbounded. A tool that can only analyze Express routes is useless in a NestJS shop. A tool that doesn’t understand your internal @ApiEndpoint() decorator is just as bad as no tool at all — the agent falls back to grep and you’ve gained nothing.
Two approaches to this:
Hardcoded parsers: Add support for each framework to the core library. Simple for users, but the maintainers decide what’s supported. You wait for someone to care enough to implement your framework.
Plugin API: Define a graph API and a pipeline contract. Let anyone add support for any framework by implementing a plugin. Maintainers don’t have to know your framework exists.
We chose plugins for a pragmatic reason: we can’t predict what you’re using. A tool that only works on the frameworks we’ve personally encountered isn’t useful at scale.
What a Grafema plugin is
The Grafema analysis pipeline has five phases:
DISCOVERY → INDEXING → ANALYSIS → ENRICHMENT → VALIDATION
A plugin is a class that declares which phase it runs in and implements an execute(context) method. The context gives it access to the graph, the current module, a logger, and the project manifest.
Analysis plugins are the most common type. They run per-module during the analysis phase. They parse AST nodes and create graph nodes and edges. If you want to detect a new kind of API endpoint, define a new node type, or recognize a custom ORM pattern — you write an analysis plugin.
Enrichment plugins run after all analysis is complete. They have access to the full graph and create edges between existing nodes. Cross-service connections (like HTTP request → backend handler) must be enrichment plugins because they depend on nodes from multiple files. The HTTPConnectionEnricher that powers cross-service tracing is an enrichment plugin.
Validation plugins run last and check graph invariants. They’re useful for enforcing architecture rules: “no direct database calls from frontend code,” “all routes must have a HANDLED_BY edge,” etc. They produce warnings and errors but don’t modify the graph.
Discovery and indexing plugins exist for more exotic cases — custom module systems, monorepo structures the built-in discovery doesn’t handle.
10 minutes: AI agent writes a NestJS plugin
NestJS uses decorators to define route handlers. A controller looks like:
@Controller('/users')
export class UsersController {
@Get('/:id')
async getUser(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Post('/')
async createUser(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
}
Out of the box, Grafema sees the functions but doesn’t know they’re HTTP handlers. The @Controller and @Get decorators are invisible to ExpressRouteAnalyzer — they’re a different framework.
In a recent session, an agent ran grafema analyze on a NestJS project and noticed the mismatch: 0 http:route nodes, but clearly dozens of route-shaped functions. It read the plugin development guide, then wrote this:
// .grafema/plugins/NestJSRouteAnalyzer.mjs
import { Plugin, createSuccessResult } from '@grafema/util';
const HTTP_METHOD_DECORATORS = ['Get', 'Post', 'Put', 'Delete', 'Patch'];
export default class NestJSRouteAnalyzer extends Plugin {
get metadata() {
return {
name: 'NestJSRouteAnalyzer',
phase: 'ANALYSIS',
creates: {
nodes: ['http:route'],
edges: ['CONTAINS', 'HANDLED_BY']
},
dependencies: ['JSASTAnalyzer']
};
}
async execute(context) {
const { graph, logger } = context;
let routesCreated = 0;
let edgesCreated = 0;
for await (const classNode of graph.queryNodes({ type: 'CLASS' })) {
// Find @Controller decorator to get the base path
const controllerPath = this.getDecoratorArg(classNode, 'Controller') ?? '';
for await (const methodNode of graph.getOutgoingEdges(classNode.id, ['CONTAINS'])) {
const method = HTTP_METHOD_DECORATORS.find(
d => this.hasDecorator(methodNode.dst, d)
);
if (!method) continue;
const subPath = this.getDecoratorArg(methodNode.dst, method) ?? '/';
const fullPath = `/${controllerPath}/${subPath}`.replace(/\/+/g, '/');
const routeId = `http:route:nestjs:${classNode.file}:${methodNode.dst.line}`;
await graph.addNode({
id: routeId,
type: 'http:route',
name: `${method.toUpperCase()} ${fullPath}`,
method: method.toUpperCase(),
path: fullPath,
fullPath,
file: classNode.file,
line: methodNode.dst.line,
framework: 'nestjs'
});
await graph.addEdge({ type: 'CONTAINS', src: classNode.id, dst: routeId });
await graph.addEdge({ type: 'HANDLED_BY', src: routeId, dst: methodNode.dst.id });
routesCreated++;
edgesCreated += 2;
}
}
logger.info(`NestJSRouteAnalyzer: created ${routesCreated} route nodes`);
return createSuccessResult({ nodes: routesCreated, edges: edgesCreated });
}
hasDecorator(node, name) {
return node.decorators?.some(d => d.name === name) ?? false;
}
getDecoratorArg(node, name) {
const decorator = node.decorators?.find(d => d.name === name);
return decorator?.args?.[0] ?? null;
}
}
Add NestJSRouteAnalyzer to .grafema/config.yaml, run npx @grafema/cli analyze again, and the NestJS routes appear in the graph. The cross-service tracing and Datalog queries that work on Express routes work on NestJS routes without any changes.
The agent wrote this in about 10 minutes. Most of that was reading the plugin guide — the implementation itself took maybe 4 minutes.
The graph API that makes this possible
Three methods cover 95% of plugin use cases:
graph.addNode({ id, type, name, file, line, ...attrs }) — create a node with a stable ID derived from file path and position. If you call this twice with the same ID, the second call is a no-op (or updates the node, depending on config).
graph.addEdge({ type, src, dst }) — create a directed edge between two existing nodes. Both nodes must exist. If either doesn’t, the edge is dropped and a warning is logged.
graph.queryNodes({ type, name }) — iterate over nodes matching the given attributes. This is the main way plugins read the graph. Returns an async iterator.
We kept the API surface small deliberately. Plugins should be writable in minutes, not days. A complex API discourages plugins; a simple API encourages them. The cost is that some complex patterns require multiple passes or creative use of enrichment plugins — but in practice, this constraint hasn’t been a problem.
Comparison: hardcoded vs. extensible
The hardcoded approach isn’t wrong. It’s simpler for the tool’s maintainers and produces high-quality support for each supported framework, because someone who deeply understands both the framework and the tool wrote the implementation.
The plugin approach trades that depth for breadth. A plugin written in 10 minutes might miss edge cases that a core implementation would handle. But a plugin that handles 90% of your cases is immediately useful. A core implementation that doesn’t exist yet is useless no matter how good it would eventually be.
There’s also a maintenance argument. Frameworks evolve. Express v5 changed the router API in ways that break some v4 assumptions. If every framework is hardcoded in the core, every framework evolution is a core maintenance burden. If frameworks are plugins, the plugin author handles their own compatibility.
The right answer depends on your use case. For common frameworks (Express, Next.js, NestJS), we’re committed to maintaining first-party plugins. For everything else — your internal ApiGateway class, your company’s custom ORM, the legacy framework from 2015 that you can’t migrate away from — plugins are the answer.
How to write your own plugin
The plugin development guide has the full reference. Short version:
- Create
.grafema/plugins/MyPlugin.mjsin your project - Export a default class extending
Pluginfrom@grafema/util - Implement
get metadata()— declare your phase, dependencies, and created node/edge types - Implement
async execute(context)— your analysis logic - Add your plugin name to
.grafema/config.yaml - Run
npx @grafema/cli analyze
The guide includes complete working examples: a simple hello-world plugin, a TODO comment detector, and a complete FastifyRouteAnalyzer example.
What we want plugins for
Frameworks we don’t have good first-party support for yet:
- Fastify — the guide includes an example, but it’s not distributed as a package yet
- Hono — increasingly common in edge runtimes
- tRPC — procedure definitions look nothing like Express routes
- Prisma — ORM queries as graph nodes would unlock data flow tracking to the database
- TypeORM / Drizzle — same argument as Prisma
If you write a plugin for any of these and want to share it, open a PR. We’ll review it for inclusion in the @grafema/plugins package.
Plugin development guide: /docs/plugin-development. Get started with Grafema: npx @grafema/cli init.