From 3d2e5ec027c8403e697bc43fce2e964561605d6c Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Date: Sun, 13 Apr 2025 00:59:18 -0600 Subject: [PATCH 1/4] Add Dispatcher implementation for CQRS pattern 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. --- .gitea/workflows/ci-basic.yml | 50 ++++++++ .gitea/workflows/ci-protected.yml | 50 ++++++++ .gitea/workflows/ci-tag.yml | 43 +++++++ .gitignore | 64 ++++++++++ dispatcher.go | 206 ++++++++++++++++++++++++++++++ dispatcher_test.go | 112 ++++++++++++++++ go.mod | 5 + go.sum | 2 + sonar-project.properties | 10 ++ 9 files changed, 542 insertions(+) create mode 100644 .gitea/workflows/ci-basic.yml create mode 100644 .gitea/workflows/ci-protected.yml create mode 100644 .gitea/workflows/ci-tag.yml create mode 100644 .gitignore create mode 100644 dispatcher.go create mode 100644 dispatcher_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 sonar-project.properties diff --git a/.gitea/workflows/ci-basic.yml b/.gitea/workflows/ci-basic.yml new file mode 100644 index 0000000..b17134d --- /dev/null +++ b/.gitea/workflows/ci-basic.yml @@ -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 ./... \ No newline at end of file diff --git a/.gitea/workflows/ci-protected.yml b/.gitea/workflows/ci-protected.yml new file mode 100644 index 0000000..35994eb --- /dev/null +++ b/.gitea/workflows/ci-protected.yml @@ -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 ./... \ No newline at end of file diff --git a/.gitea/workflows/ci-tag.yml b/.gitea/workflows/ci-tag.yml new file mode 100644 index 0000000..7fcf0ea --- /dev/null +++ b/.gitea/workflows/ci-tag.yml @@ -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 ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a820c4 --- /dev/null +++ b/.gitignore @@ -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__/ \ No newline at end of file diff --git a/dispatcher.go b/dispatcher.go new file mode 100644 index 0000000..a166da1 --- /dev/null +++ b/dispatcher.go @@ -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) +} diff --git a/dispatcher_test.go b/dispatcher_test.go new file mode 100644 index 0000000..54efd40 --- /dev/null +++ b/dispatcher_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8387a89 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitstormr.dev/stone-utils/stonecqrs + +go 1.24 + +require gitstormr.dev/stone-utils/stoneerror v1.0.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d5a0ca6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..388d554 --- /dev/null +++ b/sonar-project.properties @@ -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 \ No newline at end of file From 5bea19ecf1b10d6facb4cafd80840e0015881433 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Date: Sun, 13 Apr 2025 01:19:06 -0600 Subject: [PATCH 2/4] Add error handling test cases for dispatcher Introduce error-producing handlers to test command, query, and event dispatch processes. Ensure proper error propagation when handlers encounter failures. Validate that expected errors are correctly returned in all scenarios. --- dispatcher_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/dispatcher_test.go b/dispatcher_test.go index 54efd40..b266481 100644 --- a/dispatcher_test.go +++ b/dispatcher_test.go @@ -2,6 +2,7 @@ package stonecqrs import ( "context" + "errors" "fmt" "testing" ) @@ -49,6 +50,30 @@ func (handler *testEventHandler) Handle( return nil } +type testErrorHandler struct{} + +func (handler *testErrorHandler) Handle( + ctx context.Context, cmd Command, +) ([]Event, error) { + return nil, errors.New("test error") +} + +type testErrorQueryHandler struct{} + +func (handler *testErrorQueryHandler) Handle( + ctx context.Context, query Query, +) (interface{}, error) { + return nil, errors.New("test error") +} + +type testErrorEventHandler struct{} + +func (handler *testErrorEventHandler) Handle( + ctx context.Context, event Event, +) error { + return errors.New("test error") +} + func Test_NewDispatcher(t *testing.T) { d := NewDispatcher() if d == nil { @@ -98,7 +123,7 @@ func Test_UnknownEvent(t *testing.T) { t.Fatal("expected error") } - err = d.DispatchEvent(context.Background(), unknownEvent{}) + _, err = d.DispatchQuery(context.Background(), unknownEvent{}) if err == nil { t.Fatal("expected error") @@ -110,3 +135,25 @@ func Test_UnknownEvent(t *testing.T) { t.Fatal("expected error") } } + +func Test_Error(t *testing.T) { + d := NewDispatcher() + d.RegisterCommandHandler(testCommand{}, &testErrorHandler{}) + d.RegisterQueryHandler(testQuery{}, &testErrorQueryHandler{}) + d.RegisterEventHandler(testEvent{}, &testErrorEventHandler{}) + + _, err := d.DispatchCommand(context.Background(), testCommand{}) + if err == nil { + t.Fatal("expected error") + } + + _, err = d.DispatchQuery(context.Background(), testQuery{}) + if err == nil { + t.Fatal("expected error") + } + + err = d.DispatchEvent(context.Background(), testEvent{}) + if err == nil { + t.Fatal("expected error") + } +} From 0c1694c94762779fa27368e5beab4cb877be1faf Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Date: Sun, 13 Apr 2025 01:33:25 -0600 Subject: [PATCH 3/4] Add test for DispatchEvent with error handling This commit introduces a new test, Test_DispatchEventWithError, to verify error handling when dispatching events. It ensures that the dispatcher properly returns an error when an event handler triggers one. --- dispatcher_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dispatcher_test.go b/dispatcher_test.go index b266481..fd486ff 100644 --- a/dispatcher_test.go +++ b/dispatcher_test.go @@ -136,6 +136,17 @@ func Test_UnknownEvent(t *testing.T) { } } +func Test_DispatchEventWithError(t *testing.T) { + d := NewDispatcher() + d.RegisterCommandHandler(testCommand{}, &testCommandHandler{}) + d.RegisterEventHandler(testEvent{}, &testErrorEventHandler{}) + + err := d.DispatchEvent(context.Background(), testEvent{}) + if err == nil { + t.Fatal("expected error") + } +} + func Test_Error(t *testing.T) { d := NewDispatcher() d.RegisterCommandHandler(testCommand{}, &testErrorHandler{}) From 672ab940425c1e2f50dfbcd33c9837a2086265d6 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Date: Sun, 13 Apr 2025 09:14:03 -0600 Subject: [PATCH 4/4] Update README with enhanced documentation and examples Revamp README to include detailed usage examples, installation instructions, scientific benchmarks, and core CQRS/Event-Sourcing patterns. Improve explanation of StoneCQRS features and benefits, targeting clearer communication and user engagement. --- README.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 701de2f..0d1d3ee 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,85 @@ -# stonecqrs +# 🔥 StoneCQRS - **10 billion% decoupled architecture!** -StoneCQRS - Ultimate CQRS/Event toolkit! ⚡ Command/Query separation with event-driven superpowers. Perfect for complex domains & microservices. Saga-ready architecture baked in. Part of stone-utils ecosystem. 10 billion% more decoupled! 🚀 \ No newline at end of file +Ultimate CQRS/Event toolkit! ⚡ Command/Query separation with event-driven superpowers. Perfect for complex domains & microservices. Saga-ready architecture baked in. Part of stone-utils ecosystem. 10 billion% more decoupled! 🚀 + +[![Kingdom of Science Approved](https://img.shields.io/badge/Approved%20By-Kingdom%20of%20Science-blueviolet)]() +[![EVENT-DRIVEN](https://img.shields.io/badge/Patterns-SO_BADASS!-blueviolet)]() +[![10 BILLION](https://img.shields.io/badge/Throughput-10_Billion%25-blueviolet)]() + +## 🚀 Why StoneCQRS? + +- **CQRS/Event-Sourcing** with Senku-level precision +- **Saga-ready** architecture out of the box +- **100% decoupled** components (like good science should be) +- **10 billion events/sec** handling capacity (theoretical) + +## 💥 Installation + +```bash +go get gitstormr.dev/stone-utils/stonecqrs@latest +``` + +## ⚡ Basic Usage + +```go +package main + +import ( + "gitstormr.dev/stone-utils/stonecqrs" +) + +type CreateOrderCommand struct{ UserID string } +type OrderCreatedEvent struct{ OrderID string } + +type OrderHandler struct{} + +func (h *OrderHandler) Handle(ctx context.Context, cmd stonecqrs.Command) ([]stonecqrs.Event, error) { + return []stonecqrs.Event{OrderCreatedEvent{OrderID: "123"}}, nil +} + +func main() { + dispatcher := stonecqrs.NewDispatcher() + dispatcher.RegisterCommandHandler(CreateOrderCommand{}, &OrderHandler{}) + events, _ := dispatcher.DispatchCommand(ctx, CreateOrderCommand{UserID: "u-456"}) + // events = [OrderCreatedEvent] +} +``` + +## 🔬 Core Patterns + +### Command -> Event -> Reaction + +```text +[CreateOrderCommand] + ↓ +[OrderCreatedEvent] → [PaymentHandler] → [InventoryHandler] +``` + +### Saga ready flow + +```go +// 1. Execute command +events, _ := dispatcher.DispatchCommand(ctx, cmd) + +// 2. Automatic event processing +// (Handlers trigger subsequent commands) + +// 3. Compensation on failure +// (Built-in rollback capabilities) +``` + +## ⚗️ Scientific Benchmarks + +| METRIC | STANDARD LIB | STONECQRS | +|---------------|---------------|-----------| +| Decoupling | 30% | 10B% | +| Scalability | 1x | ∞x | +| Debuggability | ❌ | 🔍💡 | + +**Join the Scientific Revolution!** + +> "This isn't just CQRS - it's revolutionizing software patterns like we revived civilization!" - Senku Ishigami + +Kingdom of Science Approved + +(Now with 100% more Chrome screaming "SO BADASS!") \ No newline at end of file