Part 7: Queries
INFO
Queries, for example, are typically generated in a REST API and sent to the facade to fetch data from the Readmodel. While they are part of the CQRS pattern, their use is not strictly mandatory. A developer could, in theory, access aggregates directly via the AggregateRepository
within the REST API. However, doing so would break the separation between the read and write models, which is a fundamental principle of CQRS. Moreover, such queries would be highly inefficient because aggregates are stored in the Event Store. This storage is designed for write operations, not for optimized read operations. Further information about Queries can be found in the documentation: Query
What is a Query?
Similar to commands, queries are simple structures that are processed by a QueryHandler
. Their primary responsibility is to read data from the Readmodel and return it to the caller. However, queries are not strictly required to access Readmodels — they can also generate responses directly if needed. Queries are created by the user and sent to the facade. The facade forwards the queries to the QueryHandler, which retrieves the data from the Readmodel and returns it to the user. Unlike commands, this process does not modify any data and is performed in a single operation. This makes queries a lightweight and efficient mechanism for retrieving information without impacting the underlying system state.
Predefined Query Handlers
If projections are used, corresponding Query Handlers are available for these projections. These Query Handlers are predefined and can be used directly without additional implementation. As we want to show you how to create a custom query handler in this document, refer to the documentation on projections: Projection for more information.
Custom Query Handler
Like most key components in comby, a Query Handler is built upon a base class called BaseQueryHandler
. This class provides a solid foundation for implementing query handlers, including essential functions and structures needed for processing queries.
Here is the structure of our custom query handler:
// simple/domain/order/query/query.go
package query
import (
"comby.io/examples/simple/domain/order/readmodel"
"github.com/gradientzero/comby/v2"
)
type qryHandler struct {
*comby.BaseQueryHandler
Readmodel *readmodel.CustomOrderReadmodel
}
func NewCustomQueryHandler(
Readmodel *readmodel.CustomOrderReadmodel,
) *qryHandler {
qh := &qryHandler{}
qh.BaseQueryHandler = comby.NewBaseQueryHandler()
qh.Domain = "Order"
qh.Name = "CustomQueryHandler"
qh.Readmodel = Readmodel
// register custom domain query handlers
comby.AddDomainQueryHandler(qh, qh.GetList)
comby.AddDomainQueryHandler(qh, qh.GetModel)
return qh
}
Our custom Query Handler receives the Readmodel as a parameter. This Readmodel is then used within the Query Handler's methods to read and return data. The Query Handler registers two methods to handle the processing of two specific queries. Once again, the method name is not critical — what matters is the method's signature and its alignment with the used domain query. This ensures that the Query Handler can process the queries correctly and return the expected data.
Same as with Commands, it is important to note that a Query Handler can register a domain query only once. This restriction applies globally across all Query Handlers. If a query is registered multiple times, an error will be thrown. This ensures a strict 1-to-1 relationship between a query and its corresponding Query Handler (and its internal method), maintaining clarity and avoiding conflicts in query processing.
Query Handling
Once our custom Query Handler is defined, we can proceed to implement the actual domain query handlers. These handlers process the Domain Query Requests and return a Domain Query Response. Both the request and response structures are user-specific and can be customized as needed. In the following section, we demonstrate how to implement query handlers for an retrieving a single CustomOrderModel
using the readmodel:
Model
Noteworthy here is the clear definition of the request and response structures specific to this domain query. These structures are user-defined and can be tailored as needed. The CustomModelRequest provides only an orderUuid to identify the desired model instance. The CustomModelResponse contains the model data itself, which is returned to the user. For simplicity, we have reused the same data structure already defined in the Readmodel. However, in practice, it might be beneficial to modify the response structure for the specific use case.
// simple/domain/order/query/qry.model.go
package query
import (
"context"
"fmt"
"comby.io/examples/simple/domain/order/readmodel"
"github.com/gradientzero/comby/v2"
)
type CustomModelRequest struct {
OrderUuid string `json:"orderUuid"`
}
type CustomModelResponse struct {
Item *readmodel.CustomOrderModel `json:"order,omitempty"`
}
func (qh *qryHandler) GetModel(ctx context.Context, qry comby.Query, domainQry *CustomModelRequest) (*CustomModelResponse, error) {
var err error
// validate uuid
err = comby.ValidateUuid(domainQry.OrderUuid)
if err != nil {
return nil, fmt.Errorf("%s failed - orderUuid is invalid", comby.GetTypeName(domainQry))
}
// retrieve item read model
model, err := qh.Readmodel.GetModel(domainQry.OrderUuid)
if err != nil {
return nil, err
}
// generate response
response := &CustomModelResponse{}
response.Item = model
// return response
return response, nil
}
The GetModel method is invoked by the Query Handler when processing the domain query CustomModelRequest
. This method handles the domain query by first validating the input data to ensure its correctness. Next, it calls the GetModel method of the Readmodel to retrieve the desired model. The retrieved model is then wrapped into the CustomModelResponse
structure and returned to the caller. And that's it — simple and efficient.
As briefly mentioned above, the method is not limited to using the Readmodel as its data source. It can also retrieve data from other sources, such as a static file, an HTTP resource, or a database.
List
The GetList method is similar to the GetModel method but is designed to address the typical needs of a robust listing operation. In most cases, users expect a list that supports pagination, filtering, and sorting — features this example implementation provides.
The CustomListRequest
structure contains parameters for the GetList method, which can be used to filter, sort, and paginate the data. The CustomListResponse
structure holds the list of models and pagination information to be returned to the user.
The implementation of the GetList method is slightly more complex than the GetModel method, as it involves additional steps to apply filtering, sorting, and pagination to the data. However, the core logic remains the same: retrieve the data from the Readmodel, process it according to the user's request, and return the result.
// simple/domain/order/query/qry.list.go
package query
import (
"context"
"fmt"
"comby.io/examples/simple/domain/order/readmodel"
"github.com/gradientzero/comby/v2"
)
type CustomListRequest struct {
TenantUuid string `json:"tenantUuid"`
Page int64 `json:"page,omitempty"`
PageSize int64 `json:"pageSize,omitempty"`
OrderBy string `json:"orderBy,omitempty"`
}
type CustomListResponse struct {
Orders []*readmodel.CustomOrderModel `json:"orders,omitempty"`
Total int64 `json:"total,omitempty"`
Page int64 `json:"page,omitempty"`
PageSize int64 `json:"pageSize,omitempty"`
}
func (qh *qryHandler) GetList(ctx context.Context, qry comby.Query, domainQry *CustomListRequest) (*CustomListResponse, error) {
var err error
// validate uuid
err = comby.ValidateUuid(domainQry.TenantUuid)
if err != nil {
return nil, fmt.Errorf("%s failed - tenantUuid not valid", comby.GetTypeName(domainQry))
}
// retrieve list
options := []func(*readmodel.ListOptions){}
options = append(options, readmodel.ListWithTenantUuid(domainQry.TenantUuid))
if domainQry.Page > 0 {
options = append(options, readmodel.ListWithPage(domainQry.Page))
}
if domainQry.PageSize > 0 {
options = append(options, readmodel.ListWithPageSize(domainQry.PageSize))
}
if len(domainQry.OrderBy) > 0 {
options = append(options, readmodel.ListWithOrderBy(domainQry.OrderBy))
}
modelList, total, err := qh.Readmodel.GetModelList(options...)
if err != nil {
return nil, err
}
// generate response
response := &CustomListResponse{}
response.Orders = modelList
response.Total = total
response.Page = 1
if domainQry.Page > 0 {
response.Page = domainQry.Page
}
response.PageSize = 1000
if domainQry.PageSize > 0 {
response.PageSize = domainQry.PageSize
}
// return response
return response, nil
}
Register in Facade
Finally, we need to register our query handler in the facade. This is done by calling the RegisterQueryHandler
method. This step ensures that the facade can route queries to the correct handler for processing.
// simple/domain/order/register.go
func Register(ctx context.Context, fc *comby.Facade) error {
// ...
qh := query.NewCustomQueryHandler(rm)
if err := comby.RegisterQueryHandler(fc, qh); err != nil {
return err
}
// ...
}