Add Dispatcher implementation for CQRS pattern
Some checks failed
Go CI/CD / go-ci (push) Has been cancelled

Introduced a Dispatcher to manage command, query, and event handlers in a thread-safe manner utilizing read-write mutexes. This includes handler registration and dispatching logic, error handling for unregistered handlers, and support for concurrent operations. Added comprehensive tests for handler registration, dispatching, and error scenarios.
This commit is contained in:
Rene Nochebuena 2025-04-13 00:59:18 -06:00
parent 1cbda29b36
commit 3d2e5ec027
Signed by: Rene Nochebuena
GPG Key ID: A9FD83117EA538D8
9 changed files with 542 additions and 0 deletions

View File

@ -0,0 +1,50 @@
name: Go CI/CD
run-name: ${{ github.actor }} is running CI/CD basic
on:
push:
branches-ignore:
- main
- release/**
- develop
pull_request:
branches-ignore:
- main
- release/**
- develop
jobs:
go-ci:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Download dependencies
shell: bash
run: |
go mod tidy -x
- name: Run tests
shell: bash
run: |
go test -json > test-report.out
go test -coverprofile=coverage.out
- name: SonarQube Analysis
uses: SonarSource/sonarqube-scan-action@v5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
- name: Build binary
shell: bash
run: |
go build ./...

View File

@ -0,0 +1,50 @@
name: Go CI/CD
run-name: ${{ github.actor }} is running CI/CD protected
on:
push:
branches:
- main
- release/**
- develop
pull_request:
branches:
- main
- release/**
- develop
jobs:
go-ci:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Download dependencies
shell: bash
run: |
go mod tidy -x
- name: Run tests
shell: bash
run: |
go test -json > test-report.out
go test -coverprofile=coverage.out
- name: SonarQube Analysis
uses: SonarSource/sonarqube-scan-action@v5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
- name: Build binary
shell: bash
run: |
go build ./...

View File

@ -0,0 +1,43 @@
name: Go CI/CD
run-name: ${{ github.actor }} is running CI/CD Tag
on:
push:
tags:
- '*'
jobs:
go-ci:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Download dependencies
shell: bash
run: |
go mod tidy -x
- name: Run tests
shell: bash
run: |
go test -json > test-report.out
go test -coverprofile=coverage.out
- name: SonarQube Analysis
uses: SonarSource/sonarqube-scan-action@v5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
- name: Build binary
shell: bash
run: |
go build ./...

64
.gitignore vendored Normal file
View File

@ -0,0 +1,64 @@
# Binaries for programs and plugins
bin
*.exe
*.dll
*.so
*.dylib
*.test
# Output of the 'go tool cover' command
*.out
coverage.xml
test-report.xml
# Directory for Go modules
/vendor/
# Go workspace file
go.work
go.work.sum
# Editor configs
*.swp
*.swo
*.bak
*.tmp
*.log
*.viminfo
*.un~
Session.vim
# JetBrains Rider specific
.idea/
*.iml
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/shelf/
# Sublime Text specific
*.sublime-workspace
*.sublime-project
# VSCode specific
.vscode/
.vscode/settings.json
.vscode/tasks.json
.vscode/launch.json
# Emacs specific
*~
\#*\#
.#*
# MacOS specific
.DS_Store
.AppleDouble
.LSOverride
# Node modules (in case of tools/scripts)
node_modules/
# Python virtual environments (for dev tools/scripts)
venv/
*.pyc
__pycache__/

206
dispatcher.go Normal file
View File

@ -0,0 +1,206 @@
package stonecqrs
import (
"context"
"fmt"
"sync"
"gitstormr.dev/stone-utils/stoneerror"
)
// errCommandHandlerNotFound indicates a missing command handler error.
// errQueryHandlerNotFound indicates a missing query handler error.
// errEventHandlerNotFound indicates a missing event handler error.
var (
errCommandHandlerNotFound = stoneerror.New(
1001, "command handler not found",
)
errQueryHandlerNotFound = stoneerror.New(1002, "query handler not found")
errEventHandlerNotFound = stoneerror.New(1003, "event handler not found")
)
// Command represents an action or operation to be handled by a dispatcher.
type Command interface{}
// Query represents a marker interface used to identify query types.
type Query interface{}
// Event represents a domain event within the application architecture.
type Event interface{}
// CommandHandler defines an interface for handling commands in a CQRS pattern.
// Implementations should process commands and return resulting events or errors.
// Handle processes the given command and returns any resulting events or error.
type CommandHandler interface {
Handle(ctx context.Context, cmd Command) ([]Event, error)
}
// QueryHandler defines an interface for handling queries within a context.
type QueryHandler interface {
Handle(ctx context.Context, query Query) (interface{}, error)
}
// EventHandler is an interface for handling specific types of events.
// It defines a single method Handle to process an Event with a context.
// Handle returns an error if the event handling fails.
type EventHandler interface {
Handle(ctx context.Context, event Event) error
}
// Dispatcher is responsible for managing and dispatching handlers.
// It supports command, query, and event handlers concurrently.
// Commands, queries, and events are identified by their type names.
// Uses a read-write mutex to ensure thread-safe operations.
type Dispatcher struct {
commandHandlers map[string]CommandHandler
queryHandlers map[string]QueryHandler
eventHandlers map[string][]EventHandler
mutex sync.RWMutex
}
// NewDispatcher creates and returns a new instance of Dispatcher.
func NewDispatcher() *Dispatcher {
return &Dispatcher{
commandHandlers: make(map[string]CommandHandler),
queryHandlers: make(map[string]QueryHandler),
eventHandlers: make(map[string][]EventHandler),
}
}
// RegisterCommandHandler registers a handler for a specific command type.
func (d *Dispatcher) RegisterCommandHandler(
cmd Command, handler CommandHandler,
) {
d.mutex.Lock()
defer d.mutex.Unlock()
d.commandHandlers[typeName(cmd)] = handler
}
// RegisterQueryHandler registers a handler for the specified query type.
func (d *Dispatcher) RegisterQueryHandler(query Query, handler QueryHandler) {
d.mutex.Lock()
defer d.mutex.Unlock()
d.queryHandlers[typeName(query)] = handler
}
// RegisterEventHandler registers a handler for a specific type of event.
func (d *Dispatcher) RegisterEventHandler(event Event, handler EventHandler) {
d.mutex.Lock()
defer d.mutex.Unlock()
name := typeName(event)
d.eventHandlers[name] = append(d.eventHandlers[name], handler)
}
// DispatchCommand processes a Command and dispatches the resulting Events.
// Returns the generated Events or an error if command handling fails.
func (d *Dispatcher) DispatchCommand(ctx context.Context, cmd Command) (
[]Event, error,
) {
handler, err := d.getCommandHandler(cmd)
if err != nil {
return nil, err
}
events, err := handler.Handle(ctx, cmd)
if err != nil {
return nil, err
}
if len(events) > 0 {
if err = d.DispatchEvents(ctx, events...); err != nil {
return events, stoneerror.Wrap(
err, 1004, "failed to dispatch generated events",
)
}
}
return events, nil
}
// DispatchQuery dispatches a query to its corresponding handler.
// It retrieves the query handler and invokes its Handle method.
// Returns the result of the query handler or an error if not found.
func (d *Dispatcher) DispatchQuery(
ctx context.Context, query Query,
) (interface{}, error) {
handler, err := d.getQueryHandler(query)
if err != nil {
return nil, err
}
return handler.Handle(ctx, query)
}
// DispatchEvents dispatches multiple events to their registered handlers.
// It processes each event sequentially and stops on the first error.
// Returns an error if any event fails to dispatch.
func (d *Dispatcher) DispatchEvents(
ctx context.Context, events ...Event,
) error {
for _, event := range events {
if err := d.DispatchEvent(ctx, event); err != nil {
return err
}
}
return nil
}
// DispatchEvent dispatches an event to all registered event handlers.
// It returns an error if any handler fails to process the event.
func (d *Dispatcher) DispatchEvent(ctx context.Context, event Event) error {
handlers, err := d.getEventHandlers(event)
if err != nil {
return err
}
for _, handler := range handlers {
if err = handler.Handle(ctx, event); err != nil {
return stoneerror.Wrap(err, 1005, "event handling failed")
}
}
return nil
}
// getCommandHandler retrieves the CommandHandler for the given Command.
// Returns an error if no handler is found.
func (d *Dispatcher) getCommandHandler(cmd Command) (CommandHandler, error) {
d.mutex.RLock()
defer d.mutex.RUnlock()
handler, exists := d.commandHandlers[typeName(cmd)]
if !exists {
return nil, errCommandHandlerNotFound
}
return handler, nil
}
// getQueryHandler retrieves the handler for a specific query type.
// It returns an error if the handler is not found.
func (d *Dispatcher) getQueryHandler(query Query) (QueryHandler, error) {
d.mutex.RLock()
defer d.mutex.RUnlock()
handler, exists := d.queryHandlers[typeName(query)]
if !exists {
return nil, errQueryHandlerNotFound
}
return handler, nil
}
// getEventHandlers retrieves all handlers for a specific event type.
// Returns an error if no handlers are registered for the event type.
func (d *Dispatcher) getEventHandlers(event Event) ([]EventHandler, error) {
d.mutex.RLock()
defer d.mutex.RUnlock()
handlers, exists := d.eventHandlers[typeName(event)]
if !exists {
return nil, errEventHandlerNotFound
}
return handlers, nil
}
// typeName returns the name of the type of the given interface value.
func typeName(v interface{}) string {
return fmt.Sprintf("%T", v)
}

112
dispatcher_test.go Normal file
View File

@ -0,0 +1,112 @@
package stonecqrs
import (
"context"
"fmt"
"testing"
)
type testCommand struct {
message string
}
type testQuery struct {
message string
}
type testEvent struct {
message string
}
type unknownEvent struct{}
type testCommandHandler struct{}
func (handler *testCommandHandler) Handle(
ctx context.Context, cmd Command,
) ([]Event, error) {
fmt.Println(cmd)
return []Event{
testEvent{message: "test"},
}, nil
}
type testQueryHandler struct{}
func (handler *testQueryHandler) Handle(
ctx context.Context, query Query,
) (interface{}, error) {
fmt.Println(query)
return "test", nil
}
type testEventHandler struct{}
func (handler *testEventHandler) Handle(
ctx context.Context, event Event,
) error {
fmt.Println(event)
return nil
}
func Test_NewDispatcher(t *testing.T) {
d := NewDispatcher()
if d == nil {
t.Fatal("expected non-nil dispatcher")
}
}
func Test_Register(t *testing.T) {
d := NewDispatcher()
d.RegisterCommandHandler(testCommand{}, &testCommandHandler{})
d.RegisterQueryHandler(testQuery{}, &testQueryHandler{})
d.RegisterEventHandler(testEvent{}, &testEventHandler{})
}
func Test_DispatchCommandWithEvents(t *testing.T) {
d := NewDispatcher()
d.RegisterCommandHandler(testCommand{}, &testCommandHandler{})
d.RegisterQueryHandler(testQuery{}, &testQueryHandler{})
d.RegisterEventHandler(testEvent{}, &testEventHandler{})
_, err := d.DispatchCommand(context.Background(), testCommand{})
if err != nil {
t.Fatal(err)
}
}
func Test_DispatchQuery(t *testing.T) {
d := NewDispatcher()
d.RegisterQueryHandler(testQuery{}, &testQueryHandler{})
_, err := d.DispatchQuery(context.Background(), testQuery{})
if err != nil {
t.Fatal(err)
}
}
func Test_UnknownEvent(t *testing.T) {
d := NewDispatcher()
d.RegisterCommandHandler(testCommand{}, &testCommandHandler{})
d.RegisterQueryHandler(testQuery{}, &testQueryHandler{})
d.RegisterEventHandler(testEvent{}, &testEventHandler{})
_, err := d.DispatchCommand(context.Background(), unknownEvent{})
if err == nil {
t.Fatal("expected error")
}
err = d.DispatchEvent(context.Background(), unknownEvent{})
if err == nil {
t.Fatal("expected error")
}
err = d.DispatchEvent(context.Background(), unknownEvent{})
if err == nil {
t.Fatal("expected error")
}
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module gitstormr.dev/stone-utils/stonecqrs
go 1.24
require gitstormr.dev/stone-utils/stoneerror v1.0.0

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
gitstormr.dev/stone-utils/stoneerror v1.0.0 h1:EJpn4MZBeYifWlCoQBEGmGdEtNABjOrUzJmQSqcXqY0=
gitstormr.dev/stone-utils/stoneerror v1.0.0/go.mod h1:Rs34Oz14ILsbkZ++Ov9PObTz7mRvyyvcCcML9AeyIyk=

10
sonar-project.properties Normal file
View File

@ -0,0 +1,10 @@
sonar.projectKey=f33dd35c-308c-4f08-ace2-6449efc238e9
sonar.projectName=stone-utils/stonecqrs
sonar.language=go
sonar.sources=.
sonar.exclusions=**/*_test.go
sonar.tests=.
sonar.test.inclusions=**/*_test.go
sonar.go.tests.reportPaths=test-report.out
sonar.go.coverage.reportPaths=coverage.out
sonar.qualitygate.wait=true