diff --git a/.gitea/workflows/ci-basic.yml b/.gitea/workflows/ci-basic.yml new file mode 100644 index 0000000..640df69 --- /dev/null +++ b/.gitea/workflows/ci-basic.yml @@ -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' \ No newline at end of file diff --git a/.gitea/workflows/ci-protected-pr.yml b/.gitea/workflows/ci-protected-pr.yml new file mode 100644 index 0000000..19d4fd4 --- /dev/null +++ b/.gitea/workflows/ci-protected-pr.yml @@ -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' \ No newline at end of file diff --git a/.gitea/workflows/ci-protected-push.yml b/.gitea/workflows/ci-protected-push.yml new file mode 100644 index 0000000..d8ac5bb --- /dev/null +++ b/.gitea/workflows/ci-protected-push.yml @@ -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' \ No newline at end of file diff --git a/.gitea/workflows/ci-tags.yml b/.gitea/workflows/ci-tags.yml new file mode 100644 index 0000000..bc8a8f0 --- /dev/null +++ b/.gitea/workflows/ci-tags.yml @@ -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' \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e0148e --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index 9022b5d..618d9e4 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +_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. \ No newline at end of file diff --git a/cqrs/bus.go b/cqrs/bus.go new file mode 100644 index 0000000..8023d76 --- /dev/null +++ b/cqrs/bus.go @@ -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 +} diff --git a/cqrs/command.go b/cqrs/command.go new file mode 100644 index 0000000..8478ca7 --- /dev/null +++ b/cqrs/command.go @@ -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) +} diff --git a/cqrs/event.go b/cqrs/event.go new file mode 100644 index 0000000..310c0d1 --- /dev/null +++ b/cqrs/event.go @@ -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 +} diff --git a/cqrs/query.go b/cqrs/query.go new file mode 100644 index 0000000..da4bf9d --- /dev/null +++ b/cqrs/query.go @@ -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 diff --git a/cqrs/singularity.go b/cqrs/singularity.go new file mode 100644 index 0000000..cf24258 --- /dev/null +++ b/cqrs/singularity.go @@ -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 +} diff --git a/cqrs/singularity_test.go b/cqrs/singularity_test.go new file mode 100644 index 0000000..2db2dd4 --- /dev/null +++ b/cqrs/singularity_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4ffdbab --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitstormr.dev/code-raider/gravity-wells + +go 1.24