Course topics

By WebNest Studio

Spring Boot Tutorial

R2DBC: Reactive Database Access

A reactive web layer is only non-blocking if everything below it is too. JDBC is inherently blocking — every query parks a thread until the database answers — so WebFlux applications need a different database API. R2DBC (Reactive Relational Database Connectivity) is a non-blocking specification for SQL databases, with drivers for PostgreSQL, MySQL, MariaDB, SQL Server, Oracle and H2.

Spring Data R2DBC offers repositories and a fluent DatabaseClient on top of it. This lesson configures R2DBC in Spring Boot 4, maps entities, writes reactive repositories and custom queries, uses reactive transactions, manages schema with Flyway, and explains R2DBC's limitations compared with JPA.

Setup

Add spring-boot-starter-data-r2dbc and an R2DBC driver such as org.postgresql:r2dbc-postgresql. Configure spring.r2dbc.url (for example r2dbc:postgresql://localhost:5432/webnest), username and password. Spring Boot configures a connection pool (r2dbc-pool), a DatabaseClient, an R2dbcEntityTemplate and a reactive transaction manager.

Entities and Repositories

Spring Data R2DBC maps simple objects or records with @Table, @Id and @Column. Repositories extend ReactiveCrudRepository or R2dbcRepository, return Mono/Flux, and support derived queries and @Query with SQL. Auditing (@CreatedDate) and optimistic locking (@Version) are supported.

Limitations Compared with JPA

R2DBC is deliberately simpler than JPA: there is no lazy loading, no relationship mapping (@OneToMany), no cascading, no first-level cache and no automatic schema generation. You load related data explicitly with separate queries or joins. Many teams find this predictable and easy to reason about; it resembles Spring Data JDBC.

Transactions and Schema

@Transactional works on methods that return Mono or Flux, managed by R2dbcTransactionManager; TransactionalOperator offers programmatic control. Schema migrations still run through Flyway or Liquibase, which use JDBC — so also add the JDBC driver and point spring.flyway.url at the same database.

Examples

Dependencies and configuration

Java
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>r2dbc-postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
<!-- Flyway runs migrations over JDBC -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

# application.yml
spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/webnest
    username: webnest
    password: ${DB_PASSWORD}
    pool:
      max-size: 20
  flyway:
    url: jdbc:postgresql://localhost:5432/webnest
    user: webnest
    password: ${DB_PASSWORD}
Output
Flyway: Successfully applied 2 migrations
Netty started on port 8080 (http)

Entity, repository and custom SQL

Java
@Table("courses")
public record Course(
        @Id Long id,
        String slug,
        String title,
        BigDecimal price,
        @Column("published_at") Instant publishedAt,
        @Version Long version) {}

public interface CourseRepository extends R2dbcRepository<Course, Long> {

    Mono<Course> findBySlug(String slug);

    Flux<Course> findByPriceLessThanOrderByPriceAsc(BigDecimal max);

    @Query("select * from courses where title ilike concat('%', :text, '%') order by title limit :limit")
    Flux<Course> search(String text, int limit);
}

courseRepository.findByPriceLessThanOrderByPriceAsc(new BigDecimal("2000"))
    .map(Course::title)
    .subscribe(System.out::println);
Output
HTML
Java - Core

Loading related data explicitly and using DatabaseClient

Java
public record Lesson(@Id Long id, Long courseId, String title, int position) {}
public record CourseWithLessons(Course course, List<Lesson> lessons) {}

public Mono<CourseWithLessons> withLessons(String slug) {
    return courses.findBySlug(slug)
        .flatMap(c -> lessons.findByCourseIdOrderByPosition(c.id())
            .collectList()
            .map(list -> new CourseWithLessons(c, list)));
}

// Hand-written SQL with DatabaseClient
public Flux<CourseStat> stats() {
    return db.sql("""
            select c.title, count(e.id) as students
            from courses c left join enrollments e on e.course_id = c.id
            group by c.title order by students desc
            """)
        .map((row, meta) -> new CourseStat(row.get("title", String.class), row.get("students", Long.class)))
        .all();
}
Output
withLessons("spring-boot") -> CourseWithLessons[course=Course[slug=spring-boot,...], lessons=[Lesson[title=Spring vs Spring Boot vs Spring MVC,...], ...]]
stats() -> CourseStat[title=Spring Boot, students=1240], CourseStat[title=Java - Core, students=980]

Reactive transactions

Java
@Service
public class EnrollmentService {

    private final CourseRepository courses;
    private final EnrollmentRepository enrollments;
    private final WalletRepository wallets;

    public EnrollmentService(CourseRepository courses, EnrollmentRepository enrollments, WalletRepository wallets) {
        this.courses = courses;
        this.enrollments = enrollments;
        this.wallets = wallets;
    }

    @Transactional
    public Mono<Enrollment> enroll(long studentId, String slug) {
        return courses.findBySlug(slug)
            .switchIfEmpty(Mono.error(new CourseNotFoundException(slug)))
            .flatMap(course -> wallets.debit(studentId, course.price())      // fails if insufficient balance
                .then(enrollments.save(new Enrollment(null, studentId, course.id(), Instant.now()))));
    }
}
Output
enroll(7, "spring-boot") -> COMMIT: wallet debited, enrollment saved
enroll(8, "spring-boot") (insufficient balance) -> ROLLBACK: no enrollment, wallet unchanged

Common Mistakes

  • Mixing JPA repositories into a WebFlux app "just for one query", reintroducing blocking calls.
  • Expecting @OneToMany mappings and lazy loading in R2DBC; relationships must be loaded explicitly.
  • Forgetting that Flyway needs a JDBC URL and driver even in an R2DBC application.
  • Using an r2dbc: URL in spring.datasource.url or a jdbc: URL in spring.r2dbc.url.
  • Choosing R2DBC for a traditional MVC app where JPA or JDBC with virtual threads would be simpler.

Key Points to Remember

  • R2DBC provides non-blocking SQL access for reactive applications.
  • spring-boot-starter-data-r2dbc + an R2DBC driver + spring.r2dbc.* configure it.
  • R2dbcRepository returns Mono/Flux and supports derived queries, @Query, @Version and auditing.
  • No lazy loading or relationship mapping — load related data explicitly or with DatabaseClient.
  • @Transactional works on reactive methods; migrations still use Flyway over JDBC.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.