ARTICLE AD BOX
Given:
A blocking Spring Data repository. A service layer wrapping the said repository. @Transactional, performs both read and write operations. And a WebFlux handler invoked by a RouterFunction.which layer should start wrapping data objects in Reactor publishers?
The service layer simply delegates to the repository, mapping data objects with an injected mapper. It's the handler class that wraps them in publishers.
// TaskHandler @Operation(parameters = @Parameter(in = ParameterIn.PATH, name = "id"), description = "Retrieves task by id.") @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json"), description = "Task.") public Mono<ServerResponse> findById(ServerRequest request) { return Mono.fromCallable(() -> UUID.fromString(request.pathVariable("id"))) .map(service::findById) .subscribeOn(Schedulers.boundedElastic()) .flatMap(Mono::justOrEmpty) .flatMap(task -> ServerResponse.ok().bodyValue(task)) .onErrorResume(EntityNotFoundException.class, e -> ServerResponse.status(HttpStatus.NOT_FOUND).bodyValue(e.getMessage())); } // TaskService public Optional<TaskResponseDto> findById(UUID id) { return repository.findById(id) .map(mapper::toDto) .orElseThrow(() -> new EntityNotFoundException("No such task")); }The service layer performs the wrapping, the handler mostly maps results to ServerResponse objects.
My take: push it into the service. The handler shouldn't need to know that the underlying repository is blocking — that's an implementation detail of the service. The service is the one that knows it needs to jump to a bounded-elastic thread and flatten the Optional, so it should own that concern. This makes the handler much cleaner and the service's contract honest to its callers.
– Claude Sonnet
// TaskHandler @Operation(parameters = @Parameter(in = ParameterIn.PATH, name = "id"), description = "Retrieves task by id.") @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json"), description = "Task.") public Mono<ServerResponse> findById(ServerRequest request) { return Mono.fromCallable(() -> UUID.fromString(request.pathVariable("id"))) .flatMap(service::findById) .flatMap(task -> ServerResponse.ok().bodyValue(task)) .onErrorResume(EntityNotFoundException.class, t -> ServerResponse.status(HttpStatus.NOT_FOUND).bodyValue(t.getMessage())); } // TaskService public Mono<TaskResponseDto> findById(UUID id) { return Mono.fromCallable(() -> repository.findById(id)) .subscribeOn(Schedulers.boundedElastic()) .flatMap(Mono::justOrEmpty) .map(mapper::toDto) .switchIfEmpty(Mono.error(new EntityNotFoundException("No such task"))); }The downside is:
@Transactional with JpaTransactionManager stores transaction context in a ThreadLocal. The moment your reactive chain hops to a bounded-elastic thread via subscribeOn, that context is gone — Spring can't see the transaction from the new thread. For your read-only methods this mostly manifests as lazy-loading issues or no-op rollback behaviour rather than an outright failure, but for write methods (save, updateAssignee, updateStatus) the transaction may not be wrapping your repository calls the way you think.
– Claude Sonnet
So how should I approach this? Without R2DBC: it's likely to bring in a ton of unforeseen challenges that I likely won't have time to handle (the deadline is upon me).
