When a transaction boundary is applied by a wrapper around an object, why can a call between its own methods run untransacted?
answer
- interception happens on the way in
- the wrapper is not in the internal path
- the declaration is inert, not late
- works from outside, silent from inside
- extract a collaborator to cross the seam
basics
~20 sInterception happens as a call passes through the wrapper. A method calling another on the same object uses the object's own reference, so the wrapper is never crossed, no boundary begins, and the inner method's declared attributes are silently ignored.
solid answer
~50 sDeclarative demarcation works by wrapping the object: callers hold the wrapper, and it begins a boundary before delegating and commits or rolls back afterwards. An internal call does not go through the wrapper - it dispatches straight to the method through the object's own reference - so nothing starts a transaction. The inner method then runs in whatever the caller already had, which may be nothing at all, and its declared timeout, read-only intent or request for an independent boundary are ignored. The failure is silent: the method behaves correctly when invoked from outside, so tests entering through the wrapper never see it. The clean fix is to move the bounded work to a separate collaborator that is called through its own wrapped reference, which also puts the boundary on a real seam; alternatively split the use case so the outer method is the boundary, or open that inner block programmatically.
go deeper
Remember that the boundary is started by something wrapping the object, so a call the object makes to itself never reaches it. Declared attributes on that inner method do nothing.
Explain the dispatch: the internal call goes straight to the body, so nothing reads the declaration. List the fixes and say why extracting a collaborator is the one that also improves the design.
Show how you would find this in a running system - partial data after a failure that should have rolled back - and how you write a test through the outer entry point that would have caught it.
Set the rule that boundaries live on the use-case seam callers actually cross, and treat a declaration on an internal-only method as a design finding rather than a configuration detail.
## The mechanism: interception on the way in Declarative demarcation needs somewhere to run its begin/commit/rollback logic. The common implementation is **wrapping**: the object handed to collaborators is not the implementation itself but a stand-in that holds a reference to it. Every call arriving through that stand-in can be inspected, and when the target method carries a boundary declaration the stand-in starts a transaction, delegates, then commits or rolls back. The important consequence is that **the declaration is applied by the wrapper, not by the method body**. The body has no idea whether a boundary exists. Interception is a property of the *call path*, not of the code that eventually runs. ## Why the internal call escapes Inside the implementation, a call from one of its methods to another is dispatched directly on the object itself. The wrapper is not in that path, so: 1. No interception runs, so no boundary begins. 2. The inner method executes inside whatever transaction context the caller already had. 3. If the caller had none, the statements run one per implicit transaction and become durable individually. 4. Every attribute declared on the inner method - timeout, read-only intent, a request for an independent unit - is ignored, because nothing read the declaration. Point 4 is the one that surprises people most. The declaration is not merely late; it is inert. ## What the symptom looks like - The method behaves correctly when a collaborator calls it, and incorrectly when the object calls it itself. - Data written by a "bounded" internal step survives a failure that should have rolled it back. - A step declared to run in its own independent unit turns out to share the caller's fate, or vice versa. - Nothing is logged, because an internal call is an ordinary method call and indistinguishable at runtime from any other. ## Holes of the same shape - Calling a method on a reference obtained outside the wrapping mechanism - a hand-constructed instance, or one pulled out of a factory that returns the raw object. - Invoking the work from a thread or callback that the wrapping mechanism does not cover, so the surrounding context is gone entirely. - Declaring the boundary on a method whose visibility keeps the wrapping mechanism from covering it - the details vary between mechanisms, but where it applies the declaration is simply ignored. - Passing a method reference of the raw object to something that will call it later. ## Fixes, best first 1. **Extract a collaborator.** Move the bounded work into its own object and call it through its wrapped reference. The boundary is then crossed for real, and the extraction usually names a genuine responsibility. 2. **Make the outer method the boundary.** If both methods belong to the same unit of work anyway, declare it once on the entry method and delete the inner declaration - the honest answer surprisingly often. 3. **Open that block programmatically.** Where the inner step needs its own attributes and extraction is not worth it, write the boundary by hand around exactly those statements. 4. **Use a mechanism that intercepts on the object itself.** Some environments can weave the behaviour into the class so that even internal calls are intercepted, which closes the hole at the cost of a heavier build and a less obvious call model. 5. **Obtain a wrapped reference to yourself.** It works, and it is a smell: the object now depends on the mechanism that wraps it. | Approach | Closes the hole | Cost | |---|---|---| | Extract a collaborator | yes | one more type, usually a good one | | Declare on the outer method only | yes | inner step loses its own attributes | | Programmatic block | yes | hand-written commit and rollback paths | | Weaving into the class | yes | heavier mechanism, less obvious dispatch | | Self-reference through the wrapper | yes | circular dependency on the wrapping mechanism | ## How to keep it from coming back Treat "a boundary declaration on a method that only internal code calls" as a review finding. A boundary should live on a seam that outside callers actually cross - the use-case entry point - and the presence of a declaration deep inside an object is usually a sign that the object is doing two jobs. Test the behaviour, not the declaration: a test that drives the outer entry point and asserts that nothing was written after an induced failure catches this class of bug, while a test that calls the inner method directly through the wrapper hides it perfectly.
- The inner method is declared to run in its own independent unit. What actually happens on an internal call?It joins whatever the caller has, or runs unbounded if the caller has none. The independence request is never read, so a step designed to survive the caller's rollback shares its fate instead. This is the most damaging form of the hole, because the code reads as if the failure path were handled.
- How would you prove this hole exists in a given codebase?Drive the outer entry point, force a failure after the inner step has written, and assert the database is unchanged. If the inner write survives, the boundary was never applied. Calling the inner method directly through its wrapped reference will pass and prove nothing, which is exactly why the bug lasts.
saying these in an interview costs you the question
- Thinks the declaration travels with the method body wherever it is called from
- Believes changing the inner method's visibility is what makes the boundary apply
- Expects an error or a warning when the internal call finds no boundary
- Adds a second declaration on the outer method and assumes the inner one now works
- Injects the object's own wrapped reference into itself and calls it a clean fix