UVM
Callback Reuse Strategies
Using callbacks to build reusable, maintainable customization — designing the hook API as a stable contract, building reusable callback libraries, layering project/team/test callbacks that coexist independently, keeping callbacks self-contained and composable, choosing config versus override versus callback, and why an unstable hook API reintroduces the very reuse problem callbacks exist to solve.
Callbacks · Module 22 · Page 22.3
The Engineering Problem
Callbacks exist to preserve reuse (Module 22.1) and the mechanism is in hand (Module 22.2) — but using callbacks well for reuse is its own discipline. A naive use of callbacks can re-create the very problems they were meant to solve. If a component author changes a hook's signature or semantics in a new version, every callback across every test and team that depended on the old hook breaks — the VIP update that callbacks were supposed to absorb cleanly now breaks everything, reintroducing the fork-and-re-patch nightmare. If callback authors write interdependent callbacks — one assuming another already ran — the independence that lets multiple callbacks coexist is lost, and the suite becomes fragile and order-sensitive. If hooks are placed everywhere (over-hooking), the component becomes churny and slow. The reuse payoff of callbacks is not automatic — it depends on designing the hook API as a stable contract, building self-contained reusable callback libraries, and layering customization cleanly. The problem this chapter solves is callback reuse strategies: the practices that make callbacks a durable reuse mechanism rather than a new form of the old coupling.
Callback reuse strategies are the practices for using callbacks to build reusable, maintainable customization. First, the component author designs the hook API as a stable contract: hooks placed at stable, meaningful extension points (not every line), passing enough context, with stable signatures and semantics — because the hooks are an API users depend on, and changing one breaks every callback. Second, callback authors build reusable callback libraries: a callback class is itself a reusable customization unit (an error-injection callback, a logging callback) — parameterized and reused across tests, agents, and projects. Third, customization is layered: project-wide, team, and test-specific callbacks coexist at the pool, registered at the right scope, each independent. Fourth, callbacks are self-contained and composable: each must not depend on another's order or state, or the independent coexistence that makes them reusable is lost. And the strategy includes choosing the right mechanism — config DB for values, factory override for type swaps, callbacks for behavioral injection at points. The anti-patterns — unstable hook APIs (re-break reuse), over-hooking (churn), interdependent callbacks (fragile) — defeat the reuse callbacks promise. This chapter is callback reuse strategies: the stable hook contract, reusable callback libraries, layering, composability, mechanism choice, and the anti-patterns.
How do you use callbacks to build reusable, maintainable customization — design the hook API as a stable contract, build reusable callback libraries, layer project/team/test callbacks that coexist independently, keep callbacks self-contained and composable, and choose between config, override, and callbacks?
Motivation — why the reuse payoff isn't automatic
Callbacks promise reuse, but deliver it only with discipline. The reasons the strategies matter:
- The hook API is a contract users depend on. Callbacks decouple users from the component's internals — but they couple users to the hook signatures and semantics. The hooks are an API, and like any API, changing it breaks every consumer (every callback). An unstable hook API re-breaks reuse.
- A callback class is a reusable asset — if designed for it. An error-injection callback parameterized by rate and field can be reused across tests, agents, and projects — a library asset. A hardcoded, one-off callback is not reusable. Designing callbacks for reuse multiplies their value.
- Customization comes in layers. Real projects need project-wide policies, team conventions, and test-specific tweaks — simultaneously. The strategy is layering these as separate callbacks at the right scope, coexisting — not tangling them.
- Composability requires independence. The property that lets multiple callbacks coexist is that each is self-contained — not depending on another's order or state. Interdependent callbacks are fragile (break when order changes or one is removed) and defeat the independent coexistence that makes callbacks composable.
- Callbacks are one mechanism among several. Config DB, factory override, and callbacks each fit different customization needs. Using callbacks for a value (should be config) or config for a behavioral injection (should be a callback) is the wrong tool — and the reuse strategy is choosing right.
The motivation, in one line: callbacks promise reuse but deliver it only with discipline — the hook API must be a stable contract (or it re-breaks reuse), callback classes must be designed as reusable library assets, customization must be layered cleanly (project/team/test), callbacks must be self-contained (or composability is lost), and the right mechanism (config/override/callback) must be chosen — so the reuse payoff depends on the strategies, not the mechanism alone.
Mental Model
Hold callback reuse as a published port standard enabling a reusable ecosystem of independent, interchangeable accessories — like USB:
Callbacks become reusable the way USB made peripherals reusable: by publishing a stable, documented port standard. The component's hooks are the port standard — and the reuse depends on that standard being stable, documented, and general. A keyboard, a drive, a webcam (the callback library — error-injectors, loggers, coverage-taps) can each be built once and plugged into any device with that port, on any project, because the port is a fixed contract. You can plug several in at once through a hub (layering — project, team, and test accessories together), and each works independently, unaware of the others (composability). Two things destroy this ecosystem. First, an unstable port: if every device revision changes the connector, every accessory breaks and has to be rebuilt — the reuse the standard promised is gone, and you're back to bespoke, per-device wiring (forking). Second, accessories that secretly depend on each other — a drive that only works if a particular keyboard is plugged in first, in a specific order — stop being interchangeable; plug them in differently and they break. So a healthy callback ecosystem is a stable, published port standard plus a library of self-contained, interchangeable accessories that compose freely. The author's job is to keep the port standard stable; the user's job is to build accessories that don't depend on each other. Picture callbacks as a USB-like ecosystem. The component's hooks are the port standard — and the reuse depends on that standard being stable, documented, and general. A library of accessories (callback classes — error-injectors, loggers, coverage-taps) can each be built once and plugged into any device with that port, on any project, because the port is a fixed contract. You can plug several in at once through a hub (layering — project, team, and test accessories together), and each works independently, unaware of the others (composability). Two things destroy this ecosystem. First, an unstable port: if every device revision changes the connector, every accessory breaks and has to be rebuilt — the reuse the standard promised is gone, and you're back to bespoke per-device wiring (forking). Second, accessories that secretly depend on each other — a drive that only works if a particular keyboard is plugged in first, in a specific order — stop being interchangeable; plug them in differently and they break. So a healthy callback ecosystem is a stable, published port standard plus a library of self-contained, interchangeable accessories that compose freely. The author's job is to keep the port standard stable; the user's job is to build accessories that don't depend on each other.
So callback reuse is a published port standard plus an interchangeable accessory ecosystem: the hook API is the port standard (stable, documented, general — the author's responsibility), the callback library is the accessory ecosystem (self-contained, parameterized, reused everywhere — the user's asset), layering is plugging several in at once (project/team/test via the pool), and composability requires accessories that don't depend on each other. Two failures destroy reuse: an unstable port (changing the hook API → every callback breaks → back to forking) and order/state-dependent accessories (callbacks that depend on each other → not interchangeable). Keep the port standard stable and the accessories self-contained — that is what makes the callback ecosystem reusable.
Visual Explanation — the four reuse strategies
The defining picture is the stack of strategies: a stable hook API, a reusable callback library, layered customization, and composable (independent) callbacks.
The figure shows the four reuse strategies. A stable hook API (the brand-colored top — the author's contract): the component exposes hooks at meaningful points with stable signatures and semantics — a contract users depend on. A reusable callback library (the user's assets): each callback class is a parameterized, self-contained customization unit (error-injector, logger) reused across tests and projects. Layered customization: project, team, and test callbacks coexist at the pool, registered at the right scope. Composable callbacks (the warning-colored bottom): each is independent of the others' order and state, so multiple coexist freely. The crucial reading is that these four strategies divide between the two roles: the component author owns the stable hook API (and the hook placement), and the callback users own the reusable library, the layering, and the composability. Both roles must do their part for reuse: a stable hook API with interdependent callbacks is still fragile; composable callbacks on an unstable hook API still break on updates. The brand-colored stable hook API is the foundation (the port standard — if it's unstable, nothing above survives an update); the success-colored library and layering are the reuse assets built on that foundation; the warning-colored composability is the property that must hold for the assets to coexist (warning because it's the most-violated — interdependent callbacks are an easy mistake). Together, these make callbacks a durable reuse mechanism — a stable contract + reusable, layered, independent customization units — rather than a new coupling (which is what unstable hooks or interdependent callbacks would re-create). The diagram is the reuse-strategy stack: stable hook API (foundation) → reusable library + layering (assets) → composability (the property) — the practices that deliver the reuse callbacks promise. A stable hook contract, a reusable callback library, clean layering, and independent composability — all four make callbacks durably reusable.
RTL / Simulation Perspective — a stable hook API and a reusable callback
In code, the author writes a stable hook API, and the user writes a parameterized, reusable callback. The example shows both, and the layering.
// === AUTHOR: the component exposes a STABLE hook API — a contract users depend on ===
class bus_driver_callback extends uvm_callback;
// STABLE signature + semantics: pre_drive is called just before driving, passing the driver and txn.
// This is an API CONTRACT — changing it breaks every registered callback (DebugLab).
virtual function void pre_drive(bus_driver drv, bus_txn t); endfunction // hook 1
virtual function void post_drive(bus_driver drv, bus_txn t); endfunction // hook 2
endclass
// === USER: a REUSABLE, PARAMETERIZED callback — a library asset, self-contained (composable) ===
class err_inject_cb extends bus_driver_callback;
int unsigned err_rate = 5; // PARAMETERIZED → configurable per use → reusable across tests/projects
bit [7:0] err_mask = 8'hFF;
// SELF-CONTAINED: depends on NO other callback's order or state (composable)
virtual function void pre_drive(bus_driver drv, bus_txn t);
if (($urandom % 100) < err_rate) t.data ^= err_mask; // inject, using only its OWN params + the txn
endfunction
endclass
// === LAYERING: project / team / test callbacks coexist at the pool, registered at the right scope ===
// project-wide (in a base env): uvm_callbacks#(bus_driver,bus_driver_callback)::add(drv, proj_log_cb);
// team (in a team env): uvm_callbacks#(bus_driver,bus_driver_callback)::add(drv, team_cov_cb);
// test-specific (in a test): uvm_callbacks#(bus_driver,bus_driver_callback)::add(drv, my_err_cb);
// all THREE run at the hook, in order, INDEPENDENTLY → layered customization, no forks
// ✗ ANTI-PATTERN: changing the hook signature (e.g. pre_drive(bus_driver drv, bus_txn t, bit extra))
// → every registered callback across every test BREAKS → reuse problem REINTRODUCED (DebugLab)The code shows the author's stable hook API and the user's reusable callback. Author: the bus_driver_callback base declares the hooks (pre_drive, post_drive) with stable signatures and semantics — pre_drive is called just before driving, passing the driver and txn. This is an API contract: changing it breaks every registered callback (the DebugLab). User: err_inject_cb is a reusable, parameterized callback — parameterized (err_rate, err_mask) so it's configurable per use and reusable across tests/projects, and self-contained (it depends on no other callback's order or state — composable), using only its own params and the txn. Layering: project, team, and test callbacks coexist at the pool, registered at the right scope (a project-wide logger in the base env, a team coverage callback in the team env, a test-specific error callback in the test) — all three run at the hook, in order, independently. The anti-pattern (commented) is changing the hook signature (adding a parameter) — which breaks every registered callback across every test, reintroducing the reuse problem (the DebugLab). The shape to carry: the author's job is a stable hook API (stable signatures, stable semantics, meaningful points, enough context — the bus_driver_callback base as a contract), and the user's job is reusable, self-contained, parameterized callbacks (err_inject_cb as a library asset) layered at the right scope. The parameterization (err_rate, err_mask) is what makes the callback reusable (configure it per use); the self-containment (using only its params + the txn) is what makes it composable (no dependence on others); and the layered registration (project/team/test) is what lets independent customizations coexist. The hook API stability is the foundation — the anti-pattern shows one signature change un-doing it all. Author a stable hook contract; write reusable, self-contained, parameterized callbacks; layer them at the right scope.
Verification Perspective — the hook API as a stable contract
The linchpin of callback reuse is the hook API stability — the one thing that, if broken, re-breaks reuse for everyone. Seeing why the hooks are a contract clarifies the author's responsibility.
The figure shows why the hook API is a stable contract. A callback is decoupled from the component internals (it doesn't depend on how the component works inside — that's the reuse callbacks provide) but coupled to the hook API (its signature, timing, and meaning — every callback depends on the hook's parameters, when it's called, and what it passes). The verification insight is this precise coupling: callbacks move the dependency from the internals (good — decoupled, survives internal refactors) to the hook API (the interface — which must therefore be stable). A stable hook API keeps the whole callback ecosystem reusable across component updates: a new version drops in (internals can change freely), and the callbacks still attach (the hooks are unchanged). But a changed hook API — its signature (parameters), timing (when called), or meaning (what it passes) — breaks every callback that depends on it, reintroducing the fork-and-re-patch problem callbacks exist to solve. The warning-colored hook API is the coupling point (warning because it's where the contract lives): keep it stable (the success-colored outcome — reuse survives) or change it (the default-colored outcome — every callback breaks). The crucial realization is that callbacks don't eliminate coupling — they relocate it from the volatile internals to the (hopefully) stable interface. So the author's responsibility is to make that interface actually stable: treat hooks as a published, versioned, additive-only API — new hooks can be added (existing callbacks unaffected), but existing hooks' signatures, timing, and meaning must not change (or every consumer breaks). This is the same discipline as any public API: stability is the contract. The diagram is the coupling relocation: callback → decoupled from internals, coupled to the hook API → stable (reuse survives) vs changed (all break) — and the author's job is keeping the hook API stable. Callbacks relocate coupling from the internals to the hook API — so the hook API must be a stable, versioned, additive-only contract.
Runtime / Execution Flow — layered callbacks coexisting at a hook
At run time, the layering strategy is visible: at one hook, project, team, and test callbacks all run, in order, independently. The flow shows the layered call-out.
The flow shows layered callbacks coexisting at one hook. At a single hook (pre_drive), the pool holds callbacks registered at different scopes: a project-wide callback (a logging or policy callback in the base environment), a team callback (a team convention), and a test-specific callback (this test's tweak). At the hook, the component invokes all of them, in registration order. Each runs independently, using only its own state and the shared context — none depends on another. The runtime insight is that the layering and the independence work together: the layering (callbacks registered at project, team, test scopes) lets each scope customize separately — a project policy, a team convention, a test tweak — without coordinating or tangling; and the independence (each using only its own state + the shared context) lets them coexist — all run at the hook, in order, none aware of the others. The key reuse property is the success-colored "add/remove one safely": because each callback is independent, you can add a test callback or remove a team callback without affecting the others — the layers are separable. This is why the layering strategy works: the pool accumulates callbacks from different scopes, the hook runs them all, and the independence guarantees they don't interfere. The contrast is the anti-pattern: if the test callback depended on the team callback having already run (and modified shared state), then removing the team callback, or registering in a different order, would break the test callback — fragile, order-sensitive, defeating the clean layering. So the layering strategy requires the composability (independence) strategy: layered customization only stays clean if each layer is self-contained. The warning-colored hook (the shared call-out point) feeds the brand-colored layered callbacks, which the independence keeps separable (the success-colored outcome). The flow is the layering: one hook → project + team + test callbacks (in order) → all independent → add/remove safely — clean, separable, layered customization. Layered callbacks at one hook run in order and independently — the layering separates the scopes, the independence keeps them composable.
Waveform Perspective — multiple layered callbacks at one hook
The layering is visible on an execution timeline: at one hook, several callbacks run in sequence, each doing its thing, independently. The waveform shows project, team, and test callbacks at one pre_drive hook.
At one hook, layered project, team, and test callbacks run in order, each independent
12 cyclesThe waveform shows layered callbacks at one hook. The driver reaches the pre-drive hook (hook_pre). The pool's registered callbacks run in registration order: the project callback first (cb_project — logging), then the team callback (cb_team — a coverage tap), then the test callback (cb_test — error injection). Each runs independently, using only its own state and the shared transaction; none depends on another having run. After all three complete, the driver resumes and drives (drive_bus=D1). The crucial reading is the layered, ordered, independent execution: three callbacks from three scopes (project, team, test) all run at the same hook, in registration order, each doing its job (log, cover, inject) without depending on the others. The ordering is deterministic (registration order), but the independence means the order doesn't matter for correctness — each uses only its own state + the shared txn, so reordering them (or removing one) wouldn't break the others (the composability property). This is the runtime signature of clean layering: multiple scopes' callbacks coexisting at a point, separable and independent. The picture to carry is that the layering strategy produces this — project/team/test customizations running together at the hook, each self-contained — and the value is that each scope customized independently (the project added logging, the team added coverage, the test added error injection) without coordinating or forking, and any could be added or removed without disturbing the others. Reading the timeline this way — do the layered callbacks run independently, so each scope's customization is separable? — is seeing the reuse the layering provides. The three callbacks running in order at one hook, each independent is the signature of layered, composable customization — the reuse strategy in action. Layered callbacks run in order at one hook, each independent — separable customization from multiple scopes, coexisting cleanly.
DebugLab — the hook signature change that broke every callback
A VIP update that changed a callback hook's signature and broke every callback across the company
A VIP team improved their bus-agent driver and, in the new release, changed a callback hook's signature — pre_drive(bus_driver drv, bus_txn t) became pre_drive(bus_driver drv, bus_txn t, drive_ctx ctx), adding a context argument they thought was a helpful enhancement. They shipped the update company-wide. The result was a catastrophe: every team that had written a callback against the old pre_drive — dozens of callbacks across many projects (error-injectors, loggers, coverage-taps) — failed to compile against the new VIP, because their override signatures no longer matched the base. Overnight, every test that used a driver callback was broken. The update that callbacks were supposed to absorb cleanly had instead broken the entire callback ecosystem — the exact outcome callbacks exist to prevent. Teams scrambled to update every callback's signature by hand — a company-wide re-patch.
The VIP team changed the hook API's signature — breaking the contract every callback depended on. Callbacks decouple users from the internals but couple them to the hook API, so changing the hook signature broke every callback, reintroducing the fork-and-re-patch problem:
✗ CHANGE the hook signature in a new VIP release (breaking the API contract):
// old: virtual function void pre_drive(bus_driver drv, bus_txn t);
// new: virtual function void pre_drive(bus_driver drv, bus_txn t, drive_ctx ctx); // added arg
// → every callback written against the OLD signature no longer overrides the base → COMPILE BREAK
// (or, worse, silently no longer gets called) across dozens of callbacks, many projects
// the update callbacks should ABSORB cleanly instead BREAKS the whole ecosystem → company-wide re-patch
✓ TREAT the hook API as a published, versioned, ADDITIVE-ONLY contract:
// keep pre_drive(bus_driver drv, bus_txn t) STABLE — never change its signature/timing/meaning
// need to pass more context? ADD a NEW hook, leaving the old one intact:
// virtual function void pre_drive_ex(bus_driver drv, bus_txn t, drive_ctx ctx); // new, additive
// existing callbacks keep working (stable old hook); new callbacks can use the new hook
// → the VIP update drops in cleanly, the callback ecosystem survives — reuse preservedThis is the unstable-hook-API bug — the cardinal callback-reuse failure, and the re-introduction of the very problem callbacks solve. The VIP team changed the pre_drive hook's signature (adding a drive_ctx argument) in a new release — a "helpful enhancement" that broke the API contract. The deep reason it was catastrophic is the coupling relocation (Figure 2): callbacks decouple users from the component's internals (so internal changes are safe), but they couple users to the hook API (signature, timing, meaning) — so changing the hook signature broke every callback that depended on it. Dozens of callbacks across many projects — written against the old pre_drive — no longer matched the base's signature, so they failed to compile (or, worse, silently stopped being called). The update that callbacks were supposed to absorb cleanly (the whole point — Module 22.1) instead broke the entire ecosystem, forcing a company-wide manual re-patch — exactly the fork-and-re-patch nightmare callbacks exist to prevent. The root error is treating the hook API as changeable — it's a published interface that every callback depends on, so it must be stable. The fix is to treat the hook API as a published, versioned, additive-only contract: keep pre_drive(bus_driver drv, bus_txn t) stable forever (never change its signature, timing, or meaning), and if more context is needed, add a new hook (pre_drive_ex(... drive_ctx ctx)) leaving the old one intact — existing callbacks keep working (stable old hook), new callbacks use the new hook, and the VIP update drops in cleanly. The general lesson, and the chapter's thesis: the callback hook API is a stable contract — callbacks decouple users from the component's internals but couple them to the hook signatures and semantics, so changing a hook (its parameters, when it's called, or what it means) breaks every callback that depends on it, reintroducing the fork-and-re-patch problem callbacks exist to solve; so treat hooks as a published, versioned, additive-only API — add new hooks freely, but never change an existing hook's signature, timing, or meaning — because the whole reuse value of callbacks rests on the hook API staying stable across component updates. Callbacks move the coupling to the hook API; if the author breaks that contract, the update callbacks were meant to absorb breaks everyone instead.
The tell is a component update that broke callbacks across many consumers. Diagnose an unstable hook API:
- Check whether a hook's signature, timing, or meaning changed. A modified hook signature breaks every callback that overrides it; compare the base callback class across versions.
- Look for widespread callback breakage on an update. Many callbacks across teams failing on a component update points at a hook-API change, not the callbacks.
- Distinguish compile breaks from silent non-calls. A changed signature may fail to compile, or worse, silently stop overriding so the callback is never called.
- Confirm the change was to the interface, not the internals. Internal changes are safe (callbacks are decoupled from them); only hook-API changes break callbacks.
Treat the hook API as a stable, versioned contract:
- Never change an existing hook's signature, timing, or meaning. It's a published API every callback depends on; changing it breaks them all.
- Add new hooks additively. Need more context or a new point? Add a new hook, leaving existing hooks intact, so existing callbacks keep working.
- Design hooks for stability up front. Place hooks at stable points and pass enough context (the component and transaction) so the signature won't need to change.
- Version and document the hook API. Treat the callback base class as a public interface with a stability contract, documented for consumers.
The one-sentence lesson: the callback hook API is a stable contract — callbacks decouple users from the component's internals but couple them to the hook signatures and semantics, so changing a hook breaks every callback that depends on it and reintroduces the fork-and-re-patch problem callbacks exist to solve; treat hooks as a published, versioned, additive-only API, adding new hooks freely but never changing an existing one's signature, timing, or meaning.
Common Mistakes
- Changing a hook's signature, timing, or meaning. It breaks every callback that depends on it; keep hooks stable and add new ones additively.
- Writing one-off, hardcoded callbacks. A callback parameterized for reuse (configurable rate, fields) becomes a library asset; a hardcoded one isn't reusable.
- Interdependent callbacks. A callback that assumes another ran first is fragile and order-sensitive; keep each self-contained for composability.
- Over-hooking. Hooks at every line cause churn, confusion, and performance cost; place hooks at stable, meaningful extension points only.
- Tangling layers. Project, team, and test customizations should be separate callbacks at the right scope, not merged into one; layering keeps them separable.
- Using the wrong mechanism. Config DB for values, factory override for type swaps, callbacks for behavioral injection at points — pick by the customization's nature.
Senior Design Review Notes
Interview Insights
Four strategies make callbacks a durable reuse mechanism: a stable hook API, reusable callback libraries, layered customization, and composable independent callbacks. First, the component author designs the hook API as a stable contract — hooks placed at stable, meaningful extension points, passing enough context like the component and transaction, with stable signatures and semantics. This matters because the hooks are an API that every callback depends on; changing one breaks them all. Second, callback authors build reusable libraries — each callback class designed as a parameterized, self-contained customization unit. An error-injection callback parameterized by rate and mask, or a logging callback, becomes a library asset reused across tests, agents, and projects, rather than a one-off. Third, customization is layered — project-wide, team, and test-specific callbacks coexist at the pool, registered at the right scope, so each level customizes separately without tangling. A project base env registers a policy callback, a team env a convention, a test its own tweak, and all run at the hook. Fourth, callbacks are composable through independence — each is self-contained, using only its own state and the shared context, depending on no other callback's order or state. This independence is what lets multiple coexist and lets you add or remove one without disturbing the others. The mental model is a USB-like ecosystem: the hooks are a published port standard, and the callbacks are interchangeable accessories built once and plugged in anywhere, layered through a hub, each working independently. The author keeps the port standard stable; the user builds self-contained accessories. Two things destroy the ecosystem: an unstable port — changing the hook API breaks every accessory — and accessories that secretly depend on each other's order, which stop being interchangeable. And there's a mechanism-choice strategy too: config DB for values, factory override for type swaps, callbacks for behavioral injection at points. So reuse isn't automatic from using callbacks; it comes from these disciplines — stable contract, reusable assets, clean layering, and independence.
The hook API is a stable contract because callbacks decouple users from the component's internals but couple them to the hook signatures and semantics, so the hooks become the interface every callback depends on — and changing a hook breaks every callback that depends on it. The key realization is that callbacks don't eliminate coupling; they relocate it. Before callbacks, customizing meant editing the component's source, coupling you to its internals. Callbacks move that dependency: you no longer depend on how the component works inside — internal refactors are safe — but you do depend on the hook API, meaning the hook's signature, when it's called, and what it passes. Every callback overrides a hook method with a matching signature and relies on the hook's timing and meaning. So the hook API is the interface, and like any public API, its stability is the contract. If you keep it stable, the whole callback ecosystem stays reusable across component updates — a new version drops in, internals can change freely, and the callbacks still attach to the unchanged hooks. But if you change a hook — its signature by adding or removing a parameter, its timing by calling it at a different point, or its meaning by passing different data — you break every callback that depends on it. The classic disaster is a VIP team adding an argument to a pre_drive hook as a helpful enhancement; suddenly every callback across every team written against the old signature no longer overrides the base, failing to compile or, worse, silently no longer being called. The update callbacks were supposed to absorb cleanly instead breaks the entire ecosystem, forcing a company-wide manual re-patch — exactly the fork-and-re-patch problem callbacks exist to prevent. The fix is to treat the hook API as a published, versioned, additive-only contract: never change an existing hook's signature, timing, or meaning, and if you need more context or a new point, add a new hook leaving the old one intact, so existing callbacks keep working and new ones can use the new hook. The whole reuse value of callbacks rests on the hook API staying stable, so the author's discipline in not breaking it is the linchpin of callback reuse.
You layer them by registering callbacks at different scopes — a project-wide callback in the base environment, a team callback in the team environment, a test-specific callback in the test — and they all coexist at the pool, running at the same hook in registration order, and it works because each callback is independent. The layering reflects how customization actually comes in real projects: there are project-wide policies, like logging or a checking convention everyone follows; team conventions, like a coverage tap a particular team adds; and test-specific tweaks, like error injection for one test. Rather than tangling these into one place, you register each as a separate callback at the appropriate scope. The project callback is added where the base environment is built, so it applies everywhere. The team callback is added in the team's environment layer. The test callback is added in the test itself. The callback pool accumulates all of them for the component, and when the component reaches the hook, it invokes all the registered callbacks in registration order. So at a single pre-drive hook, the project logger runs, then the team coverage tap, then the test error injector — three scopes customizing the same point. Why it works is the independence: each callback uses only its own state and the shared context, like the transaction, depending on no other callback's order or state. That independence is what lets them coexist cleanly. The layering separates the concerns — each scope owns its callback — and the independence keeps them composable, so adding a test callback or removing a team callback doesn't affect the others. This is the reuse payoff: each level customizes separately, without coordinating or forking, and the layers are separable. The crucial caveat is that the layering only stays clean if the callbacks are independent. If the test callback assumed the team callback had already run and modified shared state, then reordering or removing the team callback would break the test callback — fragile and order-sensitive, defeating the clean layering. So layering requires composability: you get separable, independent customization from multiple scopes only if each callback is self-contained.
Callbacks must be self-contained — each depending only on its own state and the shared context, not on another callback's order or state — because that independence is what makes them composable, letting multiple coexist; if they're not, they become fragile and order-sensitive, defeating the independent coexistence that makes callbacks reusable. The whole value of callbacks coexisting is that you can stack several at a hook — error injection, logging, a coverage tap — and they all run, independently, none aware of the others. This composability rests on independence. Each callback should do its job using only its own parameters and the shared transaction or context the hook passes, and produce its effect without relying on what another callback did. When this holds, the callbacks are interchangeable and stackable: you can add one, remove one, or reorder them, and the others are unaffected. What goes wrong if they're not self-contained is interdependence, which is fragile in several ways. Suppose callback B assumes callback A already ran and modified some shared state — B reads a field A was supposed to set. Now the system is order-sensitive: if a test registers them in a different order, or registers B without A, B breaks or does the wrong thing silently. The callbacks are no longer interchangeable; they only work in a specific combination and order. This defeats the layering strategy, where project, team, and test callbacks are supposed to be separable — if they're interdependent, you can't safely add or remove one. It also makes the suite brittle, because the behavior depends on registration order, which is easy to get wrong and hard to reason about. And it reintroduces coupling, this time between callbacks, which is the opposite of what callbacks are for. The USB analogy is accessories that secretly depend on each other — a drive that only works if a particular keyboard is plugged in first; they stop being interchangeable. So the discipline is to write each callback to be self-contained: use only its own state and the hook's shared context, don't assume another callback ran, and don't rely on registration order for correctness. Ordering can determine sequence, but correctness shouldn't depend on it. That keeps callbacks composable, which is what keeps them reusable.
You use the config DB to set values and parameters, a factory override to swap a type wholesale, and a callback to inject behavior at a specific point — and choosing the right one keeps the customization clean and the components reusable. The config DB is for configuration values: a mode setting, a number of agents, an address, an enable flag, a rate. When the customization is just a value the component reads to parameterize its behavior, you set it in the config DB and the component gets it. You wouldn't use a callback to pass a simple value — that's overcomplicating a config. The factory override is for replacing a type: when you want a genuinely different implementation of a component or a different transaction subtype substituted wherever the original would be created. If you need a whole different driver, or a specialized transaction class, the factory swaps the type. It's substitutive and whole-type — your class runs instead of the original. The callback is for behavioral injection at a point: when you want to add or alter behavior at a specific moment in the component's execution while keeping its base behavior — inject an error before driving, log after receiving, tap coverage. It's additive and point-specific — the base runs, plus your injection — and multiple callbacks coexist. The decision is by the nature of the customization. Is it a value? Config DB. Is it a wholesale type replacement? Factory override. Is it behavioral injection at a point, especially with multiple independent tweaks? Callback. Using the wrong one causes problems. Using a factory override for a one-point tweak re-implements the component's logic just to add a line and allows only one override, conflicting across teams — that's a callback's job. Using a callback to pass a value is awkward when config would do it directly. Using config to inject behavior doesn't work — config carries data, not behavior. So the reuse strategy includes mechanism selection: match the customization to the mechanism designed for it. They're complementary, not competing — a well-built environment uses config for parameters, factory overrides for type swaps, and callbacks for behavioral hooks, each keeping its kind of customization clean and the underlying components reusable.
Exercises
- Stabilize the hook. A hook needs more context. Show the additive-only change that adds it without breaking existing callbacks.
- Parameterize for reuse. Take a hardcoded error-injection callback and make it a reusable library asset.
- Layer the scopes. Describe where you'd register a project logging callback, a team coverage callback, and a test error callback, and why they coexist.
- Spot the interdependence. Given two callbacks where one reads state the other sets, explain the fragility and how to make them self-contained.
Summary
- Callback reuse strategies are the practices that make callbacks a durable reuse mechanism: a stable hook API, reusable callback libraries, layered customization, and composable (independent) callbacks.
- The hook API is a stable contract: callbacks decouple users from the component's internals but couple them to the hook signatures and semantics, so changing a hook breaks every callback — treat hooks as a published, versioned, additive-only API (add new hooks freely, never change an existing one's signature/timing/meaning).
- Callback classes are reusable assets when parameterized and self-contained; customization is layered (project/team/test callbacks coexist at the right scope); and composability requires independence — each callback uses only its own state and the shared context, so multiple coexist and any can be added/removed safely.
- Choose the right mechanism: config DB for values, factory override for type swaps, callbacks for behavioral injection at points — and avoid the anti-patterns (unstable hook APIs re-break reuse, over-hooking causes churn, interdependent callbacks are fragile).
- The durable rule of thumb: callbacks deliver reuse only with discipline — author the hook API as a stable, versioned, additive-only contract (changing a hook breaks every callback and reintroduces the fork-and-re-patch problem), build callbacks as parameterized, self-contained, composable library assets that depend on no other's order or state, layer project/team/test customizations at the right scope, and choose config for values, override for type swaps, and callbacks for behavioral injection at points.
Next — Runtime Customization: the module closes with the dynamic dimension — using callbacks (and the wider customization mechanisms) to alter behavior at run time. Registering and removing callbacks during simulation, enabling and disabling them per phase or per test, switching customization without recompiling, and how runtime callback control turns a single reusable environment into many behaviors selected at run time.