Part 3: Domains
In the CQRS pattern, the architecture of the application is often divided based on domains. Each domain represents a distinct area of business logic and data requirements. The concept of domains in CQRS is closely related to Domain-Driven Design (DDD), a methodology that emphasizes the importance of a deep understanding of the domain to create effective software solutions.
Structure your Domain
When we talk about a domain, we are talking about a single package like Account
or Order
- including its aggregate, command, query and event handlers, reactors and custom permissions. From the user's perspective, one domain encompasses one entire package. In comby we have multiple domains: Account, Tenant, Group, Invitation, etc. Each domain has its own package and is responsible for its own business logic.
Let's start with the best practices and create a new domain called Order
. The Domain Order is intended to be a very simple example that shows the key components of the framework. First create a corresponding folder structure for the new domain so that we can use the domains more easily later:
main.go
domain
├── domain.go
└── order
├── aggregate
├── command
├── query
├── reactor
├── readmodel
└── register.go
This structure is not absolutely necessary, but has proven itself in practice. As a convention, a register.go
file is created in which all the domain-specific components of the new domain Order
is registered into the facade. Each domain has the folders: aggregate
, command
, query
, reactor
and readmodel
. This makes it easier to import, maintain and understand the code. In addition we already created a domain
folder in the root of the project. This is where all domains are registered in the domain.go
file.
Register Domain
We first start with the register.go
file:
// simple/domain/order/register.go
package order
import (
"context"
"comby.io/examples/simple/domain/order/aggregate"
"comby.io/examples/simple/domain/order/command"
"comby.io/examples/simple/domain/order/query"
"comby.io/examples/simple/domain/order/reactor"
"comby.io/examples/simple/domain/order/readmodel"
"github.com/gradientzero/comby/v2"
"github.com/gradientzero/comby/v2/projection"
)
func Register(ctx context.Context, fc *comby.Facade) error {
// register aggregate to facade
if err := comby.RegisterAggregate(fc, aggregate.NewAggregate); err != nil {
return err
}
// register command handler to facade
eventRepository := fc.GetEventRepository()
aggregateRepository := comby.NewAggregateRepository(
eventRepository,
aggregate.NewAggregate,
)
ch := command.NewCommandHandler(aggregateRepository)
if err := comby.RegisterCommandHandler(fc, ch); err != nil {
return err
}
// register (predefined) readmodel projecting your aggregate
projRm := projection.NewProjectionAggregate(
eventRepository,
aggregate.NewAggregate,
)
if err := comby.RegisterEventHandler(fc, projRm); err != nil {
return err
}
// register (predefined) query handler for the readmodel
projQh := projection.NewQueryHandler(projRm)
if err := comby.RegisterQueryHandler(fc, projQh); err != nil {
return err
}
// As an alternative or in addition, further custom read models can be added.
rm := readmodel.NewCustomReadmodel(eventRepository)
if err := comby.RegisterEventHandler(fc, rm); err != nil {
return err
}
// add custom query handler
qh := query.NewCustomQueryHandler(rm)
if err := comby.RegisterQueryHandler(fc, qh); err != nil {
return err
}
// add one reactor for demo purposes
rr := reactor.NewReactor(fc)
if err := comby.RegisterEventHandler(fc, rr); err != nil {
return err
}
return nil
}
In the register.go
file, we registers all components of the domain Order
in the facade. The Register
method is a convention and does not need to be followed exactly. This method is called in the domain.go
file, which is located in the domain
folder. The facade is passed as a parameter to the method. The facade is passed to the domain so that the domain can register its components in the facade. In the end, all that matters is the content – the creation and registration of the components to the facade.
Every component that is registered in the facade requires an Identifier
. This consists of Domain
and Name
. All components already have a default value, but we still need to set a suitable domain for our new domain: Order
. This means that the facade automatically groups all components by domain. This is important to avoid collisions. For example, if a domain "DomainA" has an event called "SomethingAdded" and another domain "DomainB" has the same event the facade can only distinguish the two events based on the domain.
It should also be mentioned that all components already implement a corresponding base component, so the user does not have to worry about event sourcing and CQRS related things. Concrete:
- A user defined aggregate always embedds the
*comby.BaseAggregate
struct. - A user defined command handler always embedds the
*comby.BaseCommandHandler
struct. - A user defined event handler always embedds the
*comby.BaseReadmodel
struct. - A user defined query handler always embedds the
*comby.BaseQueryHandler
struct. - A user defined reactor always embedds the
*comby.BaseReadmodel
struct.
A lot is already happening here, lets quickly go through the code:
Register Aggregate
// register aggregate to facade
if err := comby.RegisterAggregate(fc, aggregate.NewAggregate); err != nil {
return err
}
The aggregate is the actual model that represents the entire business logic and is used to generate new events. Each aggregate has a create function (NewAggregate
) that takes care of the initialization process and the handling of domain-specific events (DomainEvent
) with own domain-specific domain event handlers. If you call NewAggregate
you get a fully initialized aggregate including the information which domain-specific event is handled by which domain-specific event handler. The aggregate is registered with the generic help function comby.RegisterAggregate
so that the facade knows which events belong to which aggregate.
However, this does not mean that the Aggregate
instantiated with NewAggregate
is loaded in its current state. The AggregateRepository
handles this part, used for example in command handlers.
So when we register the aggregate in the facade, the facade stores all relevant information about the aggregate, such as the Domain
and Name
and the domain-specific events.
Register Command Handler
// register command handler to facade
eventRepository := fc.GetEventRepository()
aggregateRepository := comby.NewAggregateRepository(
eventRepository,
aggregate.NewAggregate,
)
ch := command.NewCommandHandler(aggregateRepository)
if err := comby.RegisterCommandHandler(fc, ch); err != nil {
return err
}
A command handler typically requires access to the AggregateRepository
in order to load corresponding aggregates in their current state. An AggregateRepository uses the EventRepository
, which can be provided by the Facade. This means we create a new -typed- AggregateRepository in the domain and pass it to our command handler.
If the command handler wants to access Aggregate from outside the domain, we also create a new AggregateRepository for the corresponding domain and pass this on to the command handlers. To create an AggregateRepository
, we use the helper function comby.NewAggregateRepository
.
The command handler is registered with the generic help function comby.RegisterCommandHandler
so that the facade knows which commands to forward to which command handler.
Register predefined Readmodel
// register (predefined) readmodel projecting your aggregate
projRm := projection.NewProjectionAggregate(
eventRepository,
aggregate.NewAggregate,
)
if err := comby.RegisterEventHandler(fc, projRm); err != nil {
return err
}
Now that the write side has been defined - without having implemented the aggregate or the command handler yet - we can now define the read side. A simple projection of the current state of a aggregate is often sufficient for the user. For this case, comby already offers a ready-made solution called ProjectionAggregate
.
The ProjectionAggregate
is a read model that projects the current state of the aggregate. It is registered with the generic help function comby.RegisterEventHandler
so that the facade knows which events to forward to which read model.
This reading model provides two predefined functions: GetModel
and GetList
, which can be used by the corresponding QueryHandler
.
Register predefined QueryHandler
// register (predefined) query handler for the readmodel
projQh := projection.NewQueryHandler(projRm)
if err := comby.RegisterQueryHandler(fc, projQh); err != nil {
return err
}
This predefined QueryHandler
is the missing counterpart to the predefined Readmodel (NewProjectionAggregate
). By registering with the facade (comby.RegisterQueryHandler
), the facade can now route its query handler's predefined queries for processing.
Register custom Readmodel
// As an alternative or in addition, further custom read models can be added.
rm := readmodel.NewCustomReadmodel(eventRepository)
if err := comby.RegisterEventHandler(fc, rm); err != nil {
return err
}
In addition to the predefined readmodel, the user can also create custom read models. These can be used to project the current state of the aggregate in a different way or to project additional information. The custom readmodel is registered with the generic help function comby.RegisterEventHandler
so that the facade knows which events to forward to which readmodel.
Register custom QueryHandler
// add custom query handler
qh := query.NewCustomQueryHandler(rm)
if err := comby.RegisterQueryHandler(fc, qh); err != nil {
return err
}
Here too we need an instance for the custom readmodel that also uses the custom readmodel. The queries defined there can be used directly in the REST API. More on that later.
Register custom Reactor
// add one reactor for demo purposes
rr := reactor.NewReactor(fc, aggregateRepository)
if err := comby.RegisterEventHandler(fc, rr); err != nil {
return err
}
Sometimes a domain needs a reactor that independently “reacts” to certain events. A reactor is nothing more than an Readmodel
without restoring its state. The reactor runs from the start of the application and only reacts to new events.
However, a difference from a normal readmodel is that the reactor works on the write side and usually has access to the AggregateRepository
so that the reactor itself can validate the actual state of one or more aggregates before a reactor creates new commands and sends them to the Facade. A reactor can alos just send emails or do other things.
Most of the time, however, this is used to represent a process that is dependent on other aggregates. Some examples:
- When a customer orders a product, an order confirmation email is sent.
- When a payment has been received, the status of the existing order will change from "Ordered" to "Paid"
Register your new domain
// simple/domain/domain.go
package domain
import (
"context"
"comby.io/examples/simple/domain/order"
"github.com/gradientzero/comby/v2"
)
func RegisterDomains(ctx context.Context, fc *comby.Facade) error {
if err := order.Register(ctx, fc); err != nil {
panic(err)
}
return nil
}
This is just an auxiliary function, but very useful in practice.
Register your domains
// main.go
func main() {
// ...
// register all your domains in one place (for example at domain/domain.go)
if err := domain.RegisterDomains(context.Background(), fc); err != nil {
panic(err)
}
// ...
// restore state
if err := fc.RestoreState(); err != nil {
panic(err)
}
// ...
}
Only after all domains have been registered Facade's fc.RestoreState
should be called.