From c6de8530bad965d04c04bf8380b28cba8a28802e Mon Sep 17 00:00:00 2001 From: Jan Dudulski Date: Wed, 2 Jul 2025 22:10:18 +0200 Subject: [PATCH] decide.rb example --- README.md | 15 ++++ examples/decide.rb/.mutant.yml | 14 ++++ examples/decide.rb/Gemfile | 9 +++ examples/decide.rb/Gemfile.lock | 43 +++++++++++ examples/decide.rb/Makefile | 10 +++ examples/decide.rb/lib/project_management.rb | 6 ++ .../lib/project_management/handler.rb | 57 +++++++++++++++ .../decide.rb/lib/project_management/issue.rb | 71 +++++++++++++++++++ .../lib/project_management/repository.rb | 31 ++++++++ examples/decide.rb/test/issue_test.rb | 19 +++++ mise.toml | 2 + 11 files changed, 277 insertions(+) create mode 100644 examples/decide.rb/.mutant.yml create mode 100644 examples/decide.rb/Gemfile create mode 100644 examples/decide.rb/Gemfile.lock create mode 100644 examples/decide.rb/Makefile create mode 100644 examples/decide.rb/lib/project_management.rb create mode 100644 examples/decide.rb/lib/project_management/handler.rb create mode 100644 examples/decide.rb/lib/project_management/issue.rb create mode 100644 examples/decide.rb/lib/project_management/repository.rb create mode 100644 examples/decide.rb/test/issue_test.rb create mode 100644 mise.toml diff --git a/README.md b/README.md index 06df7fe..fe8b028 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,18 @@ More: https://blog.arkency.com/make-your-ruby-code-more-modular-and-functional-w - aggregate not aware of events - aggregate object is still responsible for holding invariants - no id in domain class + +### Decider + +[source](examples/decider) + +- clear separation of state sourcing (with projection) +- aggregate with decider pattern + +### Decider with decide.rb gem + +[source](examples/decide.rb) + +- clear separation of state sourcing (with projection) with expected version +- aggregate with decider pattern and decide.rb DSL +- mapping between infra (RES) events and domain events used inside decider diff --git a/examples/decide.rb/.mutant.yml b/examples/decide.rb/.mutant.yml new file mode 100644 index 0000000..d14ff20 --- /dev/null +++ b/examples/decide.rb/.mutant.yml @@ -0,0 +1,14 @@ +integration: + name: minitest +includes: + - lib +requires: + - project_management +matcher: + subjects: + - ProjectManagement* + ignore: + - ProjectManagement::Test* +coverage_criteria: + process_abort: true +usage: opensource \ No newline at end of file diff --git a/examples/decide.rb/Gemfile b/examples/decide.rb/Gemfile new file mode 100644 index 0000000..422a201 --- /dev/null +++ b/examples/decide.rb/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "ruby_event_store" +gem "decide.rb", require: "decider" +gem "minitest" +gem "mutant" +gem "mutant-minitest" diff --git a/examples/decide.rb/Gemfile.lock b/examples/decide.rb/Gemfile.lock new file mode 100644 index 0000000..b6aa790 --- /dev/null +++ b/examples/decide.rb/Gemfile.lock @@ -0,0 +1,43 @@ +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + concurrent-ruby (1.3.4) + decide.rb (0.6.2) + concurrent-ruby (~> 1.3) + diff-lcs (1.5.1) + minitest (5.25.1) + mutant (0.12.4) + diff-lcs (~> 1.3) + parser (~> 3.3.0) + regexp_parser (~> 2.9.0) + sorbet-runtime (~> 0.5.0) + unparser (~> 0.6.14) + mutant-minitest (0.12.4) + minitest (~> 5.11) + mutant (= 0.12.4) + parser (3.3.6.0) + ast (~> 2.4.1) + racc + racc (1.8.1) + regexp_parser (2.9.2) + ruby_event_store (2.15.0) + concurrent-ruby (~> 1.0, >= 1.1.6) + sorbet-runtime (0.5.11647) + unparser (0.6.15) + diff-lcs (~> 1.3) + parser (>= 3.3.0) + +PLATFORMS + arm64-darwin + x86_64-linux + +DEPENDENCIES + decide.rb + minitest + mutant + mutant-minitest + ruby_event_store + +BUNDLED WITH + 2.5.23 diff --git a/examples/decide.rb/Makefile b/examples/decide.rb/Makefile new file mode 100644 index 0000000..6895980 --- /dev/null +++ b/examples/decide.rb/Makefile @@ -0,0 +1,10 @@ +install: + @bundle install + +test: + @bundle exec ruby -Ilib -rproject_management test/issue_test.rb + +mutate: + @bundle exec mutant run + +.PHONY: install test mutate diff --git a/examples/decide.rb/lib/project_management.rb b/examples/decide.rb/lib/project_management.rb new file mode 100644 index 0000000..77e09d7 --- /dev/null +++ b/examples/decide.rb/lib/project_management.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require_relative "../../../shared/lib/project_management" +require_relative "project_management/handler" +require_relative "project_management/issue" +require_relative "project_management/repository" diff --git a/examples/decide.rb/lib/project_management/handler.rb b/examples/decide.rb/lib/project_management/handler.rb new file mode 100644 index 0000000..ccd4ee9 --- /dev/null +++ b/examples/decide.rb/lib/project_management/handler.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module ProjectManagement + class Handler + def initialize(event_store) + @decider = Issue::Decider.dimap_on_event( + fl: ->(event) { infra_to_domain(event) }, + fr: ->(event) { domain_to_infra(event) } + ) + @repository = Repository.new(event_store) + end + + def call(cmd) + state = @repository.load(cmd.id, @decider) + events = @decider.decide(cmd, state) + @repository.store(cmd.id, events) + end + + private + + def infra_to_domain(event) + case event + in IssueOpened + Issue::IssueOpened.new(issue_id: event.data[:issue_id]) + in IssueResolved + Issue::IssueResolved.new(issue_id: event.data[:issue_id]) + in IssueClosed + Issue::IssueClosed.new(issue_id: event.data[:issue_id]) + in IssueReopened + Issue::IssueReopened.new(issue_id: event.data[:issue_id]) + in IssueProgressStarted + Issue::IssueProgressStarted.new(issue_id: event.data[:issue_id]) + in IssueProgressStopped + Issue::IssueProgressStopped.new(issue_id: event.data[:issue_id]) + end + end + + def domain_to_infra(event) + case event + in Issue::IssueOpened + IssueOpened.new(data: { issue_id: event.issue_id }) + in Issue::IssueResolved + IssueResolved.new(data: { issue_id: event.issue_id }) + in Issue::IssueClosed + IssueClosed.new(data: { issue_id: event.issue_id }) + in Issue::IssueReopened + IssueReopened.new(data: { issue_id: event.issue_id }) + in Issue::IssueProgressStarted + IssueProgressStarted.new(data: { issue_id: event.issue_id }) + in Issue::IssueProgressStopped + IssueProgressStopped.new(data: { issue_id: event.issue_id }) + in Issue::InvalidTransition + raise Error + end + end + end +end diff --git a/examples/decide.rb/lib/project_management/issue.rb b/examples/decide.rb/lib/project_management/issue.rb new file mode 100644 index 0000000..f1b31a9 --- /dev/null +++ b/examples/decide.rb/lib/project_management/issue.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "decider" + +module ProjectManagement + module Issue + IssueOpened = Data.define(:issue_id) + IssueResolved = Data.define(:issue_id) + IssueClosed = Data.define(:issue_id) + IssueReopened = Data.define(:issue_id) + IssueProgressStarted = Data.define(:issue_id) + IssueProgressStopped = Data.define(:issue_id) + InvalidTransition = Data.define + + Decider = Decider.define do + initial_state :none + + decide CreateIssue, :none do + emit IssueOpened.new(issue_id: command.id) + end + + decide proc { [command, state] in [ResolveIssue, :open | :in_progress | :reopened] } do + emit IssueResolved.new(issue_id: command.id) + end + + decide proc { [command, state] in [CloseIssue, :open | :in_progress | :resolved | :reopened] } do + emit IssueClosed.new(issue_id: command.id) + end + + decide proc { [command, state] in [ReopenIssue, :resolved | :closed] } do + emit IssueReopened.new(issue_id: command.id) + end + + decide proc { [command, state] in [StartIssueProgress, :open | :reopened] } do + emit IssueProgressStarted.new(issue_id: command.id) + end + + decide StopIssueProgress, :in_progress do + emit IssueProgressStopped.new(issue_id: command.id) + end + + decide proc { true } do + emit InvalidTransition.new + end + + evolve IssueOpened do + :open + end + + evolve IssueResolved do + :resolved + end + + evolve IssueClosed do + :closed + end + + evolve IssueReopened do + :reopened + end + + evolve IssueProgressStarted do + :in_progress + end + + evolve IssueProgressStopped do + :open + end + end + end +end diff --git a/examples/decide.rb/lib/project_management/repository.rb b/examples/decide.rb/lib/project_management/repository.rb new file mode 100644 index 0000000..0796cf4 --- /dev/null +++ b/examples/decide.rb/lib/project_management/repository.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ProjectManagement + class Repository + def initialize(event_store) + @event_store = event_store + end + + def load(id, decider) + stream = @event_store + .read + .stream(stream_name(id)) + + @expected_version = stream.count - 1 + + stream.reduce(decider.initial_state, &decider.evolve) + end + + def store(id, events) + @event_store.append( + events, + stream_name: stream_name(id), + expected_version: @expected_version + ) + end + + private + + def stream_name(id) = "Issue$#{id}" + end +end diff --git a/examples/decide.rb/test/issue_test.rb b/examples/decide.rb/test/issue_test.rb new file mode 100644 index 0000000..c14358d --- /dev/null +++ b/examples/decide.rb/test/issue_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "minitest/mock" +require "mutant/minitest/coverage" +require "ruby_event_store" + +require_relative "../lib/project_management" + +module ProjectManagement + class IssueTest < Minitest::Test + include Test.with( + handler: ->(event_store) { Handler.new(event_store) }, + event_store: -> { RubyEventStore::Client.new } + ) + + cover "ProjectManagement::Issue*" + end +end diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..c8650bc --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruby = "3.2.2"