Skip to main content
How YAGNI Follows Dead Paths in Code
Back to blog

How YAGNI Follows Dead Paths in Code

YAGNI begins by enumerating entry points. People ask how we know something is "really" dead, and that is fair. Many tools claim to find dead code, then discover code that is merely conditionally dead, or dead only in a linter's simplified model but alive in production. The candid answer involves careful bookkeeping, several layers of conservative assumptions, and stating clearly what the system cannot see.

This post covers the actual algorithm: no marketing summary or box-and-arrow diagram, just the mechanics.

Step 1: Finding Entry Points

The traversal needs a starting point. In a closed system, each function is either an entry point or reachable from one. First, we identify every entry point: a function the runtime can invoke without another function in the same codebase calling it first.

For a typical backend service, that includes HTTP handler registrations, every route attached to a router, the main() or equivalent top-level execution function, scheduled job runners registered with a cron or task queue framework, event listeners registered at startup, and functions exported from the module boundary for calls by an external consumer.

Patterns differ by language. In a Python Django service, YAGNI scans URL conf files and gets view functions by following the urlpatterns list. In an Express.js app, it traces app.get(), app.post(), and router.use() registrations. For a Go HTTP server, it follows http.HandleFunc(), http.Handle(), and their wrapped equivalents.

We keep the entry point set intentionally broad. When it is genuinely unclear whether something qualifies, we include it. False negatives, calling a live function dead, are worse than false positives. Missing one entry point can lead to deleting code that still runs in production. On every close call, we choose the conservative side.

Step 2: Breadth-First Search

Starting at each entry point, the algorithm traverses the call graph breadth-first. Here, a "call" covers direct function invocations, method calls on concrete types, constructor instantiations, and import resolution for modules whose exports are consumed immediately.

The traverser keeps a visited set. Each function marked reachable goes into that set. If it later finds a function already there, it skips it. This cleanly handles cycles: mutual recursion ends without infinite loops, while shared utility functions become reachable on their first encounter.

Once traversal ends, functions absent from the visited set become removal candidates. But "candidate" matters. The traversal returns a list, not a verdict.

Step 3: Polymorphism and Dynamic Dispatch

Static call graphs fail when calls pass through interfaces or virtual method tables. Imagine a router choosing a handler implementation from a config value at runtime. The static analyzer knows that something implementing the Handler interface will be called, but without running the program it cannot know which concrete implementation executes.

YAGNI remains conservative here. When it finds a method call on an interface type, it adds every concrete implementation of that interface in the codebase to the reachable set. It does not narrow the set with type flow analysis unless the type is fully resolvable within the same function scope.

As a result, code technically unreachable in a particular configuration can still be marked live. That is the right tradeoff. An incorrect narrowing would label live functions dead. We accept false positives to avoid false negatives.

Step 4: The Reflection Problem

Interface dynamic dispatch is tractable. String-based dispatch is harder.

def dispatch(handler_name: str, event: dict):
    handler = REGISTRY.get(handler_name)
    if handler:
        return handler(event)

This appears often in plugin systems, command routers, and webhook handlers. The called function depends entirely on the string value of handler_name, which YAGNI cannot determine statically. It could be anything in REGISTRY.

YAGNI traces how registries are initialized. When REGISTRY is filled through explicit assignments in a discoverable initialization function, we follow those assignments and add the registered functions to the reachable set. When a config file or database read populates the registry at runtime, we cannot trace it. In that situation, every function that could plausibly be registered along any observed initialization path remains marked reachable.

We call this kind of code "opaque-adjacent": neither demonstrably dead nor demonstrably live. We show it separately instead of forcing it into either bucket.

Step 5: Confidence Scores

Dead-path candidates do not all have equal certainty. YAGNI gives each finding a confidence tier:

High confidence: The function appears in no call chain, is not registered as a handler, is not exported and referenced, and appears in no observed string-based dispatch pattern. Automated pull requests are opened for this tier.

Medium confidence: Static analysis finds the function unreachable, but its module contains dynamic dispatch patterns we could not fully resolve. These go to human review rather than automatic removal.

Low confidence: The function is unreachable by static analysis, but the module uses heavy reflection or eval-style patterns. We surface these as observations. They stay untouched until runtime signal data changes the picture.

Runtime traces can alter confidence scores substantially. A function absent from a production request trace for 90 consecutive days gets its confidence score upgraded. A function present in a trace last week leaves the candidate list entirely, no matter what static analysis says, because static analysis is wrong about something and runtime knows better.

Step 6: Test-Only Paths

Test-only code needs separate treatment. Test helper functions, fixture factories, mock builders, and test utilities may be called extensively, but only by test suites. A naive analysis marks them live because they are reachable. They are not reachable from production entry points.

YAGNI treats test directories as a separate entry point domain. Functions reachable only from test entry points are tagged test-scoped, not production-live. They are not immediate removal candidates since tests still need them, but they are not counted as production-live code when computing the reachable set.

Correct handling depends on knowing which directories contain tests, and that varies by project. YAGNI reads .yagniignore and checks common conventions, including test/, tests/, __tests__, and spec/. Teams with unconventional layouts should configure this explicitly.

What the System Cannot Do

We should be direct about the limits. YAGNI's path tracer cannot handle fully dynamic code generation. A codebase that builds function names at runtime by string concatenation, calls exec() on dynamically generated strings, or routes through external configuration with no static footprint has a call graph portion permanently opaque to any static analysis approach.

We do not bluff through those regions. When a graph area cannot be resolved, we mark its boundary explicitly, report what is known and unknown, and leave the ambiguous region alone. The result is a confidence-weighted map, not a binary alive-or-dead list.

The traversal does not aim for perfect completeness. It gives a reliable signal about where removal is safe, keeps tests green, and avoids requiring an engineer to manually audit thousands of functions before trusting the result.

Try YAGNI on your codebase. Free plan, no credit card required.

Get Early Access

More from the blog

View all articles