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:
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.
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
BaseReadmodelOptionshold 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:
func NewBaseReadmodel(fc *Facade, opts ...BaseReadmodelOption) *BaseReadmodelBaseReadmodelOption
| Option | Description |
|---|---|
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
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:
AppendHistoryEventno longer exists — do not call it in event handlersCreateHistoryEventno longer existsProjectionAggregateOptionWithHistoryEnabledno 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:
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:
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:
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:
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:
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):
# Single item
GET /api/accounts/{accountUuid}?includeHistory=true
# List (use with caution - can return large payloads)
GET /api/accounts?includeHistory=trueVia Query programmatically:
qry := &query.AccountQueryModel{
AccountUuid: accountUuid,
IncludeHistory: true,
}
res, _ := fc.DispatchQuery(ctx, comby.NewQuery("Account", qry))
account := res.(*query.AccountQueryItemResponse).Item
// account.HistoryEvents contains the historyVia ProjectionAggregate:
model, found, _ := pa.GetModel(
aggregateUuid,
comby.ProjectionModelGetWithHistoryEvent(true),
)
// model.HistoryEvents contains the historyVia ProjectionAggregate List:
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