DEV Community

Gert
Gert

Posted on

Building Enola, Part 2: From Source Code to an Architectural Fact Model

In Part 1, I explained why Enola extracts deterministic architectural facts before an AI agent begins reasoning.

That leaves the next design question:

How should those facts be represented?

The obvious answer is to build a graph containing every symbol, dependency, route, service, and repository.

But the difficult part is not putting nodes and edges into a graph.

It is preserving what those relationships mean.

A function call is not the same as a package dependency. A route registration is not an import. A type reference is not proof that two services share a contract.

If every relationship becomes a generic connection, the resulting graph may be traversable, but it is no longer reliable enough for architectural analysis.

Enola therefore begins with a typed architectural fact model.

Parsing gives us evidence, not architecture

A parser can tell us that a function call, string literal, import, annotation, or method declaration exists.

That is necessary, but it is not yet an architectural fact.

Consider a Go application:

api := router.PathPrefix("/api").Subrouter()
registerCourseRoutes(api)
Enter fullscreen mode Exit fullscreen mode

Elsewhere:

func registerCourseRoutes(router *mux.Router) {
    router.HandleFunc("/courses", listCourses)
}
Enter fullscreen mode Exit fullscreen mode

The parser can expose both string literals and both function calls.

The architectural fact is:

GET /api/courses
  -> handled_by listCourses
Enter fullscreen mode Exit fullscreen mode

Producing that fact requires understanding the framework, how routers compose paths, and how values move through function calls.

The same problem appears in Spring annotations, Rails scopes, Axum routers, Next.js file conventions, generated clients, dependency-injection frameworks, and message-bus configuration.

Enola therefore separates parsing from architectural extraction:

Source code
    ↓
Language and framework interpretation
    ↓
Architectural facts
Enter fullscreen mode Exit fullscreen mode

The parser provides syntax.

The extractor determines what the syntax means within the architecture.

Repository boundaries are extraction scopes

A Git repository is a convenient place to begin analysis.

It provides a source revision, configuration boundary, files, build metadata, and stable source locations.

But it is not necessarily a unit of architectural truth.

A monorepo may contain several independently deployed services. A small repository may depend on schemas, infrastructure, or generated clients maintained elsewhere. Runtime behavior may also depend on deployment configuration outside the application repository.

Enola therefore treats a repository as an independently addressable extraction scope.

Within that scope, Enola may establish facts such as:

Repository contains module
Module contains file
File declares symbol
Function calls function
Package imports package
Type implements interface
Route is handled by symbol
Producer publishes to topic
Enter fullscreen mode Exit fullscreen mode

These facts remain useful even when no other repositories are loaded.

The scope gives each entity a local identity and provenance. It does not imply that the entire architecture is contained inside the repository.

That distinction matters because source ownership boundaries and architectural boundaries are rarely identical.

Different facts require different identity rules

There is no universal identifier that works for every architectural concept.

A symbol may require:

  • extraction scope;
  • language;
  • qualified name;
  • source location;
  • source revision.

An HTTP route may require:

  • method;
  • normalized path;
  • service context.

A gRPC method may require:

  • package;
  • service;
  • method.

A Kafka topic may require:

  • resolved topic name;
  • namespace or environment context.

This is why names alone are insufficient.

Two repositories may both contain UserDTO without referring to the same contract. Conversely, a Go type called PublicUser and a Swift type called ProfileResponse may represent two sides of the same API.

Enola keeps those entities separate until there is evidence establishing a relationship between them.

Typed relationships preserve the reason two things are connected

A generic graph might represent:

A -> B
Enter fullscreen mode Exit fullscreen mode

But architectural analysis needs to know why that edge exists.

Compare:

CheckoutController
  -> calls PaymentService.authorize
Enter fullscreen mode Exit fullscreen mode
checkout
  -> imports payments
Enter fullscreen mode Exit fullscreen mode
POST /checkout
  -> handled_by CheckoutController
Enter fullscreen mode Exit fullscreen mode

These relationships support different questions.

A call edge may be useful for reachability.

An import edge may be useful for dependency-cycle detection.

A route-to-handler edge may be useful for tracing request execution.

Direction matters as well. A client consumes a route. The route does not consume the client.

Enola therefore represents relationships as typed and directed facts rather than generic connectivity.

That still does not make every extracted edge equally strong.

A relationship should also retain the evidence used to produce it, including:

  • source locations;
  • extractor;
  • resolution method;
  • source revision;
  • configuration;
  • whether it was directly extracted or derived.

Without that evidence, the graph becomes another opaque answer.

One model, several analysis projections

The complete fact model contains more relationships than any single analysis should traverse.

The question determines which subset is relevant.

For package-cycle detection:

Nodes: packages
Edges: package dependencies
Enter fullscreen mode Exit fullscreen mode

For symbol reachability:

Nodes: symbols
Edges: calls and references
Enter fullscreen mode Exit fullscreen mode

For route execution:

Nodes: routes, handlers, services
Edges: handled_by and calls
Enter fullscreen mode Exit fullscreen mode

This matters because using every available edge can produce technically connected but architecturally meaningless paths.

Suppose a type belongs to a package, that package depends on another package, and the second package contains an HTTP route.

There is a path through the full graph.

That does not mean the type participates in the route’s execution.

Enola therefore constructs constrained projections for specific analyses instead of treating every traversal as equivalent.

A concrete example: why a generic dependency graph fails

Consider a frontend and backend stored in the same monorepo.

The frontend imports an API client package:

web
  -> depends_on api-client
Enter fullscreen mode Exit fullscreen mode

The backend imports a routing framework:

backend
  -> depends_on router
Enter fullscreen mode Exit fullscreen mode

A conventional dependency graph correctly records both relationships.

But it cannot answer:

Which frontend method consumes POST /api/orders?

The answer requires several additional facts:

submitOrder
  -> makes_request POST /api/orders
Enter fullscreen mode Exit fullscreen mode
POST /api/orders
  -> handled_by CreateOrderHandler
Enter fullscreen mode Exit fullscreen mode
CreateOrderHandler
  -> calls OrderService.Create
Enter fullscreen mode Exit fullscreen mode

The useful architectural path is not a package-dependency path.

It is a projection combining request, route, handler, and call relationships.

That is the reason Enola needs a typed architectural fact model rather than only a repository dependency graph.

The limit of the model

Enola constructs the architecture that can be established from the loaded source and configuration inputs.

It does not claim that source alone describes every aspect of a production system.

Deployment manifests, gateways, service meshes, runtime configuration, reflection, feature flags, and infrastructure may alter the architecture visible at runtime.

The model should therefore distinguish between:

  • established facts;
  • derived relationships;
  • unresolved relationships;
  • information outside the analyzed scope.

This is important for both developers and agents.

A missing edge does not always mean that no relationship exists. It may mean that the relevant source, configuration, or resolver was unavailable.

What comes next

A typed fact model can explain the architecture inside one extraction scope.

Production systems cross those scopes.

A mobile client calls a backend maintained elsewhere. A service publishes an event consumed by another repository. A generated client implements a contract defined in a separate codebase.

The next post will explain how Enola connects these independently extracted models without merging entities based on names or similarity alone.

Enola is open-source GitHub

Top comments (1)

Collapse
 
marcusykim profile image
Marcus Kim

Turning the two Go snippets into GET /api/courses -> handled_by listCourses is the clearest demonstration of why syntax trees alone aren't an architecture model: the useful fact only appears after composing the router prefix and following the registration call. Keeping calls, imports, handled_by, and makes_request as typed, directed relationships also makes the constrained projections for route execution and package-cycle analysis much more credible. The engineering tradeoff is extractor trust: provenance and unresolved states should be queryable first-class data, so teams can distinguish a genuinely absent dependency from a.