Skip to content

Part 9: REST API

TIP

It is entirely up to the user to decide whether to use a REST API or not. Users can implement other protocols such as raw TCP, gRPC, or any other communication method. For the purposes of this explanation, we assume the user is utilizing comby's default configuration, which already includes a REST API implementation (using huma. If we use comby's default— such as Tenant, Account, Group, Permission, etc. — a REST API, REST API Documentation, and an OpenAPI Dpecification are automatically generated with the help of huma.

The REST API - or APIs in general - are independent of the facade, except for interacting with it to send commands or queries. Therefore, the first step is to structure the API properly and place it appropriately within the project.

Structure your API

Let's start with the best practices and create a new folder api at root level of your project. In larger projects, it’s common to use a unified structure for the API. For example, this structure can be represented within the internal directory. As a convention, an api.go file is used to initialize the API and register the various resources.

Each resource is placed in its own folder (here order), and within that folder, the methods required for the resource are implemented. Every resource has its own resource.go file, where the individual methods are exposed to huma. This approach ensures a clean, modular organization of the API.

go
main.go
api
├── api.go
├── internal
│   └── internal.go
└── order
    ├── delete.go
    ├── list.custom.go
    ├── list.go
    ├── patch.go
    ├── place.go
    ├── resource.go
    ├── retrieve.custom.go
    └── retrieve.go

This structure is not absolutely necessary, but has proven itself in practice.

This time, let’s take a top-down approach. First, we need to register our new REST API in the main.go file. Next, we define the resources we want to include in our API. In this example, we’ll use the Order resource.

Setup REST API

In main.go, we register our REST API. Essentially, we call the RegisterEndpoints function, which we implement in api.go. Before that, we initialize a router with mux, mount the admin dashboard, create a new instance of the Huma API, and register comby's default endpoints. Finally, we register our custom endpoints.

go
func main() {
	// ...
	fc, _ := comby.NewFacade()
    // ...
    
	// create new mux
	mux := http.NewServeMux()

    // (optional) add comby admin dashboard
	mux.Handle("/admin/", web.AdminHandler())

    // create huma api
    humaApi := humago.New(mux, huma.DefaultConfig("My API", "1.0.0"))

	// add comby's default api
	if err := combyApi.RegisterDefaults(fc, humaApi); err != nil {
		panic(err)
	}

    // add our custom api
	if err := api.RegisterEndpoints(fc, humaApi); err != nil {
		panic(err)
	}
    // ...

Register Endpoints

Nothing unusual happens here. We simply call the existing methods of each API resource — in this case, just order — and register them with huma, which is handled internally by the Register function. The advantage of this approach is that it provides a clear overview, especially as more resources are added to the API.

go
// simple/api/api.go
package api

import (
	"comby.io/examples/simple/api/order"
	"github.com/danielgtaylor/huma/v2"
	"github.com/gradientzero/comby/v2"
)

func RegisterEndpoints(fc *comby.Facade, api huma.API) error {
	order.NewResource(fc, api).Register()
	return nil
}

It is important to pass the facade to the resources so they can use it to dispatch Commands and Queries. The facade also provides convenient access to other valuable features, such as the DataStore, runtime information, metrics, and much more, enabling efficient interaction with the system's core functionalities.

api/resource.go

The core of our REST API for the Order domain lies here. All methods required for the this domain are registered within the Register function. Based on practical experience, we define two helper methods, NewCommand and NewQuery, which streamline the creation of commands and queries with the correct domain (here "Order") set, reducing boilerplate code.

Apart from that, the Resource struct simply holds pointers to the facade and the underlying API. All operations are implemented as receiver methods on the Resource struct, granting them automatic access to both the facade and API.

go
// simple/api/order/resource.go
package order

import (
	"context"
	"net/http"

	"comby.io/examples/simple/api/internal"
	"comby.io/examples/simple/domain/order/query"
	"comby.io/examples/simple/domain/order/readmodel"

	"github.com/danielgtaylor/huma/v2"
	"github.com/gradientzero/comby/v2"
	"github.com/gradientzero/comby/v2/api"
)

// Shortcuts
func NewCommand(ctx context.Context, domainCmd comby.DomainCmd) comby.Command {
	return api.NewCommand(ctx, "Order", domainCmd)
}

func NewQuery(ctx context.Context, domainQry comby.DomainQry) comby.Query {
	return api.NewQuery(ctx, "Order", domainQry)
}

// Resource struct
type Resource struct {
	fc  *comby.Facade
	api huma.API
}

// NewResource creates new instance
func NewResource(fc *comby.Facade, api huma.API) *Resource {
	resource := &Resource{
		fc:  fc,
		api: api,
	}
	return resource
}

// Register all operations
func (rs *Resource) Register() {

	huma.Register(rs.api, huma.Operation{
		Security:    internal.Bearer,
		OperationID: "place-order",
		Description: "Place new order",
		Method:      http.MethodPost,
		Path:        "/api/tenants/{tenantUuid}/orders",
		Tags:        []string{"Order"},
	}, rs.PlaceOrder)

	huma.Register(rs.api, huma.Operation{
		Security:    internal.Bearer,
		OperationID: "order-list-custom",
		Description: "List all orders via custom readmodel",
		Method:      http.MethodGet,
		Path:        "/api/tenants/{tenantUuid}/orders/custom",
		Tags:        []string{"Order"},
	}, rs.ListCustom)

	huma.Register(rs.api, huma.Operation{
		Security:    internal.Bearer,
		OperationID: "order-list",
		Description: "List all orders via predefined readmodel",
		Method:      http.MethodGet,
		Path:        "/api/tenants/{tenantUuid}/orders",
		Tags:        []string{"Order"},
	}, rs.List)

	huma.Register(rs.api, huma.Operation{
		Security:    internal.Bearer,
		OperationID: "order-delete",
		Description: "Delete existing order",
		Method:      http.MethodDelete,
		Path:        "/api/tenants/{tenantUuid}/orders/{orderUuid}",
		Tags:        []string{"Order"},
	}, rs.Delete)

	huma.Register(rs.api, huma.Operation{
		Security:    internal.Bearer,
		OperationID: "order-update",
		Description: "Update existing order",
		Method:      http.MethodPatch,
		Path:        "/api/tenants/{tenantUuid}/orders/{orderUuid}",
		Tags:        []string{"Order"},
	}, rs.Patch)

	huma.Register(rs.api, huma.Operation{
		Security:    internal.Bearer,
		OperationID: "order-retrieve-custom",
		Description: "Retrieve existing order via custom readmodel",
		Method:      http.MethodGet,
		Path:        "/api/tenants/{tenantUuid}/orders/custom/{orderUuid}",
		Tags:        []string{"Order"},
	}, rs.RetrieveCustom)

	huma.Register(rs.api, huma.Operation{
		Security:    internal.Bearer,
		OperationID: "order-retrieve",
		Description: "Retrieve existing order via predefined readmodel",
		Method:      http.MethodGet,
		Path:        "/api/tenants/{tenantUuid}/orders/{orderUuid}",
		Tags:        []string{"Order"},
	}, rs.Retrieve)

}

// helpers
type ResponseCustomOrderModel struct {
	Body struct {
		Item *readmodel.CustomOrderModel `json:"item"`
	}
}

type ResponseCustomOrderList struct {
	Body struct {
		api.TotalPageSize
		Items []*readmodel.CustomOrderModel `json:"items"`
	}
}

func toResponseItem(res any) *ResponseCustomOrderModel {
	resTyped := res.(*query.CustomModelResponse)
	resp := &ResponseCustomOrderModel{}
	resp.Body.Item = resTyped.Item
	return resp
}

func toResponseList(res any) *ResponseCustomOrderList {
	resTyped := res.(*query.CustomListResponse)
	resp := &ResponseCustomOrderList{}
	resp.Body.Items = resTyped.Orders
	resp.Body.Total = resTyped.Total
	resp.Body.Page = resTyped.Page
	resp.Body.PageSize = resTyped.PageSize
	return resp
}

Here, we will focus on just one example, as the registration process with huma can be referenced from the official Huma documentation. Let’s take the PlaceOrder method as an example:

go
huma.Register(rs.api, huma.Operation{
    Security:    internal.Bearer,
    OperationID: "place-order",
    Description: "Place new order",
    Method:      http.MethodPost,
    Path:        "/api/tenants/{tenantUuid}/orders",
    Tags:        []string{"Order"},
}, rs.PlaceOrder)

The internal.Bearer is just a helper structure that we defined ourselves. It is declared in the internal.go file and used by the default middleware provided by comby.

go
// simple/api/internal/api.go
package internal

var Bearer = []map[string][]string{
	{"bearer": {}},
}

The other fields are essential and must be set correctly:

  • OperationID: A unique identifier for the operation. For example, setting the OperationID to "place-order" automatically generates a method in the REST API called orderPlaceOrder, which can also be used in a TypeScript client generated from the resulting OpenAPI specification. (In comby, we use this for the Admin Dashboard for example)
  • Description: A description of the operation. While not mandatory, it helps make the generated documentation more understandable by providing context for the operation.
  • Method: The HTTP method used for the operation. Here, we use http.MethodPost because we are creating a new order.
  • Path: The endpoint path for the operation. For example, /api/tenants/{tenantUuid}/orders specifies creating a new order for a specific tenant. Even if the tenant information is not explicitly used in this example, it has proven highly practical to include it in almost every operation (exceptions might include account-specific operations, runtime, or other endpoints). To avoid boilerplate code, this information can be used to automatically set the tenant information in Commands and Queries, sparing the user from having to provide it repeatedly.
  • Tags: An array of tags associated with the operation. These tags are used in the generated documentation to group operations. For this example, we use the tag "Order".
  • Method itself: The function that performs the actual work, in this case, rs.PlaceOrder.

The remaining methods in resource.go are helper functions and shared structs designed to simplify the main methods. While not strictly necessary, they are highly practical in real-world applications. For example, they convert the response values from queries into the appropriate types defined as request or response objects for the respective operations.

Place Order

A typical REST API endpoint usually consists of a request and a response definition. The request contains the information needed to execute the operation, while the response contains the information returned after the operation completes. In this case, the request is a PlaceOrderRequest, and the response is a PlaceOrderResponse struct.

go
// simple/api/order/place.go
package order

import (
	"context"

	"comby.io/examples/simple/domain/order/command"
	"comby.io/examples/simple/domain/order/query"

	"github.com/gradientzero/comby/v2/api"
)

type OrderItem struct {
	Sku      string
	Quantity int64
}

type RequestOrderPlace struct {
	TenantUuid string `json:"tenantUuid" path:"tenantUuid" validate:"required"`
	Body       struct {
		OrderUuid string    `json:"orderUuid" validate:"required"`
		Order     OrderItem `json:"order" validate:"required"`
	}
}

func (rs *Resource) PlaceOrder(ctx context.Context, req *RequestOrderPlace) (*ResponseCustomOrderModel, error) {
	// create command
	cmd := NewCommand(ctx, &command.PlaceOrder{
		OrderUuid: req.Body.OrderUuid,
		Item: &command.PlaceOrderItem{
			Sku:      req.Body.Order.Sku,
			Quantity: req.Body.Order.Quantity,
		},
	})

	// dispatch command to CommandBus or broker
	if _, err := rs.fc.DispatchCommand(ctx, cmd); err != nil {
		return nil, api.SchemaError(err)
	}

	// wait for command fully processed
	if err := rs.fc.WaitForCmd(ctx, cmd); err != nil {
		return nil, api.SchemaError(err)
	}

	// create query
	qry := NewQuery(ctx, &query.CustomModelRequest{
		OrderUuid: req.Body.OrderUuid,
	})

	// dispatch query on this instance
	if res, err := rs.fc.DispatchQuery(ctx, qry); err != nil {
		return nil, api.SchemaError(err)
	} else {
		return toResponseItem(res), nil
	}
}

Since commands are executed asynchronously, the usual approach is to return a RequestUuid that allows the user to track the status of the original command. However, in modern applications, we take advantage of a blocking method, WaitForCmd, provided by the Facade, which allows the HTTP request to wait for the command to complete fully. It’s important to note that we are not blocking the Facade itself—only the HTTP request method, which is called as a Goroutine by the underlying router. If WaitForCmd does not complete within the specified time (300 seconds by default), a timeout response is returned.

Afterward, a query is generated to retrieve the newly created order and the query response is converted into a PlaceOrderResponse and returned to the client.

This principle applies consistently to nearly all REST API endpoints and follows a repetitive pattern. Therefore, we will only briefly touch on the other methods from this point forward.

Delete Order

go
// simple/api/order/delete.go
package order

import (
	"context"

	"comby.io/examples/simple/domain/order/command"

	"github.com/gradientzero/comby/v2/api"
)

type RequestOrderDelete struct {
	TenantUuid string `json:"tenantUuid" path:"tenantUuid" validate:"required"`
	OrderUuid  string `json:"orderUuid" path:"orderUuid" validate:"required"`
}

func (rs *Resource) Delete(ctx context.Context, req *RequestOrderDelete) (*api.ResponseEmpty, error) {

	// create command
	cmd := NewCommand(ctx, &command.DeleteOrder{
		OrderUuid: req.OrderUuid,
	})

	// dispatch command to CommandBus or broker
	if _, err := rs.fc.DispatchCommand(ctx, cmd); err != nil {
		return nil, api.SchemaError(err)
	}

	// wait for command fully processed
	if err := rs.fc.WaitForCmd(ctx, cmd); err != nil {
		return nil, api.SchemaError(err)
	}

	// render response
	return nil, nil
}

Technically, we don't need to wait here, but we do so to ensure that any errors are returned as appropriate error messages. If we didn't wait, the user would need to check the status using the RequestUuid, which is rarely utilized in practice.

Update Order

go
// simple/api/order/patch.go
package order

import (
	"context"

	"comby.io/examples/simple/domain/order/command"
	"comby.io/examples/simple/domain/order/query"

	"github.com/gradientzero/comby/v2/api"
)

type RequestOrderUpdate struct {
	TenantUuid string `json:"tenantUuid" path:"tenantUuid" validate:"required"`
	OrderUuid  string `json:"orderUuid" path:"orderUuid" validate:"required"`
	Body       struct {
		Comment       string   `json:"comment,omitempty"`
		AnyOtherField string   `json:"anyOtherField,omitempty"`
		PatchedFields []string `json:"patchedFields"`
	}
}

func (rs *Resource) Patch(ctx context.Context, req *RequestOrderUpdate) (*ResponseCustomOrderModel, error) {

	// create command
	cmd := NewCommand(ctx, &command.UpdateOrder{
		OrderUuid:     req.OrderUuid,
		Comment:       req.Body.Comment,
		AnyOtherField: req.Body.AnyOtherField,
		PatchedFields: req.Body.PatchedFields,
	})

	// dispatch command to CommandBus or broker
	if _, err := rs.fc.DispatchCommand(ctx, cmd); err != nil {
		return nil, api.SchemaError(err)
	}

	// wait for command fully processed
	if err := rs.fc.WaitForCmd(ctx, cmd); err != nil {
		return nil, api.SchemaError(err)
	}

	// create query
	qry := NewQuery(ctx, &query.CustomModelRequest{
		OrderUuid: req.OrderUuid,
	})

	// dispatch query on this instance
	if res, err := rs.fc.DispatchQuery(ctx, qry); err != nil {
		return nil, api.SchemaError(err)
	} else {
		return toResponseItem(res), nil
	}
}

Notably, the PatchedFields field deserves special mention as it is used as a convention to explicitly specify which fields should be updated. This addresses the ambiguity of whether a field should be set to empty or simply ignored if it is not provided. Without this convention, such cases can lead to inconsistencies, as different REST API frameworks (chi and co) handle this scenario differently. By explicitly defining the fields to be updated, we eliminate this uncertainty and ensure consistent behavior. The handling is done in the UpdateOrder domain command handler.

Retrieve Order

go
// simple/api/order/retrieve.go
package order

import (
	"context"

	"comby.io/examples/simple/domain/order/aggregate"

	"github.com/gradientzero/comby/v2/api"
	"github.com/gradientzero/comby/v2/projection"
)

type RequestOrderModel struct {
	TenantUuid string `json:"tenantUuid" path:"tenantUuid" validate:"required"`
	OrderUuid  string `json:"orderUuid" path:"orderUuid" validate:"required"`
}

func (rs *Resource) Retrieve(ctx context.Context, req *RequestOrderModel) (*api.QueryResponseWithBody[*aggregate.Order], error) {

	// create query
	domainQry := &projection.QueryModelRequest[*aggregate.Order]{
		AggregateUuid: req.OrderUuid,
	}
	qry := NewQuery(ctx, domainQry)

	// dispatch query on this instance
	if res, err := rs.fc.DispatchQuery(ctx, qry); err != nil {
		return nil, api.SchemaError(err)
	} else {
		if response, err := api.QueryResponseModelWithBodyFrom[*aggregate.Order](res); err != nil {
			return nil, api.SchemaError(err)
		} else {
			return response, nil
		}
	}
}

In our example, we have two methods to retrieve an order: one through the projection and another via a custom query or custom readmodel. In this case, we use the former. We create a query from a generic structure and dispatch it to the facade. Since we have a custom HTTP response structure for this query, we convert the query response into our defined structure and return it.

Retrieve Custom Order

go
// simple/api/order/retrieve.custom.go
package order

import (
	"context"

	"comby.io/examples/simple/domain/order/query"

	"github.com/gradientzero/comby/v2/api"
)

type RequestCustomOrderModel struct {
	TenantUuid string `json:"tenantUuid" path:"tenantUuid" validate:"required"`
	OrderUuid  string `json:"orderUuid" path:"orderUuid" validate:"required"`
}

func (rs *Resource) RetrieveCustom(ctx context.Context, req *RequestCustomOrderModel) (*ResponseCustomOrderModel, error) {

	// create query
	qry := NewQuery(ctx, &query.CustomModelRequest{
		OrderUuid: req.OrderUuid,
	})

	// dispatch query on this instance
	if res, err := rs.fc.DispatchQuery(ctx, qry); err != nil {
		return nil, api.SchemaError(err)
	} else {
		return toResponseItem(res), nil
	}
}

A custom query is user-defined and is not part of the default comby configuration. In this case, the query retrieves the requested order using a specific OrderUuid. The query is dispatched to the facade, which utilizes the corresponding custom readmodel to fetch the custom order model. The query response is then converted into our defined structure and returned to the client.

List Order

go
// simple/api/order/list.go
package order

import (
	"context"

	"comby.io/examples/simple/domain/order/aggregate"

	"github.com/gradientzero/comby/v2/api"
	"github.com/gradientzero/comby/v2/projection"
)

type RequestOrderList struct {
	TenantUuid string `json:"tenantUuid" path:"tenantUuid" validate:"required"`
	api.PageSizeOrderBy
}

func (rs *Resource) List(ctx context.Context, req *RequestOrderList) (*api.QueryResponseListWithBody[*aggregate.Order], error) {

	// params
	Page, PageSize, OrderBy := api.ExtractPaginationFrom(ctx, req.PageSizeOrderBy)
	tenantUuid := api.ExtractTenantUuidFrom(ctx, req.TenantUuid)

	// create query
	domainQry := &projection.QueryListRequest[*aggregate.Order]{
		TenantUuid: tenantUuid,
		Page:       Page,
		PageSize:   PageSize,
		OrderBy:    OrderBy,
	}
	qry := NewQuery(ctx, domainQry)

	// dispatch query on this instance
	if res, err := rs.fc.DispatchQuery(ctx, qry); err != nil {
		return nil, api.SchemaError(err)
	} else {
		if response, err := api.QueryResponseListWithBodyFrom[*aggregate.Order](res); err != nil {
			return nil, api.SchemaError(err)
		} else {
			return response, nil
		}
	}
}

Similar to the "Retrieve Order" method, there are two approaches here as well. In practice, only one of these is typically used, but for demonstration purposes, both approaches are shown for listing. The main difference is that the request parameters are extracted from the path URL using a helper function.

List Custom Order

go
// simple/api/order/list.custom.go
package order

import (
	"context"

	"comby.io/examples/simple/domain/order/query"

	"github.com/gradientzero/comby/v2/api"
)

type RequestCustomOrderList struct {
	TenantUuid string `json:"tenantUuid" path:"tenantUuid" validate:"required"`
	api.PageSizeOrderBy
}

func (rs *Resource) ListCustom(ctx context.Context, req *RequestCustomOrderList) (*ResponseCustomOrderList, error) {

	// params
	Page, PageSize, OrderBy := api.ExtractPaginationFrom(ctx, req.PageSizeOrderBy)
	tenantUuid := api.ExtractTenantUuidFrom(ctx, req.TenantUuid)

	// create query
	qry := NewQuery(ctx, &query.CustomListRequest{
		TenantUuid: tenantUuid,
		Page:       Page,
		PageSize:   PageSize,
		OrderBy:    OrderBy,
	})

	// dispatch query on this instance
	if res, err := rs.fc.DispatchQuery(ctx, qry); err != nil {
		return nil, api.SchemaError(err)
	} else {
		return toResponseList(res), nil
	}
}

Technically, this is very similar to Retrieve Custom.

Helpers

As briefly mentioned earlier, comby's default middlewares automatically handle tasks such as extracting the TargetTenantUuid from the URL path of the endpoint. Additionally, sender information is extracted from the Authorization Header and stored in the RequestContext, which is then included when sending commands or queries.

These helper functions and middlewares are defined within comby itself and are applied automatically when using the default configuration. That's the reason we use api.NewCommand and api.NewQuery in our custom NewCommand and NewQuery methods. This allows the user to focus entirely on the content and business logic without worrying about these underlying details.