Zero to Hero: Spring Boot Reactive with Kotlin & MongoDB
This course takes you from blocking applications (SQL/JDBC, thread-per-request) to the high-performance world of non-blocking Reactive Mongo. We focus on architecture, clean Kotlin syntax, and Reactor patterns (Mono, Flux, flatMap, switchIfEmpty). Each module teaches fundamentals step-by-step, then challenges you to solve code.
Course structure: 6 modules + 3 challenges + 1 capstone. You will define data models with @Document, use ReactiveMongoRepository, implement service-layer logic with flatMap and switchIfEmpty, expose REST APIs with WebFlux, and handle complex queries with ReactiveMongoTemplate. Finish with a Reactive Blog Engine that combines posts and comments using Mono and Flux.
Module 1: The Reactive Shift (Architecture)
In a blocking app, when you query the database the thread stops and waits for the result. In a reactive app we use the Reactive MongoDB Driver: the thread sends the query and is released. When Mongo has the data, it pushes it back into the stream (Flux / Mono). No thread is blocked waiting on I/O.
Why MongoDB? MongoDB is document-based (JSON-like). That fits Reactive Streams well: data flows as chunks rather than rigid table rows, and the reactive driver natively supports non-blocking operations.
Setup: Dependencies
Add these to your build.gradle.kts:
// build.gradle.ktsdependencies {implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")implementation("org.springframework.boot:spring-boot-starter-webflux")implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")}
Module 2: The Data Model (Kotlin Style)
Goal: Define how data looks in the database. In SQL you use @Entity and @Table; in Mongo we use @Document. Kotlin data classes are ideal: they give equals, hashCode, and copy for free.
import org.springframework.data.annotation.Idimport org.springframework.data.mongodb.core.mapping.Document@Document(collection = "products") // 1. Maps to a Mongo 'Collection' (like a Table)data class Product(@Id // 2. The Primary Key (usually a MongoDB ObjectId string)val id: String? = null, // Nullable: Mongo generates it on saveval name: String,val price: Double,val category: String,val inStock: Boolean = true)
๐ฅ Challenge 1: The User Model โ (1) Create a User data class. (2) Fields: id, email, username, roles (List<String>). (3) Make id nullable with default null so Mongo can auto-generate it. Map it to a collection users with @Document.
// ๐ฅ Challenge 1: The User Model// 1. Create a User data class// 2. Fields: id, email, username, roles (List<String>)// 3. id null by default so Mongo can auto-generate itimport org.springframework.data.annotation.Idimport org.springframework.data.mongodb.core.mapping.Document// Your @Document and data class here// @Document(collection = "users")// data class User(...)
๐ก Solution (click to expand)
@Document(collection = "users")data class User(@Idval id: String? = null,val email: String,val username: String,val roles: List<String>)
Module 3: The Reactive Repository (The Interface)
Goal: Create the bridge to the database without writing query code. Extend ReactiveMongoRepository. You get save(), findAll(), findById()โall returning Mono or Flux. For custom queries, add method names like findByCategory; Spring parses them and generates the Mongo query.
import org.springframework.data.mongodb.repository.ReactiveMongoRepositoryimport org.springframework.stereotype.Repositoryimport reactor.core.publisher.Fluximport reactor.core.publisher.Mono@Repositoryinterface ProductRepository : ReactiveMongoRepository<Product, String> {// Custom Query 1: Find by category โ Flux (many results)fun findByCategory(category: String): Flux<Product>// Custom Query 2: Spring parses method name โ Mongo queryfun findByPriceLessThan(maxPrice: Double): Flux<Product>// Custom Query 3: Find one โ Monofun findByName(name: String): Mono<Product>}
๐ฅ Challenge 2: The User Repo โ (1) Create UserRepository extending ReactiveMongoRepository<User, String>. (2) findByEmail(email: String): Mono<User>. (3) findByRolesContaining(role: String): Flux<User>.
// ๐ฅ Challenge 2: The User Repo// 1. Create UserRepository extending ReactiveMongoRepository<User, String>// 2. findByEmail(email: String): Mono<User>// 3. findByRolesContaining(role: String): Flux<User>// @Repository// interface UserRepository : ReactiveMongoRepository<User, String> {// ...// }
๐ก Solution (click to expand)
@Repositoryinterface UserRepository : ReactiveMongoRepository<User, String> {fun findByEmail(email: String): Mono<User>fun findByRolesContaining(role: String): Flux<User>}
Module 4: The Service Layer (Business Logic)
Goal: Handle โwhat if?โ logic: product not found, negative price, etc. Use flatMap, switchIfEmpty, and map to build a pipeline. We never โunwrapโ values here; we chain operations on Mono / Flux.
import org.springframework.stereotype.Serviceimport reactor.core.publisher.Fluximport reactor.core.publisher.Mono@Serviceclass ProductService(private val repo: ProductRepository) {fun getAllProducts(): Flux<Product> = repo.findAll()fun createProduct(product: Product): Mono<Product> {if (product.price < 0) return Mono.error(IllegalArgumentException("Price cannot be negative"))return repo.save(product)}fun updatePrice(id: String, newPrice: Double): Mono<Product> {return repo.findById(id).switchIfEmpty(Mono.error(RuntimeException("Product not found"))).flatMap { existing ->val updated = existing.copy(price = newPrice)repo.save(updated)}}}
๐ฅ Challenge 3: The Safe User Updater โ (1) updateUsername(email: String, newName: String): Mono<User>. (2) Find user by email. (3) If not found, return Mono.error with "User Unknown". (4) If found, update username, save, and return the updated user.
// ๐ฅ Challenge 3: The Safe User Updater// 1. updateUsername(email: String, newName: String): Mono<User>// 2. Find user by email// 3. If not found โ Mono.error("User Unknown")// 4. If found โ update username, save, return// fun updateUsername(email: String, newName: String): Mono<User> {// return userRepo.findByEmail(email)// .switchIfEmpty(Mono.error(...))// .flatMap { ... }// }
๐ก Solution (click to expand)
fun updateUsername(email: String, newName: String): Mono<User> {return userRepo.findByEmail(email).switchIfEmpty(Mono.error(RuntimeException("User Unknown"))).flatMap { user ->val updated = user.copy(username = newName)userRepo.save(updated)}}
Module 5: The Controller (The API Endpoint)
Goal: Expose your reactive streams as REST. Spring WebFlux subscribes for you: you return Flux or Mono, and the framework streams the response to the client (browser, Postman, etc.).
import org.springframework.http.HttpStatusimport org.springframework.web.bind.annotation.*import reactor.core.publisher.Fluximport reactor.core.publisher.Mono@RestController@RequestMapping("/api/products")class ProductController(private val service: ProductService) {@GetMappingfun getAll(): Flux<Product> = service.getAllProducts()@PostMapping@ResponseStatus(HttpStatus.CREATED)fun create(@RequestBody product: Product): Mono<Product> = service.createProduct(product)@PutMapping("/{id}/price")fun updatePrice(@PathVariable id: String, @RequestParam newPrice: Double): Mono<Product> {return service.updatePrice(id, newPrice)}}
Module 6: Advanced โ ReactiveMongoTemplate
Goal: When findBy... is not enough (e.g. dynamic filters: โprice > 100 AND category in [Tech, Home]โ), use ReactiveMongoTemplate with Query and Criteria to build the query programmatically.
import org.springframework.data.mongodb.core.ReactiveMongoTemplateimport org.springframework.data.mongodb.core.query.Criteriaimport org.springframework.data.mongodb.core.query.Query@Serviceclass ComplexSearchService(private val template: ReactiveMongoTemplate) {fun searchProducts(minPrice: Double?, category: String?): Flux<Product> {val criteriaList = mutableListOf<Criteria>()if (minPrice != null) criteriaList.add(Criteria.where("price").gte(minPrice))if (category != null) criteriaList.add(Criteria.where("category").`is`(category))val query = if (criteriaList.isEmpty()) Query()else Query().addCriteria(Criteria().andOperator(*criteriaList.toTypedArray()))return template.find(query, Product::class.java)}}
Final Capstone: The Reactive Blog Engine
Combine everything: a mini blog backend with posts and comments.
Models
import java.time.Instant@Document(collection = "posts")data class Post(@Id val id: String? = null,val title: String,val content: String,val authorId: String,val createdAt: Instant = Instant.now())@Document(collection = "comments")data class Comment(@Id val id: String? = null,val postId: String,val content: String)
Endpoints
POST /postsโ Create a post.GET /postsโ List all posts.GET /posts/{id}โ Get one post.- Hard:
GET /posts/{id}/detailsโ Return JSON with the post and all its comments. UseMono.ziporflatMapandcollectList()to combine them into a DTO.
Pro tip: Use DTOs for API responses. Donโt return @Document entities directly; that avoids leaking internal fields (e.g. passwords, version).
// GET /posts/{id}/details โ Post + Commentsdata class PostDetailsDto(val post: Post, val comments: List<Comment>)fun getPostWithComments(postId: String): Mono<PostDetailsDto> {val postMono = postRepo.findById(postId).switchIfEmpty(Mono.error(NoSuchElementException("Post not found")))val commentsFlux = commentRepo.findByPostId(postId).collectList()return postMono.flatMap { post ->commentsFlux.map { comments -> PostDetailsDto(post, comments) }}}
Implement PostRepository, CommentRepository, PostService, and PostController. Add the /details endpoint using findById + findByPostId and combine into a PostDetailsDto.
// Final Capstone: Reactive Blog Engine// Models: Post(id, title, content, authorId, createdAt), Comment(id, postId, content)// Endpoints: POST /posts, GET /posts, GET /posts/{id}// Hard: GET /posts/{id}/details โ JSON with Post AND all Comments// Hint: Mono.zip or flatMap + collectList() to combine Post + Comments into a DTO// Implement PostRepository, CommentRepository, PostService, PostController.// Use DTOs for API responses (don't expose @Document entities directly).
Recap: Key Takeaways
- Reactive = non-blocking: thread sends the query and is released; data is pushed back via
Mono/Flux. - @Document + Kotlin data classes model MongoDB collections;
@Idnullable for Mongo-generated IDs. - ReactiveMongoRepository gives CRUD + derived queries (
findByX); returnsMono/Flux. - Service layer uses flatMap, switchIfEmpty, map; never block or unwrap.
- Controllers return
Mono/Flux; WebFlux handles subscription and HTTP streaming. - Complex queries โ ReactiveMongoTemplate +
Query/Criteria. - Use DTOs for API responses; combine Post + Comments with
flatMapandcollectList().