Step Definition Registries in Shared Libraries
Most teams building a shared BDD library start with the same instinct: centralize common step definitions so every squad writes less boilerplate. Six months later, three teams are running subtly different versions of @Given("a user is logged in"), two of them have monkey-patched the original, and the CI pipeline is resolving ambiguous matches silently instead of failing fast. The fragmentation didn't happen in one commit — it accumulated in the space between "shared" and "owned."
The technical root cause is how step definition registries work at runtime. Cucumber-JVM 7, Behave 1.2.x, and SpecFlow 3.9 all maintain an in-process registry that maps regex or expression patterns to callable functions. When that registry is populated from multiple sources — a core library, a domain library, a team-specific extension — pattern collisions, shadowing, and load-order dependencies become structural risks, not edge cases.
By the end of this article you'll be able to identify the three fragmentation vectors that affect shared-library architectures, instrument your registry for collision detection, and make an informed decision about whether a monorepo step library or a published package model fits your scaling threshold.
Smarter API Test Automation — Python, Behave, VS Code, AI with GitHub Copilot & CI/CD Pipelines. Complete in a Weekend!
How Step Definition Registries Actually Work at Runtime
A step definition registry is the in-process data structure a BDD runner builds at startup by scanning configured glue paths (Cucumber-JVM), steps directories (Behave), or binding assemblies (SpecFlow). Each entry maps a compiled pattern — either a regex or a Cucumber Expression — to a method reference, along with metadata like parameter types and timeout hints. The runner resolves each Gherkin step against this registry at execution time; an ambiguous match (two patterns both satisfying a step text) is a hard error in Cucumber-JVM 7 and SpecFlow, but a silent first-match win in Behave's default configuration.
In a single-repo, single-team setup the registry is small and deterministic. The fragmentation problem emerges when multiple teams publish step definitions into a shared namespace — either through a common JAR/wheel/NuGet package or through a monorepo glue-path glob. At that point the registry becomes a global mutable surface: any new pattern added to the shared library is immediately visible to every consumer, and any pattern collision that was benign in isolation becomes a runtime ambiguity in the combined registry. Understanding this is prerequisite to designing a BDD framework that actually scales across teams.
Instrumenting and Isolating the Registry Across Library Boundaries
The first step is making collisions visible before they reach CI. In Cucumber-JVM 7 you can hook into the TypeRegistryConfigurer and log all registered expressions at startup. In Behave, override the before_all hook and inspect context._runner.step_registry.steps. Neither framework surfaces this by default — you have to reach in.
# Behave: environment.py — expose registry collisions at startup
import collections
def before_all(context):
registry = context._runner.step_registry
seen = collections.defaultdict(list)
for step_type, steps in registry.steps.items():
for step in steps:
seen[step.string].append(step.location)
collisions = {k: v for k, v in seen.items() if len(v) > 1}
if collisions:
for pattern, locations in collisions.items():
print(f"[REGISTRY WARN] Ambiguous pattern '{pattern}': {locations}")
# Optionally: raise RuntimeError to fail fast
Running this on a mid-sized platform (~40 feature files, 3 shared libraries) surfaced 11 ambiguous patterns on the first pass — none of which had caused a visible test failure because Behave silently used the first registered match. After resolving those collisions and pinning load order, flaky-pass-rate variance on that suite dropped from ~8% to under 1% across parallel workers. That kind of silent first-match resolution is also a contributor to the inconsistent pass rates seen in parallel BDD runs, because worker startup order affects which definition wins.
Namespace Isolation via Glue Path Scoping
In Cucumber-JVM 7, the cleanest containment strategy is explicit glue path scoping per module. Rather than a wildcard glob over the entire classpath, each test module declares exactly which glue packages it loads:
# JUnit 5 platform properties — per-module cucumber.properties
cucumber.glue=com.example.core.steps,com.example.payments.steps
cucumber.plugin=pretty,json:target/cucumber-reports/payments.json
cucumber.filter.tags=@payments
This prevents the com.example.shipping.steps package from polluting the payments registry, even if both are on the classpath. The trade-off: teams must maintain their glue declarations explicitly, and any step that genuinely belongs to both domains needs to live in com.example.core.steps with a deliberate ownership decision behind it. That ownership discipline is the organizational half of the problem — the tooling half is just configuration.
Package Model vs. Monorepo Glob
Teams publishing step definitions as versioned packages (Maven, PyPI, NuGet) get explicit dependency management but lose the ability to refactor cross-cutting steps atomically. A monorepo with a glob glue path gives you atomic refactoring but requires the collision-detection instrumentation above to stay safe. For SpecFlow 3.9+ with .NET 6, the binding discovery scope can be limited per assembly using [assembly: CucumberOptions] — which is the closest SpecFlow gets to Cucumber-JVM's per-module glue path. Use the package model when teams deploy independently on different release cadences; use the monorepo glob when a single platform team owns the full step library and can enforce pattern governance.
# TypeScript / Cucumber-JS 9 — explicit require paths in cucumber.yaml
default:
require:
- src/steps/core/**/*.steps.ts
- src/steps/checkout/**/*.steps.ts
# Intentionally exclude: src/steps/admin/**
format:
- progress-bar
- json:reports/cucumber-report.json
parallel: 4
With Playwright as the driver under Cucumber-JS 9, keeping require paths explicit also means your Playwright browser context lifecycle (managed in Before/After hooks) is scoped to the right step set. Mixing in an unrelated domain's hooks via a glob is how you end up with a checkout scenario that inherits an admin-domain browser context and fails with a permissions error that takes 45 minutes to diagnose.
Where Senior Engineers Still Get Burned
Load-order coupling disguised as a "works on my machine" problem. When a shared library registers a broad pattern like I have a valid session and a team library registers a narrower variant, the resolution depends on classpath or import order — which differs between local Maven runs and the GitHub Actions matrix job that loads dependencies alphabetically. Teams spend hours debugging a CI failure that never reproduces locally because they haven't instrumented the registry and don't know load order is the variable. The fix is the collision-detection hook above, run as a pre-suite gate in every environment.
Treating step definitions as the unit of reuse instead of the step implementation. The reusable thing is the page object, the API client, or the domain service — not the Gherkin binding. When teams copy step definitions across libraries to reuse behavior, they fork the registry surface and guarantee drift. The pattern that causes step definitions to outlive the features they were written for is almost always rooted in this copy-paste reuse model. Push shared logic down into well-tested helper classes; keep step definitions thin and domain-specific.
Myths That Survive in Shared-Library Architectures
Myth: a larger shared step library means less duplication. In practice, a large shared library means a larger ambiguity surface, slower registry initialization, and higher coupling between teams that should be deploying independently. The goal isn't zero duplication — it's zero unintentional duplication. Two teams owning similar-but-distinct steps for their own domains is correct; one team owning a step that silently serves both is a hidden dependency. Measure registry size as a health metric, not just scenario count. Keeping each step definition tightly scoped to a single behavioral intent is the discipline that keeps the registry manageable.
Myth: ambiguous match errors mean the framework is broken. Cucumber-JVM 7 and SpecFlow throwing on ambiguous matches is the correct behavior — it's surfacing a registry integrity problem that Behave's silent first-match hides. Teams that suppress these errors with workarounds (overly specific regex, load-order hacks) are accumulating technical debt in the registry layer. The right response to an ambiguous match error is a naming and ownership conversation, not a regex escape. Similarly, assuming that AI-assisted step generation avoids this problem is optimistic — generated steps are particularly prone to pattern overlap, especially when the domain language is ambiguous at the source.
Registry fragmentation is a slow-burn problem: it's invisible until it isn't, and by the time it surfaces as a flaky suite or a CI-only failure, the organizational patterns that caused it are already entrenched. The immediate next step is running the collision-detection hook against your current glue paths and treating the output as a registry health report. Once you have visibility, the scoping and ownership decisions become tractable. After that, the next thing worth measuring is how often a registry collision correlates with a flaky test — that signal is more actionable than raw flakiness rate alone.
Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.