Skip to content

Readmodel

The Readmodel interface in comby defines the contract for managing queryable representations of domain state. It combines two key responsibilities: event handling and state restoration, enabling the read model to remain up-to-date and consistent with the event-sourced domain.r validating the command and emitting events as a result of the command being executed.

Readmodel interface

In comby an Readmodel is defined as an interface as follows:

go
type Readmodel interface {
	// EventHandler provides methods for handling domain events and updating the read model.
	EventHandler

	// StateRestorer provides methods for restoring the read model's state from a sequence of events.
	StateRestorer
}

As an extension of the EventHandler interface, the Readmodel includes methods for processing domain events. This allows the read model to react to events emitted by aggregates, updating its state accordingly to ensure it reflects the latest changes in the system.

The Readmodel also implements the StateRestorer interface, which provides methods for reconstructing the read model's state from a historical sequence of events. This functionality is essential for initializing or recovering the read model in event-sourced architectures, ensuring that it can be derived entirely from the event store.

By combining these interfaces, the Readmodel serves as a foundational component for building consistent, queryable views of domain data in comby.

Base Readmodel

The BaseReadmodel is a foundational implementation of the Readmodel interface in comby, designed to manage the creation, restoration, and maintenance of queryable projections in event-sourced architectures. It provides mechanisms for handling domain events, restoring state from an event repository, and tracking metrics related to event processing.

go
type BaseReadmodel struct {
	*BaseIdentifier                                // Embeds a base identifier for readmodel identification.
	domainEvtHandlerMap map[string][]DomainEventHandlerEntry // Registered domain event handlers (keyed by event path).
	EventRepository     *EventRepository           // Repository for retrieving events.
	ReadmodelStoreBackend ReadmodelStoreBackend     // Backend store for readmodel persistence.
	Options             *BaseReadmodelOptions       // Configuration options for the readmodel.
	CatchedEvents       []Event                    // Events captured during state restoration.
	RestorationState    string                     // Current state of the restoration process.
	lastEventTimestamp  int64                      // Timestamp of the last processed event.
	ctxCancelFunc       context.CancelFunc         // Cancel function for the restoration context.
	metrics             *metrics                   // Metrics for tracking readmodel performance.
}

At its core, the BaseReadmodel embeds a BaseIdentifier to uniquely associate the read model with a domain and name. It maintains a map of registered domain event handlers (domainEvtHandlerMap), keyed by event path for efficient lookup. The read model also integrates with an EventRepository, which serves as the source for historical events during state restoration or rehydration.

Key features of the BaseReadmodel include:

  • State Restoration: The RestorationState and CatchedEvents fields track the progress of restoring the read model’s state from events stored in the repository.
  • Event Handling: The GetDomainEventHandlers method dynamically retrieves the list of handler functions for a specified domain event, ensuring efficient processing. The GetDomainEvents method provides a comprehensive list of unique domain events that the read model can handle.
  • ReadmodelStoreBackend: Configurable persistence backend for the readmodel (e.g., in-memory, SQLite, Postgres).
  • Options: The BaseReadmodelOptions hold configuration for the readmodel.
  • Metrics and Context Management: The metrics field tracks performance-related data for the read model, while the ctxCancelFunc allows graceful cancellation of long-running restoration processes.

Constructor

The NewBaseReadmodel function initializes the BaseReadmodel. It accepts the Facade and optional BaseReadmodelOption functions for configuration:

go
func NewBaseReadmodel(fc *Facade, opts ...BaseReadmodelOption) *BaseReadmodel

BaseReadmodelOption

OptionDescription
WithReadmodelStoreBackend(backend ReadmodelStoreBackend)Sets the persistence backend for the readmodel.

To extend functionality, the AddDomainEventHandler function allows developers to register handler functions for specific domain events. Each handler is associated with metadata, such as the event’s type path and name, and stored in the domainEvtHandlerMap. This design ensures flexibility in event handling, supporting both specific event types and catch-all handlers (e.g., AllEvents).

Custom User-defined Readmodel

go
type CustomOrderReadmodel struct {
	*comby.BaseReadmodel
	// any additional fields
}

func NewCustomReadmodel(fc *comby.Facade, opts ...comby.BaseReadmodelOption) *CustomOrderReadmodel {
	rm := &CustomOrderReadmodel{}
	rm.BaseReadmodel = comby.NewBaseReadmodel(fc, opts...)
	rm.Domain = "Order"
	rm.Name = "CustomOrderReadmodel"

	// register domain event handlers
	comby.AddDomainEventHandler(rm, rm.TenantCreatedEvent)
	comby.AddDomainEventHandler(rm, rm.TenantRemovedEvent)
	// ...
	return rm
}

func (rm *CustomOrderReadmodel) OnRestoreState(ctx context.Context, opts ...comby.RestoreStateOption) (comby.RestorationDoneCh, error) {
	// any additional reset logic
	return rm.BaseReadmodel.RestoreState(ctx, opts...)
}

The CustomOrderReadmodel is a user-defined read model in the "Order" domain, extending the functionality of the BaseReadmodel in comby. It is designed to maintain a queryable representation of order-related data, optimized for quick access through tenant and order UUID mappings.

This read model leverages two concurrent maps (sync.Map) to efficiently store and retrieve data:

mapByTenantUuid: Maps tenant UUIDs to their associated orders. mapByOrderUuid: Maps order UUIDs to their respective details. The NewCustomReadmodel function initializes an instance of the CustomOrderReadmodel. During initialization, it sets the domain and name for identification and registers a set of domain event handlers to process events such as:

  • TenantCreatedEvent
  • TenantRemovedEvent

These handlers enable the read model to react to domain events, updating its state to reflect the latest changes in the system.

The RestoreState method is overridden to reset the internal maps (mapByTenantUuid and mapByOrderUuid) before invoking the base restoration process. This ensures a clean slate for restoring the read model’s state from historical events stored in the event repository. The method returns a RestorationDoneCh, signaling when the restoration process is complete.

By combining the extensibility of the BaseReadmodel with domain-specific logic and efficient data structures, the CustomOrderReadmodel provides a scalable and performant solution for querying.

History Events

History Events provide a complete audit trail of all changes to an aggregate. This is useful for:

  • Audit trails - Track who changed what and when
  • Change history UI - Display modification history to users
  • Debugging - Investigate issues by reviewing event sequences
  • Compliance - Meet regulatory requirements for data change tracking

On-Demand History (v2.15.0+)

Breaking Change

In v2.15.0, the history mechanism was fundamentally changed. History is now loaded on-demand from the EventStore at query time, rather than being accumulated and stored within the readmodel. This means:

  • AppendHistoryEvent no longer exists — do not call it in event handlers
  • CreateHistoryEvent no longer exists
  • ProjectionAggregateOptionWithHistoryEnabled no longer exists — no configuration needed on ProjectionAggregate
  • Events now carry a ReqCtx (RequestContext) with sender information, enabling history without breaking CQRS (no CommandRepository access needed in readmodels)

How it works: When history is requested (e.g., via ?includeHistory=true), the system calls EventRepository.GetAggregateHistory() to load the complete event history for the aggregate directly from the EventStore. Each event's ReqCtx provides the sender information (who triggered the change), which is included in the HistoryEventModel.

HistoryEventModel Structure

Each history event contains:

go
type HistoryEventModel struct {
    EventUuid          string // Unique event identifier
    DomainEvtName      string // Name of the domain event (e.g., "AccountRegisteredEvent")
    DomainEvtData      string // JSON-serialized event payload
    Version            int64  // Aggregate version after this event
    CreatedAt          int64  // Timestamp (nanoseconds)
    SenderTenantUuid   string // Tenant that triggered the event (from Event ReqCtx)
    SenderIdentityUuid string // Identity that triggered the event (from Event ReqCtx)
}

History for Default Domains

For comby's built-in domains (Account, Tenant, Identity, etc.), history is available on-demand for all domains without any configuration. Simply register defaults as usual:

go
comby.RegisterDefaults(ctx, fc)

When querying, pass ?includeHistory=true to load the complete event history from the EventStore.

History for Custom Readmodels

For custom readmodels extending BaseReadmodel, no special configuration is needed — history is always available on-demand when ?includeHistory=true is passed:

go
rm := NewCustomReadmodel(fc)

Your event handlers do not need to handle history at all — simply update the model state. History is loaded automatically from the EventStore when queried:

go
func (rm *CustomReadmodel) MyDomainEvent(ctx context.Context, evt comby.Event, domainEvt *MyDomainEvent) error {
    m, found, _ := rm.store.Get(evt.GetAggregateUuid())
    if !found {
        m = &MyModel{Uuid: evt.GetAggregateUuid()}
    }

    // Update model fields — no AppendHistoryEvent needed!
    m.SomeField = domainEvt.SomeField

    return rm.store.Set(evt.GetAggregateUuid(), m)
}

History for ProjectionAggregate

For ProjectionAggregate readmodels, no history configuration is needed when creating the projection. History is loaded on-demand when queried:

go
projRm := comby.NewProjectionAggregate(
    fc,
    aggregate.NewMyAggregate,
    // No history option needed — history is loaded on-demand
)
comby.RegisterEventHandler(fc, projRm)

Querying with History

History events are not returned by default to optimize performance. Request them explicitly:

Via API (query parameter):

bash
# Single item
GET /api/accounts/{accountUuid}?includeHistory=true

# List (use with caution - can return large payloads)
GET /api/accounts?includeHistory=true

Via Query programmatically:

go
qry := &query.AccountQueryModel{
    AccountUuid:    accountUuid,
    IncludeHistory: true,
}
res, _ := fc.DispatchQuery(ctx, comby.NewQuery("Account", qry))
account := res.(*query.AccountQueryItemResponse).Item
// account.HistoryEvents contains the history

Via ProjectionAggregate:

go
model, found, _ := pa.GetModel(
    aggregateUuid,
    comby.ProjectionModelGetWithHistoryEvent(true),
)
// model.HistoryEvents contains the history

Via ProjectionAggregate List:

go
models, total, err := projRm.GetModelList(
    comby.ProjectionModelListWithHistoryEvent(true),
)

Performance Considerations

WARNING

On-demand history loading is more storage-efficient than the previous approach (no duplicate event data in readmodels), but each history query goes to the EventStore. Consider these best practices:

  • Avoid on List queries - Requesting history on list endpoints triggers an EventStore query per aggregate
  • Use pagination - When displaying history in UI, implement pagination
  • EventStore performance - Ensure your EventStore backend (Postgres, SQLite) is properly indexed for aggregate UUID lookups