Write a Query
The read chain is simpler than the write chain: no transactions, no events, no state changes. But it has a fixed shape too — Query → QueryBus → QueryHandler → read-side repository → assembler → DTO.
Five steps
- Define the Query (extend
BaseQuery<R>orBasePageQuery<R>) - Define the read-side repository interface (in the
domainpackage) - Implement the QueryHandler
- Query data in the repository implementation and convert it to domain objects
- Use an assembler to turn domain objects into DTOs
Step 1: Define the Query
Single-item query
Extend BaseQuery<R>; R is the return type:
package com.example.demo.application.cqrs.query;
import com.example.demo.domain.producer.Producer;
import com.hibuka.soda.cqrs.query.BaseQuery;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "GetProducerQuery model")
public class GetProducerQuery extends BaseQuery<Producer> {
@Schema(description = "uid", example = "pid-dkljkcdfd")
private String uid;
}BaseQuery<R> ships these context fields (all with @Schema(hidden = true), so they never show up in the API docs):
| Field | Type | Meaning |
|---|---|---|
requestId | String | Request tracing ID |
callerUid | Long | Caller UID |
userName | String | User name |
authorities | String | Authorities string |
jti | String | JWT token id |
jwt | String | JWT token |
tenantId | String | Tenant ID |
userInfo | String | User info |
extension | String | Extension field |
Paged query
Extend BasePageQuery<R>, which adds the paging parameters:
package com.example.demo.application.cqrs.query;
import com.example.demo.adapter.inbound.web.dto.ProducerDTO;
import com.hibuka.soda.cqrs.query.BasePageQuery;
import com.hibuka.soda.foundation.io.PageVO;
import lombok.Data;
@Data
public class ListProducerQuery extends BasePageQuery<PageVO<ProducerDTO>> {
}BasePageQuery<R> is a message base class, not a Handler — it just preloads the paging parameters and their defaults. ListProducerQuery still needs its own QueryHandler, exactly like any other Query.
Its constants and defaults:
| Item | Value |
|---|---|
pageNumber default | 1 |
pageSize default | 20 |
MAX_PAGE_SIZE | 200 |
DEFAULT_PAGE_SIZE | 20 |
DEFAULT_PAGE_NUMBER | 1 |
The two “with default” accessors are the ones you should actually use:
public int getPageSizeWithDefault() {
return (pageSize == null || pageSize <= 0 || pageSize > MAX_PAGE_SIZE) ? DEFAULT_PAGE_SIZE : pageSize;
}
public int getPageNumberWithDefault() {
return (pageNumber == null || pageNumber <= 0) ? DEFAULT_PAGE_NUMBER : pageNumber;
}So a pageSize of 0, a negative number, or anything over 200 all fall back to 20. Passing null won't NPE either.
Always use getPageSizeWithDefault(), not getPageSize(). The latter returns the raw value — a user passing pageSize=9999 goes straight to the database with it.
Step 2: The read-side repository interface
soda ships no base interface for the read side — read shapes differ too much between projects, and a forced abstraction only gets in the way. Declare your own in the domain package:
package com.example.demo.domain.producer.repository;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.demo.application.cqrs.query.ListProducerQuery;
import com.example.demo.domain.producer.Producer;
import com.hibuka.soda.foundation.error.BaseException;
public interface ProducerReadRepository {
Producer getByUid(String uid) throws BaseException;
Page<Producer> list(ListProducerQuery query) throws BaseException;
}Two points:
- Put the interface in the
domainpackage, alongsideProducerWriteRepository. - Return domain objects, not DOs. The DO → domain object conversion happens in the implementation.
The read-side interface has one small controversy: the method signature takes ListProducerQuery (an application-layer type), which makes the domain layer depend on the application layer. Strict clean architecture would have the domain layer not know about Queries at all. Both approaches exist in practice — either extract the query criteria into a domain-layer parameter object, or accept the slight coupling.
Step 3: Implement the QueryHandler
package com.example.demo.application.cqrs.handler.query;
import com.example.demo.application.cqrs.query.GetProducerQuery;
import com.example.demo.domain.producer.Producer;
import com.example.demo.domain.producer.repository.ProducerReadRepository;
import com.hibuka.soda.cqrs.query.QueryHandler;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.ParamChecker;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class GetProducerQueryHandler implements QueryHandler<GetProducerQuery, Producer> {
private final ProducerReadRepository producerReadRepository;
@Override
public Producer handle(GetProducerQuery query) throws BaseException {
queryValidate(query);
return producerReadRepository.getByUid(query.getUid());
}
private void queryValidate(GetProducerQuery query) throws BaseException {
ParamChecker.notNull(query, "query");
ParamChecker.notNull(query.getUid(), "uid");
}
}A QueryHandler's three-step shape is fixed:
- Validate the inputs with
ParamChecker - Call the repository
- Return the result
The handle signature on QueryHandler<Q extends Query<R>, R> is R handle(Q query) throws BaseException. BaseException is a subclass of RuntimeException, so the throws declaration is optional — but keeping it tells readers the method can fail.
The paged QueryHandler
A paged QueryHandler has one extra step: converting MyBatis-Plus's Page into a PageVO:
@Component
@RequiredArgsConstructor
public class ListProducerQueryHandler implements QueryHandler<ListProducerQuery, PageVO<ProducerDTO>> {
private final ProducerReadRepository producerReadRepository;
@Override
public PageVO<ProducerDTO> handle(ListProducerQuery query) throws BaseException {
queryValidate(query);
Page<Producer> page = producerReadRepository.list(query);
PageVO<Producer> pageVo = new PageVO<>();
pageVo.setTotal(page.getTotal());
pageVo.setList(page.getRecords());
return ProducerWebAssembler.toDTO(pageVo);
}
private void queryValidate(ListProducerQuery query) throws BaseException {
ParamChecker.notNull(query, "query");
}
}Step 4: The repository implementation
package com.example.demo.infrastructure.repositoryImpl.producer;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.demo.application.cqrs.query.ListProducerQuery;
import com.example.demo.domain.producer.Producer;
import com.example.demo.domain.producer.repository.ProducerReadRepository;
import com.example.demo.infrastructure.acl.mysql.entity.ScodaProducer;
import com.example.demo.infrastructure.acl.mysql.mapper.ScodaProducerMapper;
import com.example.demo.infrastructure.assembler.ProducerAssembler;
import com.hibuka.soda.foundation.error.BaseErrorCode;
import com.hibuka.soda.foundation.error.BaseException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ProducerReadRepositoryImpl implements ProducerReadRepository {
private final ScodaProducerMapper scodaProducerMapper;
@Override
public Producer getByUid(String uid) throws BaseException {
try {
LambdaQueryWrapper<ScodaProducer> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ScodaProducer::getUid, uid);
ScodaProducer scodaProducer = scodaProducerMapper.selectOne(wrapper);
if (null == scodaProducer) {
return null;
}
return ProducerAssembler.toDomain(scodaProducer);
} catch (Exception e) {
throw new BaseException(BaseErrorCode.DATABASE_ERROR.getCode(), e.getMessage());
}
}
}Exceptions are uniformly wrapped in BaseException with error code BaseErrorCode.DATABASE_ERROR (10004). That way the web layer only has to recognize one exception type: BaseException.
Step 5: Assemble DTOs
Domain objects can't be serialized straight to the frontend (they leak internal fields, and the shape is hard to control). Convert with the web assembler:
package com.example.demo.adapter.inbound.web.assembler;
import com.example.demo.adapter.inbound.web.dto.ProducerDTO;
import com.example.demo.domain.producer.Producer;
import com.hibuka.soda.foundation.io.PageVO;
import org.springframework.util.CollectionUtils;
import java.util.stream.Collectors;
public class ProducerWebAssembler {
public static ProducerDTO toDTO(Producer producer) {
if (producer == null) {
return null;
}
ProducerDTO to = new ProducerDTO();
to.setUid(producer.getUid());
to.setName(producer.getName());
return to;
}
public static PageVO<ProducerDTO> toDTO(PageVO<Producer> page) {
if (page == null) {
return null;
}
PageVO<ProducerDTO> to = new PageVO<>();
to.setTotal(page.getTotal());
if (CollectionUtils.isEmpty(page.getList())) {
to.setList(null);
return to;
}
to.setList(page.getList().stream().map(ProducerWebAssembler::toDTO).collect(Collectors.toList()));
return to;
}
}PageVO<T> extends PageDTO<T>. Fields:
| Field | Type | Meaning |
|---|---|---|
total | Long | Total records (inherited from PageDTO) |
list | List<T> | Data list (inherited from PageDTO) |
pageNo | Integer | Current page number, starting at 1 |
pageSize | Integer | Page size |
pages | Long | Total pages |
hasNext | Boolean | Whether there is a next page |
hasPrevious | Boolean | Whether there is a previous page |
startIndex | Long | Start index of the current page |
endIndex | Long | End index of the current page |
The PageVO constructor computes the paging metadata for you:
public PageVO(Long total, List<T> list, Integer pageNo, Integer pageSize) {
super(total, list);
this.pageNo = pageNo != null ? Math.max(pageNo, 1) : 1;
this.pageSize = pageSize != null ? Math.min(Math.max(pageSize, 1), 100) : 10;
calculatePaginationMetadata();
}The PageVO constructor clamps pageSize to 1–100, while BasePageQuery.getPageSizeWithDefault() caps at 200. The two limits disagree — pass query.getPageSizeWithDefault() from the Controller into PageVO and a 200 gets clamped to 100. Keep the page-size limits consistent, or standardize on one of the two caps.
Returning from the Controller
Paged responses use PageResult<T>:
@PostMapping("listProducers")
public PageResult<List<ProducerDTO>> listProducer(@RequestBody ListProducerQuery query) throws BaseException {
PageVO<ProducerDTO> dto = producerAppService.listProducer(query);
return PageResult.success(dto.getList(), dto.getTotal(), query.getPageNumberWithDefault(), query.getPageSizeWithDefault());
}PageResult<T> extends Result<T>, adding eight fields to the response body: total, pageNo, pageSize, pages, hasNext, hasPrevious, startIndex, endIndex.
The parameter order of PageResult.success is (data, total, pageNo, pageSize) — note total comes second.
Before 4.0.0, the parameter order of PageResult.success was (data, pageNo, pageSize, total). If you are upgrading from an older version, this needs to change. Details: Upgrade Guide.
Single-item queries use Result<T>:
@PostMapping("getProducer")
public Result<ProducerDTO> getProducer(@RequestBody GetProducerQuery query) throws BaseException {
ProducerDTO dto = producerAppService.getProducer(query);
return Result.success(dto);
}Sending queries through BusFacade
In the app service, use BusFacade to put the Query on the bus:
@Service
@RequiredArgsConstructor
public class ProducerAppServiceImpl implements ProducerAppService {
private final BusFacade busFacade;
@Override
public ProducerDTO getProducer(GetProducerQuery query) throws BaseException {
Producer domain = busFacade.sendQuery(query);
return ProducerWebAssembler.toDTO(domain);
}
}The signature of BusFacade.sendQuery is <Q extends BaseQuery<R>, R> R sendQuery(Q query) throws BaseException.
If the QueryHandler returns a DTO directly (like the paged QueryHandler does), the app service doesn't need a second conversion:
@Override
public PageVO<ProducerDTO> listProducer(ListProducerQuery query) throws BaseException {
return busFacade.sendQuery(query);
}The complete chain at a glance
HTTP POST /api/getProducer
│
├─ ControllerAroundHandler: reads requestId from request headers, writes it into the Query, sets MDC
│
├─ Controller: binds the request body to GetProducerQuery
│
├─ ProducerAppService: busFacade.sendQuery(query)
│
├─ QueryBus.send(query) → finds GetProducerQueryHandler
│
├─ CqrsAroundHandler: logs timing and in/out params
│
├─ GetProducerQueryHandler.handle(query)
│ ├─ ParamChecker validation
│ └─ producerReadRepository.getByUid(uid)
│ └─ Mapper → ScodaProducer → ProducerAssembler.toDomain → Producer
│
├─ ProducerWebAssembler.toDTO(producer) → ProducerDTO
│
└─ Result.success(dto)FAQ
Q: What is returned when no data is found?
getByUid returns null, the assembler returns null, and the Controller wraps it in Result.success(null). The response body is {"code":200,"message":"","data":null,"success":true}. If you want a 404, throw BaseException(BaseErrorCode.NOT_FOUND.getCode(), ...) in the QueryHandler.
Q: Should a paged query run in a transaction?
No. The read chain changes no state; a transaction only ties up a database connection for nothing.
Q: Can a QueryHandler inject a CommandHandler?
Technically yes (they are all Spring beans), but that lets the read chain trigger writes, breaking CQRS's premise. If you genuinely need it (say, a write triggered after a query), go through the command bus — don't call it directly from the QueryHandler.
Q: Can one Query have multiple QueryHandlers?
No. SimpleQueryBus keys a ConcurrentHashMap by Query type, so a type registers exactly one QueryHandler. A later registration overwrites the earlier one.
Next steps
- The write chain carries two extra things (events, transactions): Write a Command
- When domain events get published: Domain Events