Skip to content
Bluetape4k docs1.11

Transactions, failures, and testing

Latest stable Based on Bluetape4k release 1.11.0

Transactions are owned outside the extension

Section titled “Transactions are owned outside the extension”

The module’s extensions do not create a transaction manager. When the current coroutine context carries a Spring reactive transaction, the underlying Spring Data operation uses its bound connection. Otherwise, the publisher follows its normal lifecycle.

Wrap the full service operation in one reactive transaction when several writes must commit together. withTransactionSuspend in bluetape4k-r2dbc is one option, provided every operation stays on the same ConnectionFactory and DatabaseClient boundary.

suspend fun createPost(command: CreatePost): Post =
databaseClient.withTransactionSuspend {
val post = posts.insert(command.toPost())
val postId = checkNotNull(post.id)
comments.insert(command.toInitialComment(postId))
post
}

If the project standardizes on annotations or TransactionalOperator, follow that policy. The invariant is that an individual extension does not automatically start a transaction.

The extensions execute publishers through R2dbcEntityOperations; they do not acquire or close connections directly. The application or Spring Boot owns:

  • R2DBC URL and credentials
  • Driver and ConnectionFactory
  • Pool size, acquisition timeout, and validation
  • Startup schema migration
  • Pool shutdown

The 1.11.0 R2dbcBlogApplication creates an H2 ConnectionFactory and an initializer explicitly. It is a test application example, not module auto-configuration.

SituationResult
selectOneSuspending with zero rowsUnderlying error
selectOneOrNullSuspending with zero rowsnull
selectOne* with multiple rowsCardinality error
selectFirstOrNullSuspending with zero rowsnull
Update/delete with no match0L
Count with no match0L
Connection, SQL, or mapping failurePropagated exception
Coroutine cancellationPropagated cancellation

null, 0L, and an exception are separate contracts. Do not collapse them into one repository outcome.

The test application uses r2dbc:h2:mem:///test and initializes posts and comments from schema.sql. An ApplicationReadyEvent listener inserts two posts and four comments.

R2dbcEntityOperationsExtensionsTest runs insert→update→select→delete→exists within one test. It verifies each affected count and the final state, which makes failure location easy to identify.

@Test
fun `insert update delete extensions`() = runTest {
val saved = operations.insertSuspending(createPost())
val query = Query.query(Criteria.where(Post::id.name).isEqual(saved.id))
operations.updateSuspending<Post>(query, Update.update("title", "Updated")) shouldBeEqualTo 1L
operations.selectOneSuspending<Post>(query).title shouldBeEqualTo "Updated"
operations.deleteSuspending<Post>(query) shouldBeEqualTo 1L
operations.existsSuspending<Post>(query).shouldBeFalse()
}

A shared H2 database is fast but can allow data collisions between tests. Use generated IDs or unique input and clean only rows created by the test, or apply transaction rollback. Assert before/after deltas rather than a fixed global seed count to reduce ordering dependencies.

H2 does not prove production-driver type conversion, locking, or isolation behavior. Add sequential integration tests with the target PostgreSQL or MySQL R2DBC driver when those contracts matter.

Continue to the R2DBC ecosystem path.