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.
	domainEvthandlers  []DomainEventHandlerEntry // Registered domain event handlers.
	EventRepository    *EventRepository          // Repository for retrieving events.
	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 list of registered domain event handlers (domainEvthandlers), allowing it to dynamically handle specific domain events as they occur. 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. 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. The NewBaseReadmodel function initializes the BaseReadmodel, setting up its dependencies, including the event repository and performance metrics tracking. This ensures that the read model is ready to handle events and restore state effectively.

To extend functionality, the addDomainEventHandler method 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 domainEvthandlers list. 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(EventRepository *comby.EventRepository) *CustomOrderReadmodel {
	rm := &CustomOrderReadmodel{}
	rm.BaseReadmodel = comby.NewBaseReadmodel(EventRepository)
	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. When enabled, the readmodel stores a HistoryEventModel[] array containing every domain event that modified the 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

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
    SenderIdentityUuid string // Identity that triggered the event
}

Enabling History for Default Domains

For comby's built-in domains (Account, Tenant, Identity, etc.), enable history tracking when registering defaults:

go
import "github.com/gradientzero/comby/v2/domain/internal"

// Enable history for specific domains
comby.RegisterDefaults(ctx, fc,
    internal.WithIncludeHistoryForDomains("Account", "Tenant", "Identity"),
)

Available domains: Account, Asset, Group, Identity, Invitation, Tenant, Webhook, Workspace

Enabling History for Custom Readmodels

For custom readmodels extending BaseReadmodel, enable history via the option:

go
rm := NewCustomReadmodel(fc, comby.WithReadmodelIncludeHistory(true))

Then use AppendHistoryEvent in your event handlers:

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...
    m.SomeField = domainEvt.SomeField
    
    // Append history event (only stored if history is enabled)
    var err error
    if m.HistoryEvents, err = rm.AppendHistoryEvent(ctx, evt, domainEvt, m.HistoryEvents); err != nil {
        return err
    }
    
    return rm.store.Set(evt.GetAggregateUuid(), m)
}

Enabling History for ProjectionAggregate

For ProjectionAggregate readmodels, enable history when creating the projection:

go
pa := comby.NewProjectionAggregate(
    fc,
    aggregate.NewMyAggregate,
    comby.ProjectionAggregateOptionWithHistoryEnabled(true),
)
comby.RegisterEventHandler(fc, pa)

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.ProjectionModelOptionIncludeHistory(true),
)
// model.HistoryEvents contains the history

Performance Considerations

WARNING

History events increase memory usage and response payload sizes. Consider these best practices:

  • Enable selectively - Only enable history for domains that require audit trails
  • Avoid on List queries - Requesting history on list endpoints can return very large payloads
  • Use pagination - When displaying history in UI, implement pagination
  • Consider archival - For long-lived aggregates, consider archiving old history events