Add CQRS library 'Singularity' with core handlers and tests
All checks were successful
Go CI/CD / go-ci (push) Successful in 36s

Introduces the 'Singularity' implementation, a CQRS Bus that supports commands, queries, and events, along with middleware extensibility. Includes comprehensive tests, modular files for commands, queries, and events, as well as CI/CD workflows.
This commit is contained in:
Rene Nochebuena 2025-04-26 22:25:23 -06:00
parent 0708376c0b
commit bc3c5aa846
Signed by: Rene Nochebuena
GPG Key ID: A9FD83117EA538D8
13 changed files with 764 additions and 2 deletions

View File

@ -0,0 +1,30 @@
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
env:
GOPROXY: https://proxy.golang.org,direct
GOPRIVATE: gitstormr.dev
GONOSUMDB: gitstormr.dev
steps:
- uses: https://gitstormr.dev/actions/go-ci@v1.1.1
with:
workflow-type: 'basic'
go-version: '1.24'
build-type: 'library'
publish-docker: 'false'

View File

@ -0,0 +1,25 @@
name: Go CI/CD
run-name: ${{ github.actor }} is running CI/CD protected
on:
pull_request:
branches:
- main
- release/**
- develop
jobs:
go-ci:
runs-on: ubuntu-latest
env:
GOPROXY: https://proxy.golang.org,direct
GOPRIVATE: gitstormr.dev
GONOSUMDB: gitstormr.dev
steps:
- uses: https://gitstormr.dev/actions/go-ci@v1.1.1
with:
workflow-type: 'basic'
go-version: '1.24'
build-type: 'library'
publish-docker: 'false'

View File

@ -0,0 +1,25 @@
name: Go CI/CD
run-name: ${{ github.actor }} is running CI/CD protected
on:
push:
branches:
- main
- release/**
- develop
jobs:
go-ci:
runs-on: ubuntu-latest
env:
GOPROXY: https://proxy.golang.org,direct
GOPRIVATE: gitstormr.dev
GONOSUMDB: gitstormr.dev
steps:
- uses: https://gitstormr.dev/actions/go-ci@v1.1.1
with:
workflow-type: 'protected'
go-version: '1.24'
build-type: 'library'
publish-docker: 'false'

View File

@ -0,0 +1,23 @@
name: Go CI/CD
run-name: ${{ github.actor }} is running CI/CD Tag
on:
push:
tags:
- '*'
jobs:
go-ci:
runs-on: ubuntu-latest
env:
GOPROXY: https://proxy.golang.org,direct
GOPRIVATE: gitstormr.dev
GONOSUMDB: gitstormr.dev
steps:
- uses: https://gitstormr.dev/actions/go-ci@v1.1.1
with:
workflow-type: 'tag'
go-version: '1.24'
build-type: 'library'
publish-docker: 'false'

67
.gitignore vendored Normal file
View File

@ -0,0 +1,67 @@
# 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__/
# Config file
config.json

View File

@ -1,3 +1,48 @@
# gravity-wells
# Gravity Wells
Lightweight CQRS and Event Bus forged with the gravity of domain-driven design. Commands bend reality. Queries catch light. Events echo through time.
_Forge gravity into your code. Shape time, distort space, and carve legacy into the architecture of tomorrow._
**Gravity Wells** is a lightweight CQRS and Event-Driven library, built to resonate with the spirit of domain-driven architectures.
At its core lies the **Singularity** — a gravitational nexus where commands, queries, and events intertwine, bending the causal fabric of your systems.
---
## ✨ Philosophy
- **Commands** bend domain realities.
- **Queries** capture the light escaping from the core.
- **Events** ripple as gravitational waves across time.
Gravity Wells does not impose structures — it unlocks potential.
It does not dictate architectures — it forges paths.
---
## 🧠 Core Principles
- **Hexagonal Architecture (Ports & Adapters) friendly**
- **Built for CQRS, Event Sourcing, and Saga orchestration**
- **Extensible through Command, Query, and Event Middlewares**
- **Zero external dependencies: pure minimalism**
---
## 🪐 Infinite Expansion
Gravity Wells is only the first fragment of a greater constellation:
- `gravitywells/sagas`: Saga orchestrators, weaving constellations of process.
- `gravitywells/observatory`: Observability to record the curvatures of events.
- `gravitywells/eventstorm`: A DSL for mapping event storms across untamed domains.
---
> _Commands are gravity wells.
> Queries are photons escaping.
> Events are echoes traveling eternally across the fabric of systems._
---
# 📜 License
**MIT** — because knowledge, like gravity, belongs to everyone.

17
cqrs/bus.go Normal file
View File

@ -0,0 +1,17 @@
package cqrs
import (
"context"
)
type Bus interface {
RegisterCommandHandler(cmd Command, handler CommandHandler)
RegisterQueryHandler(query Query, handler QueryHandler)
RegisterEventHandler(event Event, handler EventHandler)
UseCommandMiddleware(middleware CommandMiddleware)
UseQueryMiddleware(middleware QueryMiddleware)
UseEventMiddleware(middleware EventMiddleware)
ExecuteCommand(ctx context.Context, cmd Command) ([]Event, error)
ExecuteQuery(ctx context.Context, query Query) (interface{}, error)
EmitEvent(ctx context.Context, event Event) error
}

21
cqrs/command.go Normal file
View File

@ -0,0 +1,21 @@
package cqrs
import (
"context"
)
type Command interface{}
type CommandMiddleware func(CommandHandler) CommandHandler
type CommandHandlerFunc func(ctx context.Context, cmd Command) ([]Event, error)
func (f CommandHandlerFunc) Handle(ctx context.Context, cmd Command) (
[]Event, error,
) {
return f(ctx, cmd)
}
type CommandHandler interface {
Handle(ctx context.Context, cmd Command) ([]Event, error)
}

19
cqrs/event.go Normal file
View File

@ -0,0 +1,19 @@
package cqrs
import (
"context"
)
type Event interface{}
type EventMiddleware func(EventHandler) EventHandler
type EventHandlerFunc func(ctx context.Context, event Event) error
func (f EventHandlerFunc) Handle(ctx context.Context, event Event) error {
return f(ctx, event)
}
type EventHandler interface {
Handle(ctx context.Context, event Event) error
}

21
cqrs/query.go Normal file
View File

@ -0,0 +1,21 @@
package cqrs
import (
"context"
)
type Query interface{}
type QueryHandler interface {
Handle(ctx context.Context, query Query) (interface{}, error)
}
type QueryHandlerFunc func(ctx context.Context, query Query) (Query, error)
func (f QueryHandlerFunc) Handle(ctx context.Context, query Query) (
interface{}, error,
) {
return f(ctx, query)
}
type QueryMiddleware func(QueryHandler) QueryHandler

193
cqrs/singularity.go Normal file
View File

@ -0,0 +1,193 @@
package cqrs
import (
"context"
"fmt"
"log"
"reflect"
"sync"
)
type singularity struct {
commandHandlers map[string]CommandHandler
queryHandlers map[string]QueryHandler
eventHandlers map[string][]EventHandler
commandMiddlewares []CommandMiddleware
queryMiddlewares []QueryMiddleware
eventMiddlewares []EventMiddleware
mutex sync.RWMutex
}
func NewSingularity() Bus {
return &singularity{
commandHandlers: make(map[string]CommandHandler),
queryHandlers: make(map[string]QueryHandler),
eventHandlers: make(map[string][]EventHandler),
commandMiddlewares: make([]CommandMiddleware, 0),
queryMiddlewares: make([]QueryMiddleware, 0),
eventMiddlewares: make([]EventMiddleware, 0),
}
}
func (s *singularity) RegisterCommandHandler(
cmd Command, handler CommandHandler,
) {
s.mutex.Lock()
defer s.mutex.Unlock()
handler = applyCommandMiddleware(handler, s.commandMiddlewares)
s.commandHandlers[typeName(cmd)] = handler
}
func (s *singularity) RegisterQueryHandler(query Query, handler QueryHandler) {
s.mutex.Lock()
defer s.mutex.Unlock()
handler = applyQueryMiddleware(handler, s.queryMiddlewares)
s.queryHandlers[typeName(query)] = handler
}
func (s *singularity) RegisterEventHandler(event Event, handler EventHandler) {
s.mutex.Lock()
defer s.mutex.Unlock()
handler = applyEventMiddleware(handler, s.eventMiddlewares)
name := typeName(event)
s.eventHandlers[name] = append(s.eventHandlers[name], handler)
}
func (s *singularity) UseCommandMiddleware(middleware CommandMiddleware) {
s.commandMiddlewares = append(s.commandMiddlewares, middleware)
}
func (s *singularity) UseQueryMiddleware(middleware QueryMiddleware) {
s.queryMiddlewares = append(s.queryMiddlewares, middleware)
}
func (s *singularity) UseEventMiddleware(middleware EventMiddleware) {
s.eventMiddlewares = append(s.eventMiddlewares, middleware)
}
func (s *singularity) ExecuteCommand(ctx context.Context, cmd Command) (
[]Event, error,
) {
handler, err := s.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 = s.emitEvents(ctx, events...); err != nil {
return events, fmt.Errorf(
"command succeeded, but dispatching resulting events failed: %v",
typeName(cmd),
)
}
}
return events, nil
}
func (s *singularity) ExecuteQuery(
ctx context.Context, query Query,
) (interface{}, error) {
handler, err := s.getQueryHandler(query)
if err != nil {
return nil, err
}
return handler.Handle(ctx, query)
}
func (s *singularity) EmitEvent(ctx context.Context, event Event) error {
handlers := s.getEventHandlers(event)
if len(handlers) == 0 {
log.Printf("[RuneBringer] No handler listening for event: %T", event)
return nil
}
for _, handler := range handlers {
if err := handler.Handle(ctx, event); err != nil {
return err
}
}
return nil
}
func (s *singularity) emitEvents(ctx context.Context, events ...Event) error {
for _, event := range events {
if err := s.EmitEvent(ctx, event); err != nil {
return err
}
}
return nil
}
func (s *singularity) getCommandHandler(cmd Command) (CommandHandler, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
handler, exists := s.commandHandlers[typeName(cmd)]
if !exists {
return nil, fmt.Errorf(
"no command handler registered for command type '%v'",
typeName(cmd),
)
}
return handler, nil
}
func (s *singularity) getQueryHandler(query Query) (QueryHandler, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
handler, exists := s.queryHandlers[typeName(query)]
if !exists {
return nil, fmt.Errorf(
"no query handler registered for query type '%v'", typeName(query),
)
}
return handler, nil
}
func (s *singularity) getEventHandlers(event Event) []EventHandler {
s.mutex.RLock()
defer s.mutex.RUnlock()
return s.eventHandlers[typeName(event)]
}
func typeName(v interface{}) string {
t := reflect.TypeOf(v)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t.Name()
}
func applyCommandMiddleware(
handler CommandHandler, middlewares []CommandMiddleware,
) CommandHandler {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
func applyQueryMiddleware(
handler QueryHandler, middlewares []QueryMiddleware,
) QueryHandler {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
func applyEventMiddleware(
handler EventHandler, middlewares []EventMiddleware,
) EventHandler {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}

273
cqrs/singularity_test.go Normal file
View File

@ -0,0 +1,273 @@
package cqrs_test
import (
"context"
"errors"
"log"
"reflect"
"testing"
"gitstormr.dev/code-raider/gravity-wells/cqrs"
)
type testEvent struct {
Echo string
}
type testCommand struct {
Echo string
}
type testQuery struct {
Echo string
}
type successCommandHandler struct {
SendEvent bool
}
func (s *successCommandHandler) Handle(
ctx context.Context, cmd cqrs.Command,
) ([]cqrs.Event, error) {
command, _ := cmd.(*testCommand)
log.Printf("Processing command: %s\n", command.Echo)
if s.SendEvent {
return []cqrs.Event{testEvent{Echo: command.Echo}}, nil
} else {
return nil, nil
}
}
type successEventHandler struct{}
func (e *successEventHandler) Handle(
ctx context.Context, evt cqrs.Event,
) error {
event, _ := evt.(testEvent)
log.Printf("Processing event: %s\n", event.Echo)
return nil
}
type failureCommandHandler struct{}
func (e *failureCommandHandler) Handle(
ctx context.Context, cmd cqrs.Command,
) ([]cqrs.Event, error) {
return nil, errors.New("failed")
}
type successQueryHandler struct{}
func (e *successQueryHandler) Handle(
ctx context.Context, query cqrs.Query,
) (interface{}, error) {
return query, nil
}
type failureQueryHandler struct{}
func (e *failureQueryHandler) Handle(
ctx context.Context, query cqrs.Query,
) (interface{}, error) {
return nil, errors.New("failed")
}
type failureEventHandler struct{}
func (e *failureEventHandler) Handle(
ctx context.Context, evt cqrs.Event,
) error {
return errors.New("failed")
}
func logTestCommandMiddleware(next cqrs.CommandHandler) cqrs.CommandHandler {
return cqrs.CommandHandlerFunc(
func(ctx context.Context, cmd cqrs.Command) ([]cqrs.Event, error) {
log.Printf("Received command: %s\n", cmd)
return next.Handle(ctx, cmd)
},
)
}
func logTestQueryMiddleware(next cqrs.QueryHandler) cqrs.QueryHandler {
return cqrs.QueryHandlerFunc(
func(ctx context.Context, query cqrs.Query) (cqrs.Query, error) {
log.Printf("Received query: %s\n", query)
return next.Handle(ctx, query)
},
)
}
func logTestEventMiddleware(next cqrs.EventHandler) cqrs.EventHandler {
return cqrs.EventHandlerFunc(
func(ctx context.Context, evt cqrs.Event) error {
log.Printf("Received event: %s\n", evt)
return next.Handle(ctx, evt)
},
)
}
func TestNewRuneBringer(t *testing.T) {
bus := cqrs.NewSingularity()
if bus == nil {
t.Error("RuneBringer should not be nil")
}
busType := reflect.TypeOf(bus)
if busType.Kind() == reflect.Ptr {
busType = busType.Elem()
}
if busType.Name() != "singularity" {
t.Errorf(
"RuneBringer should be of type RuneBringer, got %s", busType.Name(),
)
}
}
func TestRuneBringer_CommandHandle(t *testing.T) {
tests := []struct {
SendEvent bool
}{
{true},
{false},
}
for _, tt := range tests {
bus := cqrs.NewSingularity()
bus.UseCommandMiddleware(logTestCommandMiddleware)
bus.UseEventMiddleware(logTestEventMiddleware)
bus.RegisterCommandHandler(
&testCommand{}, &successCommandHandler{SendEvent: tt.SendEvent},
)
bus.RegisterEventHandler(&testEvent{}, &successEventHandler{})
events, err := bus.ExecuteCommand(
context.Background(), &testCommand{Echo: "Hello"},
)
if err != nil {
t.Error("Error should be nil")
}
if tt.SendEvent {
if len(events) == 0 {
t.Errorf("Expected 1 event, got %d", len(events))
}
} else {
if len(events) != 0 {
t.Errorf("Expected 0 events, got %d", len(events))
}
}
}
}
func TestRuneBringer_CommandHandleFailure(t *testing.T) {
bus := cqrs.NewSingularity()
bus.UseCommandMiddleware(logTestCommandMiddleware)
bus.RegisterCommandHandler(
&testCommand{}, &failureCommandHandler{},
)
_, err := bus.ExecuteCommand(
context.Background(), &testCommand{Echo: "Hello"},
)
if err == nil {
t.Error("Error should not be nil")
}
}
func TestRuneBringer_CommandMissingHandler(t *testing.T) {
bus := cqrs.NewSingularity()
bus.UseCommandMiddleware(logTestCommandMiddleware)
_, err := bus.ExecuteCommand(
context.Background(), &testCommand{Echo: "Hello"},
)
if err == nil {
t.Error("Error should not be nil")
}
}
func TestRuneBringer_CommandHandleEventFailure(t *testing.T) {
tests := []struct {
SendEvent bool
EventHandler cqrs.EventHandler
}{
{true, &failureEventHandler{}},
{true, nil},
{false, nil},
}
for _, tt := range tests {
bus := cqrs.NewSingularity()
bus.UseCommandMiddleware(logTestCommandMiddleware)
bus.RegisterCommandHandler(
&testCommand{}, &successCommandHandler{SendEvent: tt.SendEvent},
)
if tt.EventHandler != nil {
bus.RegisterEventHandler(&testEvent{}, tt.EventHandler)
}
_, err := bus.ExecuteCommand(
context.Background(), &testCommand{Echo: "Hello"},
)
bus.RegisterEventHandler(&testEvent{}, tt.EventHandler)
if tt.SendEvent && tt.EventHandler != nil {
if err == nil {
t.Error("Error should not be nil")
}
} else {
if err != nil {
t.Error("Error should be nil")
}
}
}
}
func TestRuneBringer_QueryHandle(t *testing.T) {
bus := cqrs.NewSingularity()
bus.UseQueryMiddleware(logTestQueryMiddleware)
bus.RegisterQueryHandler(
&testQuery{}, &successQueryHandler{},
)
result, err := bus.ExecuteQuery(
context.Background(), &testQuery{Echo: "Hello"},
)
if err != nil {
t.Error("Error should be nil")
}
if result == nil {
t.Error("Result should not be nil")
}
}
func TestRuneBringer_QueryHandleFailure(t *testing.T) {
bus := cqrs.NewSingularity()
bus.UseQueryMiddleware(logTestQueryMiddleware)
bus.RegisterQueryHandler(
&testQuery{}, &failureQueryHandler{},
)
_, err := bus.ExecuteQuery(
context.Background(), &testQuery{Echo: "Hello"},
)
if err == nil {
t.Error("Error should not be nil")
}
}
func TestRuneBringer_QueryMissingHandler(t *testing.T) {
bus := cqrs.NewSingularity()
bus.UseQueryMiddleware(logTestQueryMiddleware)
_, err := bus.ExecuteQuery(
context.Background(), &testQuery{Echo: "Hello"},
)
if err == nil {
t.Error("Error should not be nil")
}
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module gitstormr.dev/code-raider/gravity-wells
go 1.24