Skip to content

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:

go
// 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.

go
// 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.

go
// 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.

go
// 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
    }
    // ...
}