The 30 second answer
There are three usual causes. By default Spring rolls back only on unchecked exceptions and errors, so a checked exception still commits unless I set rollbackFor. Second, the exception was caught inside the method and never rethrown. Third, self-invocation: calling a transactional method from the same class skips the proxy, so no transaction is started for that call.
Three common causes. One: by default Spring rolls back only on unchecked exceptions (RuntimeException and Error), not checked ones; fix with rollbackFor = Exception.class. Two: the exception was caught inside the method and not rethrown. Three: self-invocation; calling a @Transactional method from the same class skips the proxy, so no transaction starts on that call. Follow-up: How do you fix self-invocation? (Move the method to another bean, or inject the proxy; the docs suggest AspectJ mode as an alternative.)