Class ReadServiceTransactionalRuleTest
Spring opens the Hibernate session from @Transactional. A service method that calls a DAO without
one reaches the session factory with nothing bound to the thread and fails at runtime with
"Could not obtain transaction-synchronized Session for current thread" — a 500, on every call,
for a method that compiles and unit-tests perfectly.
🛑 This is invisible to the tests we normally write, in both directions. A DAO test extending
BaseDatabaseTest5 runs inside the test's own transaction, so a session always exists and the
missing annotation cannot manifest. A mocked web-layer test stubs the service outright, so the real
method never executes. Green above, green below, broken in between.
Caught in production on 2026-08-26: ArrayDesignReadServiceImpl.loadOriginalPlatformValueObjectsForEE
was written alongside loadValueObjectsForEE, copying the body but not the
@Transactional(readOnly = true) the sibling carries, and
GET /datasets/{id}/platforms?original=true 500'd on the live server. Annotations are not inherited
from the method you cloned; this rule is the thing that remembers that. On its first working run it found a
second instance of the identical mistake — ExpressionExperimentReadServiceImpl.findByBioMaterials,
whose neighbour findIdsByBioMaterial is annotated and whose own class Javadoc asserted that every
public method was readOnly.
Scope is deliberately narrow — public methods on *ReadServiceImpl that call a *Dao
directly. A method that only delegates to another service is out of scope: the transaction belongs where
the DAO is touched, and widening this to every *ServiceImpl would flag delegation chains that are
correct as they stand.
A class-level @Transactional satisfies the rule, since Spring applies it to every public method.
"Calls a *Dao" over-approximates: a handful of DAO methods never reach a session (they return a
precomputed field, or read the JPA metamodel, which is static mapping configuration). Those carry
SuppressArchUnit("ReadServiceTransactional") with a comment saying which, rather than a
pointless annotation that would open a transaction nothing uses.
- Author:
- gemma
-
Field Summary
FieldsModifier and TypeFieldDescriptionstatic final com.tngtech.archunit.lang.ArchRule -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionstatic voidclasses_are_actually_imported(com.tngtech.archunit.core.domain.JavaClasses classes) 🛑 The guard against a guard that checks nothing.
-
Field Details
-
read_service_methods_reaching_a_dao_must_be_transactional
public static final com.tngtech.archunit.lang.ArchRule read_service_methods_reaching_a_dao_must_be_transactionalallowEmptyShouldstaysfalsedeliberately — seeclasses_are_actually_imported(JavaClasses). If thethat()clause ever stops matching, that is a signal worth a red build, not something to suppress.
-
-
Constructor Details
-
ReadServiceTransactionalRuleTest
public ReadServiceTransactionalRuleTest()
-
-
Method Details
-
classes_are_actually_imported
public static void classes_are_actually_imported(com.tngtech.archunit.core.domain.JavaClasses classes) 🛑 The guard against a guard that checks nothing.ArchUnit reads compiled bytecode through a bundled ASM. When that ASM is older than the class file version we compile to, it imports zero classes and does not complain — every rule then passes for a reason that has nothing to do with the codebase. That is not hypothetical: archunit 1.3.0 could not read Java 25 bytecode (major version 69), so this rule and
AutowireImplRuleTestboth ran green while checking nothing at all, and removing the annotation whose absence had just 500'd production changed no result. Fixed by archunit 1.4.1.allowEmptyShouldis what converts that failure into a pass, so this rule leaves itfalseand this sentinel states the expectation directly: an empty import is a broken toolchain, never a clean codebase.
-