├── .ruby-gemset ├── .ruby-version ├── .rspec ├── lib ├── sandthorn │ ├── version.rb │ ├── errors.rb │ ├── aggregate_root.rb │ ├── application_snapshot_store.rb │ ├── bounded_context.rb │ ├── event_stores.rb │ ├── aggregate_root_marshal.rb │ ├── event_inspector.rb │ └── aggregate_root_base.rb └── sandthorn.rb ├── .travis.yml ├── Gemfile ├── spec ├── db │ └── sequel_driver.sqlite3_old ├── support │ └── custom_matchers.rb ├── initialize_signature_change_spec.rb ├── bounded_context_spec.rb ├── benchmark_spec.rb ├── spec_helper.rb ├── constructor_events_spec.rb ├── complex_aggregate_spec.rb ├── default_attributes_spec.rb ├── snapshot_spec.rb ├── aggregate_events_spec.rb ├── aggregate_delta_spec.rb ├── stateless_events_spec.rb ├── event_stores_spec.rb ├── tracing_spec.rb └── aggregate_root_spec.rb ├── .autotest ├── .gitignore ├── Rakefile ├── LICENSE ├── sandthorn.gemspec └── README.md /.ruby-gemset: -------------------------------------------------------------------------------- 1 | sandthorn 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.1 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format d 3 | -------------------------------------------------------------------------------- /lib/sandthorn/version.rb: -------------------------------------------------------------------------------- 1 | module Sandthorn 2 | VERSION = "1.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.4.1 4 | - 2.3.4 5 | - 2.2.7 6 | sudo: false 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in sandthorn.gemspec 4 | gemspec -------------------------------------------------------------------------------- /spec/db/sequel_driver.sqlite3_old: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sandthorn/sandthorn/HEAD/spec/db/sequel_driver.sqlite3_old -------------------------------------------------------------------------------- /.autotest: -------------------------------------------------------------------------------- 1 | Autotest.add_hook :initialize do |at| 2 | %w{.git spec/db coverage}.each {|exception| at.add_exception(exception)} 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/custom_matchers.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/expectations' 2 | 3 | RSpec::Matchers.define :have_aggregate_type do |expected| 4 | match do |actual| 5 | actual[:aggregate_type].to_s == expected.to_s 6 | end 7 | end -------------------------------------------------------------------------------- /lib/sandthorn/errors.rb: -------------------------------------------------------------------------------- 1 | module Sandthorn 2 | module Errors 3 | class Error < StandardError; end 4 | class AggregateNotFound < Error; end 5 | class ConcurrencyError < Error; end 6 | class ConfigurationError < Error; end 7 | class SnapshotError < Error; end 8 | end 9 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | coverage 6 | InstalledFiles 7 | lib/bundler/man 8 | pkg 9 | rdoc 10 | spec/reports 11 | *.sqlite3 12 | test/tmp 13 | test/version_tmp 14 | tmp 15 | Gemfile.lock 16 | 17 | # YARD artifacts 18 | .yardoc 19 | _yardoc 20 | doc/ 21 | .idea 22 | Dockerfile 23 | -------------------------------------------------------------------------------- /lib/sandthorn/aggregate_root.rb: -------------------------------------------------------------------------------- 1 | require 'sandthorn/aggregate_root_base' 2 | require 'sandthorn/aggregate_root_marshal' 3 | 4 | module Sandthorn 5 | module AggregateRoot 6 | include Base 7 | include Marshal 8 | 9 | def self.included(base) 10 | base.extend(Base::ClassMethods) 11 | end 12 | end 13 | end -------------------------------------------------------------------------------- /lib/sandthorn/application_snapshot_store.rb: -------------------------------------------------------------------------------- 1 | module Sandthorn 2 | class ApplicationSnapshotStore 3 | def initialize 4 | @store = Hash.new 5 | end 6 | 7 | attr_reader :store 8 | 9 | def save aggregate_id, aggregate 10 | @store[aggregate_id] = aggregate 11 | end 12 | 13 | def find aggregate_id 14 | @store[aggregate_id] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task :default => :spec 8 | 9 | task :benchmark do 10 | sh "rspec --tag benchmark" 11 | end 12 | 13 | task :console do 14 | require 'ap' 15 | require 'pry' 16 | require 'pry/completion' 17 | require 'bundler' 18 | require 'sandthorn' 19 | ARGV.clear 20 | Pry.start 21 | end 22 | -------------------------------------------------------------------------------- /spec/initialize_signature_change_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class InitChange 4 | include Sandthorn::AggregateRoot 5 | attr_reader :foo 6 | def initialize foo: nil 7 | @foo = foo 8 | end 9 | end 10 | def change_init 11 | InitChange.class_eval do 12 | define_method :initialize, lambda { @foo = :foo } 13 | end 14 | end 15 | describe "when the initialize-method changes" do 16 | it "should be possible to replay anyway" do 17 | aggregate = InitChange.new foo: :bar 18 | events = aggregate.aggregate_events 19 | change_init 20 | with_change = InitChange.new 21 | expect(with_change.foo).to eq(:foo) 22 | replayed = InitChange.aggregate_build(events) 23 | expect(replayed.foo).to eq(:bar) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Sandthorn Event Sourcing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/bounded_context_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'sandthorn/bounded_context' 3 | 4 | module Sandthorn 5 | describe BoundedContext do 6 | it 'should respond to `aggregate_types`' do 7 | expect(BoundedContext).to respond_to(:aggregate_types) 8 | end 9 | end 10 | 11 | describe "::aggregate_types" do 12 | module TestModule 13 | include Sandthorn::BoundedContext 14 | class AnAggregate 15 | include Sandthorn::AggregateRoot 16 | end 17 | 18 | class NotAnAggregate 19 | end 20 | 21 | module Deep 22 | class DeepAggregate 23 | include Sandthorn::AggregateRoot 24 | end 25 | end 26 | end 27 | 28 | it "aggregate_types should include AnAggregate" do 29 | expect(TestModule.aggregate_types).to include(TestModule::AnAggregate) 30 | end 31 | 32 | it "aggregate_types should not include NotAnAggregate" do 33 | expect(TestModule.aggregate_types).not_to include(TestModule::NotAnAggregate) 34 | end 35 | 36 | it "aggregate_types should include DeepAnAggregate in a nested Module" do 37 | expect(TestModule.aggregate_types).to include(TestModule::Deep::DeepAggregate) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/sandthorn/bounded_context.rb: -------------------------------------------------------------------------------- 1 | module Sandthorn 2 | module BoundedContext 3 | module ClassMethods 4 | def aggregate_types 5 | @aggregate_list = p_aggregate_types(self) 6 | end 7 | 8 | private 9 | 10 | def p_aggregate_types(bounded_context_module) 11 | return [] unless bounded_context_module.respond_to?(:constants) 12 | 13 | classes = classes_in(bounded_context_module) 14 | aggregate_list = classes.select { |item| item.include?(Sandthorn::AggregateRoot) } 15 | modules = modules_in(bounded_context_module, classes) 16 | 17 | aggregate_list += modules.flat_map { |m| p_aggregate_types(m) } 18 | 19 | aggregate_list 20 | end 21 | 22 | def classes_in(namespace) 23 | namespace.constants.map(&namespace.method(:const_get)).grep(Class) 24 | end 25 | 26 | def modules_in(namespace, classes) 27 | namespace.constants.map(&namespace.method(:const_get)).grep(Module).delete_if do |m| 28 | classes.include?(m) || m == Sandthorn::BoundedContext::ClassMethods 29 | end 30 | end 31 | end 32 | 33 | extend ClassMethods 34 | 35 | def self.included( other ) 36 | other.extend( ClassMethods ) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/benchmark_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'benchmark' 3 | 4 | module Sandthorn 5 | module AggregateRoot 6 | class TestClass 7 | include Sandthorn::AggregateRoot 8 | attr_reader :name 9 | 10 | 11 | def initialize args = {} 12 | end 13 | 14 | def change_name value 15 | unless name == value 16 | @name = value 17 | commit 18 | end 19 | end 20 | 21 | end 22 | 23 | describe "benchmark", benchmark: true do 24 | 25 | let(:test_object) { 26 | o = TestClass.new().save 27 | o 28 | } 29 | n = 500 30 | it "should new, change_name, save and find 500 aggregates" do 31 | 32 | Benchmark.bm do |x| 33 | x.report("new change save find") { for i in 1..n; s = TestClass.new().change_name("benchmark").save(); TestClass.find(s.id); end } 34 | end 35 | 36 | end 37 | it "should find 500 aggregates" do 38 | Benchmark.bm do |x| 39 | x.report("find") { for i in 1..n; TestClass.find(test_object.id); end } 40 | end 41 | end 42 | it "should commit 500 actions" do 43 | Benchmark.bm do |x| 44 | x.report("commit") { for i in 1..n; test_object.change_name "#{i}"; end } 45 | end 46 | end 47 | it "should commit and save 500 actions" do 48 | Benchmark.bm do |x| 49 | x.report("commit save") { for i in 1..n; test_object.change_name("#{i}").save; end } 50 | end 51 | end 52 | end 53 | end 54 | end -------------------------------------------------------------------------------- /sandthorn.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'sandthorn/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "sandthorn" 8 | spec.version = Sandthorn::VERSION 9 | spec.authors = ["Lars Krantz", "Morgan Hallgren", "Jesper Josefsson"] 10 | spec.email = ["lars.krantz@alaz.se", "morgan.hallgren@gmail.com", "jesper.josefsson@gmail.com"] 11 | spec.description = %q{Event sourcing} 12 | spec.summary = %q{Event sourcing gem} 13 | spec.homepage = "https://github.com/Sandthorn/sandthorn" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | spec.required_ruby_version = '>= 2.0' 21 | 22 | 23 | spec.add_development_dependency "bundler", "~> 1.3" 24 | spec.add_development_dependency "rake" 25 | spec.add_development_dependency "rspec", "~> 3.0" 26 | spec.add_development_dependency "gem-release" 27 | spec.add_development_dependency "pry" 28 | spec.add_development_dependency "pry-doc" 29 | spec.add_development_dependency "awesome_print" 30 | spec.add_development_dependency "autotest-standalone" 31 | spec.add_development_dependency "sqlite3" 32 | spec.add_development_dependency "coveralls" 33 | spec.add_development_dependency "sandthorn_driver_sequel", "~> 4" 34 | end 35 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # Require this file using `require "spec_helper"` to ensure that it is only 4 | # loaded once. 5 | # 6 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 7 | require 'coveralls' 8 | Coveralls.wear! 9 | require "ap" 10 | require "bundler" 11 | require "sandthorn_driver_sequel" 12 | require "support/custom_matchers" 13 | Bundler.require 14 | 15 | module Helpers 16 | def class_including(mod) 17 | Class.new.tap {|c| c.send :include, mod } 18 | end 19 | end 20 | 21 | RSpec.configure do |config| 22 | config.run_all_when_everything_filtered = true 23 | config.filter_run :focus 24 | config.filter_run_excluding benchmark: true 25 | 26 | # Run specs in random order to surface order dependencies. If you find an 27 | # order dependency and want to debug it, you can fix the order by providing 28 | # the seed, which is printed after each run. 29 | # --seed 1234 30 | config.order = 'random' 31 | config.before(:all) { 32 | sqlite_store_setup 33 | } 34 | 35 | config.before(:each) { 36 | migrator = SandthornDriverSequel::Migration.new url: url 37 | migrator.send(:clear_for_test) 38 | } 39 | 40 | config.after(:all) do 41 | Sandthorn.event_stores.default_store.driver.instance_variable_get(:@db).disconnect 42 | end 43 | end 44 | 45 | def url 46 | "sqlite://spec/db/sequel_driver.sqlite3" 47 | end 48 | 49 | def sqlite_store_setup 50 | 51 | SandthornDriverSequel.migrate_db url: url 52 | 53 | Sandthorn.configure do |c| 54 | c.event_store = SandthornDriverSequel.driver_from_url(url: url) 55 | end 56 | 57 | end 58 | -------------------------------------------------------------------------------- /spec/constructor_events_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Sandthorn 4 | class ConstructorEventsSpec 5 | include AggregateRoot 6 | 7 | constructor_events :created_event 8 | attr_reader :name 9 | 10 | 11 | def self.create name 12 | created_event(name) { @name = name } 13 | end 14 | 15 | end 16 | 17 | describe "::constructor_events" do 18 | 19 | let(:subject) do 20 | ConstructorEventsSpec.create("name").save 21 | end 22 | 23 | context "interface" do 24 | 25 | it "should not expose constructor_events methods" do 26 | expect(subject).not_to respond_to(:created_event) 27 | end 28 | 29 | it "should create the constructor event on the class" do 30 | expect(ConstructorEventsSpec.private_methods).to include(:created_event) 31 | end 32 | 33 | end 34 | end 35 | 36 | describe "::create" do 37 | let(:aggregate_id) do 38 | a = ConstructorEventsSpec.create("create_name") 39 | a.save 40 | a.aggregate_id 41 | end 42 | 43 | it "should create an ConstructorEventsSpec aggregate" do 44 | expect(ConstructorEventsSpec.find(aggregate_id)).to be_a ConstructorEventsSpec 45 | end 46 | 47 | it "should set instance variable in aggregate" do 48 | expect(ConstructorEventsSpec.find(aggregate_id).name).to eq("create_name") 49 | end 50 | 51 | it "should have created an created_event" do 52 | expect(Sandthorn.find(aggregate_id, ConstructorEventsSpec).first[:event_name]).to eq("created_event") 53 | end 54 | 55 | it "should have set the attribute_delta name" do 56 | expect(Sandthorn.find(aggregate_id, ConstructorEventsSpec).first[:event_data]).to have_key(:name) 57 | expect(Sandthorn.find(aggregate_id, ConstructorEventsSpec).first[:event_data][:name][:new_value]).to eq("create_name") 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/complex_aggregate_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'date' 3 | 4 | class Hello 5 | attr_reader :foo_bar 6 | attr_accessor :change_me 7 | 8 | def initialize foo_bar 9 | @foo_bar = foo_bar 10 | end 11 | 12 | def set_foo_bar value 13 | @foo_bar = value 14 | end 15 | end 16 | 17 | class IAmComplex 18 | include Sandthorn::AggregateRoot 19 | attr_reader :a_date 20 | attr_reader :hello 21 | 22 | def set_hello! hello 23 | set_hello_event hello 24 | end 25 | 26 | def set_foo_bar_on_hello value 27 | @hello.set_foo_bar value 28 | commit 29 | end 30 | 31 | 32 | def initialize date 33 | @a_date = date 34 | end 35 | 36 | private 37 | def set_hello_event hello 38 | @hello = hello 39 | commit 40 | end 41 | 42 | end 43 | 44 | 45 | describe 'when using complex types in events' do 46 | before(:each) do 47 | aggr = IAmComplex.new Date.new 2012,01,20 48 | aggr.set_hello! Hello.new "foo" 49 | @events = aggr.aggregate_events 50 | end 51 | it 'should be able to build from events' do 52 | aggr = IAmComplex.aggregate_build @events 53 | expect(aggr.a_date).to be_a(Date) 54 | expect(aggr.hello).to be_a(Hello) 55 | end 56 | 57 | it 'should detect hello changing' do 58 | aggr = IAmComplex.aggregate_build @events 59 | hello = aggr.hello 60 | hello.change_me = ["Fantastisk"] 61 | aggr.set_hello! hello 62 | hello.change_me << "Otroligt" 63 | aggr.set_hello! hello 64 | builded = IAmComplex.aggregate_build aggr.aggregate_events 65 | expect(builded.hello.change_me).to include "Fantastisk" 66 | expect(builded.hello.change_me).to include "Otroligt" 67 | end 68 | 69 | it 'should detect foo_bar chaning in hello' do 70 | aggr = IAmComplex.aggregate_build @events 71 | aggr.set_foo_bar_on_hello "morgan" 72 | builded = IAmComplex.aggregate_build aggr.aggregate_events 73 | expect(builded.hello.foo_bar).to eq("morgan") 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/sandthorn/event_stores.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module Sandthorn 4 | class EventStores 5 | extend Forwardable 6 | include Enumerable 7 | 8 | def_delegators :stores, :each 9 | 10 | def initialize(stores = nil) 11 | @store_map = Hash.new 12 | add_initial(stores) 13 | end 14 | 15 | def add(name, event_store) 16 | store_map[name] = event_store 17 | end 18 | alias_method :[]=, :add 19 | 20 | def add_many(stores) 21 | stores.each_pair do |name, store| 22 | add(name, store) 23 | end 24 | end 25 | 26 | def by_name(name) 27 | store_map[name] || default_store 28 | end 29 | alias_method :[], :by_name 30 | 31 | def default_store 32 | store_map.fetch(:default) 33 | end 34 | 35 | def default_store=(store) 36 | store_map[:default] = store 37 | end 38 | 39 | def stores 40 | store_map.values 41 | end 42 | 43 | def map_types(hash) 44 | hash.each_pair do |event_store, aggregate_types| 45 | map_aggregate_types_to_event_store(aggregate_types, event_store) 46 | end 47 | end 48 | 49 | private 50 | 51 | attr_reader :store_map 52 | 53 | def add_initial(store) 54 | if is_event_store?(store) 55 | self.default_store = store 56 | elsif is_many_event_stores?(store) 57 | add_many(store) 58 | end 59 | end 60 | 61 | def is_many_event_stores?(store) 62 | store.respond_to?(:each_pair) 63 | end 64 | 65 | def is_event_store?(store) 66 | store.respond_to?(:get_events) 67 | end 68 | 69 | def map_aggregate_type_to_event_store(aggregate_type, event_store) 70 | aggregate_type.event_store(event_store) 71 | end 72 | 73 | def map_aggregate_types_to_event_store(aggregate_types = [], event_store) 74 | aggregate_types.each do |aggregate_type| 75 | map_aggregate_type_to_event_store(aggregate_type, event_store) 76 | end 77 | end 78 | 79 | end 80 | end -------------------------------------------------------------------------------- /spec/default_attributes_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # class DefaultAttributes 4 | # include Sandthorn::AggregateRoot 5 | # def initialize 6 | # end 7 | # end 8 | 9 | 10 | describe "when the initialize-method changes" do 11 | 12 | before do 13 | class DefaultAttributes 14 | include Sandthorn::AggregateRoot 15 | def initialize 16 | end 17 | end 18 | 19 | end 20 | 21 | #Make sure the DefaultAttributes class are reset on every test 22 | after do 23 | Object.send(:remove_const, :DefaultAttributes) 24 | end 25 | 26 | it "should not have an array attribute on first version of the DefaultAttributes class" do 27 | aggregate = DefaultAttributes.new 28 | expect(aggregate.respond_to?(:array)).to be_falsy 29 | end 30 | 31 | context "default_attributes" do 32 | 33 | def add_default_attributes 34 | DefaultAttributes.class_eval do 35 | attr_reader :array 36 | define_method :default_attributes, lambda { @array = [] } 37 | define_method :add_item, lambda { |item| 38 | @array << item 39 | commit 40 | } 41 | end 42 | end 43 | 44 | it "should have an set the array attribute to [] on new" do 45 | add_default_attributes 46 | aggregate = DefaultAttributes.new 47 | expect(aggregate.array).to eq([]) 48 | end 49 | 50 | it "should have set the array attribute to [] on rebuilt when attribute is intruduced after `new`" do 51 | aggregate = DefaultAttributes.new 52 | add_default_attributes 53 | rebuilt_aggregate = DefaultAttributes.aggregate_build(aggregate.aggregate_events) 54 | expect(rebuilt_aggregate.array).to eq([]) 55 | end 56 | 57 | it "should set the array attribute to ['banana'] on rebuilt" do 58 | add_default_attributes 59 | aggregate = DefaultAttributes.new 60 | aggregate.add_item 'banana' 61 | rebuilt_aggregate = DefaultAttributes.aggregate_build(aggregate.aggregate_events) 62 | expect(rebuilt_aggregate.array).to eq(['banana']) 63 | end 64 | 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/sandthorn/aggregate_root_marshal.rb: -------------------------------------------------------------------------------- 1 | module Sandthorn 2 | module AggregateRoot 3 | module Marshal 4 | 5 | def aggregate_initialize *args 6 | @aggregate_attribute_deltas = {} 7 | @aggregate_stored_instance_variables = {} 8 | end 9 | 10 | def set_instance_variables! attribute 11 | super attribute 12 | init_vars = extract_relevant_aggregate_instance_variables 13 | 14 | init_vars.each do |attribute_name| 15 | @aggregate_stored_instance_variables[attribute_name] = 16 | ::Marshal.dump(instance_variable_get(attribute_name)) 17 | end 18 | end 19 | 20 | def get_delta 21 | deltas = extract_relevant_aggregate_instance_variables 22 | deltas.each { |d| delta_attribute(d) } 23 | 24 | result = @aggregate_attribute_deltas 25 | clear_aggregate_deltas 26 | result 27 | end 28 | 29 | private 30 | 31 | def delta_attribute attribute_name 32 | old_dump = @aggregate_stored_instance_variables[attribute_name] 33 | new_dump = ::Marshal.dump(instance_variable_get(attribute_name)) 34 | 35 | unless old_dump == new_dump 36 | store_attribute_deltas attribute_name, new_dump, old_dump 37 | store_aggregate_instance_variable attribute_name, new_dump 38 | end 39 | end 40 | 41 | def store_attribute_deltas attribute_name, new_dump, old_dump 42 | new_value_to_store = ::Marshal.load(new_dump) 43 | old_value_to_store = old_dump ? ::Marshal.load(old_dump) : nil 44 | 45 | @aggregate_attribute_deltas[attribute_name.to_s.delete("@").to_sym] = { 46 | old_value: old_value_to_store, 47 | new_value: new_value_to_store 48 | } 49 | end 50 | 51 | def store_aggregate_instance_variable attribute_name, new_dump 52 | @aggregate_stored_instance_variables[attribute_name] = new_dump 53 | end 54 | 55 | def clear_aggregate_deltas 56 | @aggregate_attribute_deltas = {} 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/snapshot_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Sandthorn 4 | module Snapshot 5 | class KlassOne 6 | include Sandthorn::AggregateRoot 7 | snapshot true 8 | end 9 | 10 | class KlassTwo 11 | include Sandthorn::AggregateRoot 12 | end 13 | 14 | class KlassThree 15 | include Sandthorn::AggregateRoot 16 | end 17 | 18 | describe "::snapshot" do 19 | before do 20 | Sandthorn.configure do |c| 21 | c.snapshot_types = [KlassTwo] 22 | end 23 | end 24 | it "snapshot should be enabled on KlassOne and KlassTwo but not KlassThree" do 25 | expect(KlassOne.snapshot).to be_truthy 26 | expect(KlassTwo.snapshot).to be_truthy 27 | expect(KlassThree.snapshot).not_to be_truthy 28 | end 29 | end 30 | 31 | describe "find snapshot on snapshot enabled aggregate" do 32 | let(:klass) { KlassOne.new.save } 33 | 34 | it "should find on snapshot enabled Class" do 35 | copy = KlassOne.find klass.aggregate_id 36 | expect(copy.aggregate_version).to eql(klass.aggregate_version) 37 | end 38 | 39 | it "should get saved snapshot" do 40 | copy = Sandthorn.find_snapshot klass.aggregate_id 41 | expect(copy.aggregate_version).to eql(klass.aggregate_version) 42 | end 43 | 44 | end 45 | 46 | describe "save and find snapshot on snapshot disabled aggregate" do 47 | let(:klass) { KlassThree.new.save } 48 | 49 | it "should not find snapshot" do 50 | snapshot = Sandthorn.find_snapshot klass.aggregate_id 51 | expect(snapshot).to be_nil 52 | end 53 | 54 | it "should save and get saved snapshot" do 55 | Sandthorn.save_snapshot klass 56 | snapshot = Sandthorn.find_snapshot klass.aggregate_id 57 | expect(snapshot).not_to be_nil 58 | 59 | #Check by key on the snapshot_store hash 60 | expect(Sandthorn.snapshot_store.store.has_key?(klass.aggregate_id)).to be_truthy 61 | 62 | end 63 | 64 | end 65 | 66 | end 67 | end 68 | 69 | -------------------------------------------------------------------------------- /spec/aggregate_events_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'sandthorn/event_inspector' 3 | 4 | module Sandthorn 5 | class EventsSpec 6 | include AggregateRoot 7 | 8 | events :name_changed, :some_other_event, :third_event 9 | attr_reader :name 10 | 11 | def change_name(name) 12 | if @name != name 13 | name_changed(name) { @name = name } 14 | end 15 | end 16 | 17 | def some_other one, two 18 | some_other_event one, two 19 | end 20 | 21 | def old_way_event event_params 22 | commit 23 | end 24 | end 25 | 26 | describe "::events" do 27 | 28 | let(:subject) do 29 | EventsSpec.new.extend EventInspector 30 | end 31 | 32 | it "should not expose events methods" do 33 | expect(subject).not_to respond_to(:name_changed) 34 | end 35 | 36 | it "should make the events methods private" do 37 | expect(subject.private_methods).to include(:name_changed) 38 | end 39 | 40 | describe ".change_name" do 41 | 42 | before do 43 | subject.change_name "new name" 44 | end 45 | 46 | it "should set the name instance variable" do 47 | expect(subject.name).to eq("new name") 48 | end 49 | 50 | it "should store the event params as methods args" do 51 | expect(subject.has_event?(:name_changed)).to be_truthy 52 | end 53 | 54 | it "should store the args to the event" do 55 | expect(subject.aggregate_events[1][:event_data][:name][:new_value]).to eq("new name") 56 | end 57 | 58 | it "should store the event_name" do 59 | expect(subject.aggregate_events[1][:event_name]).to eq("name_changed") 60 | end 61 | end 62 | 63 | describe ".some_other" do 64 | 65 | before do 66 | subject.some_other 1, 2 67 | end 68 | 69 | it "should store the event" do 70 | expect(subject.has_event?(:some_other_event)).to be_truthy 71 | end 72 | 73 | end 74 | 75 | describe ".old_way_event" do 76 | 77 | before do 78 | subject.old_way_event "hej" 79 | end 80 | 81 | it "should store the event the old way" do 82 | expect(subject.has_event?(:old_way_event)).to be_truthy 83 | end 84 | 85 | end 86 | end 87 | end -------------------------------------------------------------------------------- /spec/aggregate_delta_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class PersonTest 4 | include Sandthorn::AggregateRoot 5 | attr_reader :name 6 | attr_reader :age 7 | attr_reader :relationship_status 8 | attr_reader :my_array 9 | attr_reader :my_hash 10 | 11 | def initialize name, age, relationship_status 12 | @name = name 13 | @age = age 14 | @relationship_status = relationship_status 15 | @my_array = [] 16 | @my_hash = {} 17 | end 18 | 19 | def change_name new_name 20 | @name = new_name 21 | record_event 22 | end 23 | 24 | def change_relationship new_relationship 25 | @relationship_status = new_relationship 26 | record_event 27 | end 28 | 29 | def add_to_array element 30 | @my_array << element 31 | record_event 32 | end 33 | 34 | def add_to_hash name,value 35 | @my_hash[name] = value 36 | record_event 37 | end 38 | end 39 | 40 | describe 'Property Delta Event Sourcing' do 41 | let(:person) { PersonTest.new "Lasse",40,:married} 42 | 43 | it 'should be able to set name' do 44 | person.change_name "Klabbarparen" 45 | expect(person.name).to eq("Klabbarparen") 46 | end 47 | 48 | it 'should be able to build from events' do 49 | person.change_name "Klabbarparen" 50 | builded = PersonTest.aggregate_build person.aggregate_events 51 | expect(builded.name).to eq(person.name) 52 | expect(builded.aggregate_id).to eq(person.aggregate_id) 53 | end 54 | 55 | it 'should not have any events when built up' do 56 | person.change_name "Mattias" 57 | builded = PersonTest.aggregate_build person.aggregate_events 58 | expect(builded.aggregate_events).to be_empty 59 | end 60 | 61 | it 'should detect change on array' do 62 | person.add_to_array "Foo" 63 | person.add_to_array "bar" 64 | 65 | builded = PersonTest.aggregate_build person.aggregate_events 66 | expect(builded.my_array).to include "Foo" 67 | expect(builded.my_array).to include "bar" 68 | end 69 | 70 | it 'should detect change on hash' do 71 | person.add_to_hash :foo, "bar" 72 | person.add_to_hash :bar, "foo" 73 | 74 | builded = PersonTest.aggregate_build person.aggregate_events 75 | expect(builded.my_hash[:foo]).to eq("bar") 76 | expect(builded.my_hash[:bar]).to eq("foo") 77 | 78 | person.add_to_hash :foo, "BAR" 79 | 80 | builded2 = PersonTest.aggregate_build person.aggregate_events 81 | expect(builded2.my_hash[:foo]).to eq("BAR") 82 | end 83 | end -------------------------------------------------------------------------------- /lib/sandthorn/event_inspector.rb: -------------------------------------------------------------------------------- 1 | module Sandthorn 2 | module EventInspector 3 | def has_unsaved_event? event_name, options = {} 4 | unsaved = events_with_trace_info 5 | 6 | if self.aggregate_events.empty? 7 | unsaved = [] 8 | else 9 | unsaved.reject! do |e| 10 | e[:aggregate_version] < self 11 | .aggregate_events.first[:aggregate_version] 12 | end 13 | end 14 | 15 | matching_events = unsaved.select { |e| e[:event_name] == event_name } 16 | event_exists = matching_events.length > 0 17 | trace = has_trace? matching_events, options.fetch(:trace, {}) 18 | 19 | !!(event_exists && trace) 20 | end 21 | 22 | def has_saved_event? event_name, options = {} 23 | saved = events_with_trace_info 24 | 25 | unless self.aggregate_events.empty? 26 | saved.reject! do |e| 27 | e[:aggregate_version] >= self 28 | .aggregate_events.first[:aggregate_version] 29 | end 30 | end 31 | 32 | matching_events = saved.select { |e| e[:event_name] == event_name } 33 | event_exists = matching_events.length > 0 34 | trace = has_trace? matching_events, options.fetch(:trace, {}) 35 | 36 | !!(event_exists && trace) 37 | end 38 | 39 | def has_event? event_name, options = {} 40 | matching_events = events_with_trace_info 41 | .select { |e| e[:event_name] == event_name } 42 | 43 | event_exists = matching_events.length > 0 44 | trace = has_trace? matching_events, options.fetch(:trace, {}) 45 | !!(event_exists && trace) 46 | end 47 | 48 | def events_with_trace_info 49 | begin 50 | saved = Sandthorn.find aggregate_id, self.class 51 | rescue Exception 52 | saved = [] 53 | end 54 | 55 | unsaved = self.aggregate_events 56 | all = saved 57 | .concat(unsaved) 58 | .sort { |a, b| a[:aggregate_version] <=> b[:aggregate_version] } 59 | 60 | extracted = all.collect do |e| 61 | if e[:event_data].nil? && !e[:event_data].nil? 62 | data = Sandthorn.deserialize e[:event_data] 63 | else 64 | data = e[:event_data] 65 | end 66 | 67 | { 68 | aggregate_version: e[:aggregate_version], 69 | event_name: e[:event_name].to_sym, 70 | event_data: data, 71 | event_metadata: e[:event_metadata] 72 | } 73 | end 74 | 75 | extracted 76 | end 77 | 78 | private 79 | 80 | def get_unsaved_events event_name 81 | self.aggregate_events.select { |e| e[:event_name] == event_name.to_s } 82 | end 83 | 84 | def get_saved_events event_name 85 | saved_events = Sandthorn.find self.class, self.aggregate_id 86 | saved_events.select { |e| e[:event_name] == event_name.to_s } 87 | end 88 | 89 | def has_trace? events_to_check, trace_info 90 | return true if trace_info.empty? 91 | events_to_check.each do |event| 92 | return false if event[:trace] != trace_info 93 | end 94 | true 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/sandthorn.rb: -------------------------------------------------------------------------------- 1 | require "sandthorn/version" 2 | require "sandthorn/errors" 3 | require "sandthorn/aggregate_root" 4 | require "sandthorn/event_stores" 5 | require "sandthorn/application_snapshot_store" 6 | require 'yaml' 7 | require 'securerandom' 8 | 9 | module Sandthorn 10 | class << self 11 | extend Forwardable 12 | 13 | def_delegators :configuration, :event_stores 14 | def_delegators :configuration, :snapshot_store 15 | 16 | def default_event_store 17 | event_stores.default_store 18 | end 19 | 20 | def default_event_store=(store) 21 | event_stores.default_store = store 22 | end 23 | 24 | def configure 25 | yield(configuration) if block_given? 26 | end 27 | 28 | def configuration 29 | @configuration ||= Configuration.new 30 | end 31 | 32 | def generate_aggregate_id 33 | SecureRandom.uuid 34 | end 35 | 36 | def save_events aggregate_events, aggregate_id, aggregate_type 37 | event_store_for(aggregate_type).save_events aggregate_events, aggregate_id, *aggregate_type 38 | end 39 | 40 | def all aggregate_type 41 | event_store_for(aggregate_type).all(aggregate_type) 42 | end 43 | 44 | def find aggregate_id, aggregate_type, after_aggregate_version = 0 45 | event_store_for(aggregate_type).find(aggregate_id, aggregate_type, after_aggregate_version) 46 | end 47 | 48 | def save_snapshot aggregate 49 | raise Errors::SnapshotError, "Can't take snapshot on object with unsaved events" if aggregate.unsaved_events? 50 | snapshot_store.save aggregate.aggregate_id, aggregate 51 | end 52 | 53 | def find_snapshot aggregate_id 54 | return snapshot_store.find aggregate_id 55 | end 56 | 57 | def find_event_store(name) 58 | event_stores.by_name(name) 59 | end 60 | 61 | private 62 | 63 | def event_store_for(aggregate_type) 64 | event_store = event_stores.by_name(aggregate_type.event_store).tap do |store| 65 | yield(store) if block_given? 66 | end 67 | end 68 | 69 | def missing_key(key) 70 | raise ArgumentError, "missing keyword: #{key}" 71 | end 72 | 73 | class Configuration 74 | extend Forwardable 75 | 76 | def_delegators :default_store, :event_stores, :default_store= 77 | 78 | def initialize 79 | yield(self) if block_given? 80 | end 81 | 82 | def event_stores 83 | @event_stores ||= EventStores.new 84 | end 85 | 86 | def event_store=(event_store) 87 | @event_stores = EventStores.new(event_store) 88 | end 89 | 90 | def map_types= data 91 | @event_stores.map_types data 92 | end 93 | 94 | def snapshot_store 95 | @snapshot_store ||= ApplicationSnapshotStore.new 96 | end 97 | 98 | def snapshot_store=(snapshot_store) 99 | @snapshot_store = snapshot_store 100 | end 101 | 102 | def snapshot_types= aggregate_types 103 | aggregate_types.each do |aggregate_type| 104 | aggregate_type.snapshot(true) 105 | end 106 | end 107 | 108 | alias_method :event_stores=, :event_store= 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/stateless_events_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Sandthorn 4 | class StatelessEventsSpec 5 | include AggregateRoot 6 | 7 | stateless_events :one_event, :some_other_event 8 | attr_reader :name 9 | 10 | def initialize name 11 | @name = name 12 | end 13 | 14 | end 15 | 16 | describe "::stateless_events" do 17 | 18 | let(:args) do 19 | {first: "first", other: [1,2,3]} 20 | end 21 | 22 | context "interface" do 23 | 24 | it "should expose stateless_events methods" do 25 | expect(StatelessEventsSpec).to respond_to(:one_event) 26 | end 27 | 28 | end 29 | 30 | context "when adding a stateless event to an existing aggregate" do 31 | 32 | let(:subject) do 33 | StatelessEventsSpec.new("name").save 34 | end 35 | 36 | before do 37 | StatelessEventsSpec.one_event(subject.aggregate_id, args) 38 | end 39 | 40 | let(:last_event) do 41 | Sandthorn.find(subject.aggregate_id, StatelessEventsSpec).last 42 | end 43 | 44 | let(:reloaded_subject) do 45 | StatelessEventsSpec.find subject.aggregate_id 46 | end 47 | 48 | it "should add one_event last on the aggregate" do 49 | expect(last_event[:event_name]).to eq("one_event") 50 | end 51 | 52 | it "should add aggregate_id to events" do 53 | expect(last_event[:aggregate_id]).to eq(subject.aggregate_id) 54 | end 55 | 56 | it "should have stateless data in deltas in event" do 57 | expect(last_event[:event_data]).to eq({:first => {:old_value => nil, :new_value => "first"}, :other => {:old_value => nil, :new_value => [1, 2, 3]}}) 58 | end 59 | 60 | it "should have same name attribute after reload" do 61 | expect(subject.name).to eq(reloaded_subject.name) 62 | end 63 | end 64 | 65 | context "when adding stateless_events to none existing aggregate" do 66 | 67 | before do 68 | StatelessEventsSpec.one_event(aggregate_id, args) 69 | end 70 | 71 | let(:aggregate_id) {"none_existing_aggregate_id"} 72 | 73 | let(:events) do 74 | Sandthorn.find aggregate_id, StatelessEventsSpec 75 | end 76 | 77 | it "should store the stateless event as the first event" do 78 | expect(events.length).to be 1 79 | end 80 | 81 | it "should have correct aggregate_id in event" do 82 | expect(events.first[:aggregate_id]).to eq(aggregate_id) 83 | end 84 | 85 | it "should have event name one_event" do 86 | expect(events.first[:event_name]).to eq("one_event") 87 | end 88 | end 89 | 90 | context "overriding properties with stateless data" do 91 | let(:subject) do 92 | StatelessEventsSpec.new("name").save 93 | end 94 | 95 | let(:reloaded_subject) do 96 | StatelessEventsSpec.find subject.aggregate_id 97 | end 98 | 99 | let(:args) do 100 | {name: "ghost"} 101 | end 102 | 103 | before do 104 | StatelessEventsSpec.one_event(subject.aggregate_id, args) 105 | end 106 | 107 | it "should override the name via the stateless event" do 108 | expect(subject.name).not_to eq(reloaded_subject.name) 109 | end 110 | end 111 | end 112 | end -------------------------------------------------------------------------------- /spec/event_stores_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Sandthorn 4 | describe EventStores do 5 | let(:stores) { EventStores.new } 6 | 7 | before do 8 | class AnAggregate 9 | include Sandthorn::AggregateRoot 10 | end 11 | end 12 | 13 | 14 | describe "#initialize" do 15 | context "when given a single event_store" do 16 | it "sets it as the default event store" do 17 | store = double(get_events: true) 18 | allow(store).to receive(:get_events) 19 | stores = EventStores.new(store) 20 | expect(stores.default_store).to eq(store) 21 | end 22 | end 23 | 24 | context "when given number of stores" do 25 | it "adds them all" do 26 | stores = { 27 | default: double, 28 | other: double 29 | } 30 | repo = EventStores.new(stores) 31 | expect(repo.by_name(:default)).to eq(stores[:default]) 32 | expect(repo.by_name(:other)).to eq(stores[:other]) 33 | end 34 | end 35 | end 36 | 37 | describe "enumerable" do 38 | let(:store) { double } 39 | let(:other_store) { double } 40 | it "should respond to each" do 41 | expect(stores).to respond_to(:each) 42 | end 43 | 44 | it "should yield each store" do 45 | stores.add_many( 46 | foo: store, 47 | bar: other_store 48 | ) 49 | expect { |block| stores.each(&block) }.to yield_successive_args(store, other_store) 50 | end 51 | end 52 | 53 | describe "#default_store=" do 54 | it "sets the default" do 55 | store = double 56 | stores = EventStores.new 57 | stores.default_store = store 58 | expect(stores.default_store).to eq(store) 59 | end 60 | end 61 | 62 | describe "#by_name" do 63 | context "when the store exists" do 64 | it "returns the store" do 65 | store = double 66 | stores.add(:foo, store) 67 | expect(stores.by_name(:foo)).to eq(store) 68 | end 69 | end 70 | 71 | context "when the store does not exist" do 72 | it "returns the default store" do 73 | store = double 74 | stores.default_store = store 75 | expect(stores.by_name(:unknown)).to eq(store) 76 | end 77 | end 78 | end 79 | 80 | describe "#add" do 81 | it "adds the store under the given name" do 82 | store = double 83 | stores.add(:foo, store) 84 | expect(stores[:foo]).to eq(store) 85 | end 86 | end 87 | 88 | describe "#map_types" do 89 | 90 | context "map two events stores" do 91 | 92 | class AnAggregate1 93 | include Sandthorn::AggregateRoot 94 | end 95 | 96 | class AnAggregate2 97 | include Sandthorn::AggregateRoot 98 | end 99 | 100 | before do 101 | store = double 102 | stores.add(:foo, store) 103 | stores.add(:bar, store) 104 | stores.map_types(foo: [AnAggregate1], bar: [AnAggregate2]) 105 | end 106 | 107 | it "should map event_store foo to AnAggregate1" do 108 | expect(AnAggregate1.event_store).to eq(:foo) 109 | end 110 | 111 | it "should map event_store bar to AnAggregate2" do 112 | expect(AnAggregate2.event_store).to eq(:bar) 113 | end 114 | end 115 | end 116 | 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/tracing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'sandthorn/event_inspector' 3 | 4 | class UsualSuspect 5 | include Sandthorn::AggregateRoot 6 | 7 | def initialize full_name 8 | @full_name = full_name 9 | @charges = [] 10 | end 11 | 12 | def charge_suspect_of_crime! crime_name 13 | suspect_was_charged crime_name 14 | end 15 | 16 | private 17 | def suspect_was_charged crime_name 18 | @charges << crime_name 19 | record_event 20 | end 21 | end 22 | 23 | class Simple 24 | include Sandthorn::AggregateRoot 25 | end 26 | module Go 27 | def go 28 | @foo = "bar" 29 | record_event 30 | end 31 | end 32 | 33 | describe "using a traced change" do 34 | context "when extending an instance with aggregate_root" do 35 | it "should record tracing if specified" do 36 | simple = Simple.new 37 | simple.extend Sandthorn::EventInspector 38 | 39 | simple.extend Go 40 | simple.aggregate_trace "123" do |traced| 41 | traced.go 42 | end 43 | expect(simple.events_with_trace_info.last[:event_metadata]).to eq("123") 44 | end 45 | end 46 | context "when not tracing" do 47 | it "should not have any trace event info at all on new" do 48 | suspect = UsualSuspect.new "Ronny" 49 | event = suspect.aggregate_events.first 50 | expect(event[:event_metadata]).to be_nil 51 | end 52 | it "should not have any trace event info at all on regular event" do 53 | suspect = UsualSuspect.new "Ronny" 54 | event = suspect.aggregate_events.first 55 | expect(event[:event_metadata]).to be_nil 56 | end 57 | end 58 | context "when changing aggregate in a traced context" do 59 | let(:suspect) {UsualSuspect.new("Conny").extend Sandthorn::EventInspector} 60 | it "should record modififier in the event" do 61 | suspect.aggregate_trace "Ture Sventon" do |s| 62 | s.charge_suspect_of_crime! "Theft" 63 | end 64 | event = suspect.events_with_trace_info.last 65 | expect(event[:event_metadata]).to eq("Ture Sventon") 66 | end 67 | 68 | it "should record optional other tracing information" do 69 | trace_info = {ip: "127.0.0.1", client: "Mozilla"} 70 | suspect.aggregate_trace trace_info do |s| 71 | s.charge_suspect_of_crime! "Murder" 72 | end 73 | event = suspect.events_with_trace_info.last 74 | expect(event[:event_metadata]).to eq(trace_info) 75 | end 76 | end 77 | context "when initializing a new aggregate in a traced context" do 78 | it "should record modifier in the new event" do 79 | UsualSuspect.aggregate_trace "Ture Sventon" do 80 | suspect = UsualSuspect.new("Sonny").extend Sandthorn::EventInspector 81 | event = suspect.events_with_trace_info.first 82 | expect(event[:event_metadata]).to eq("Ture Sventon") 83 | end 84 | end 85 | it "should record tracing for all events in the trace block" do 86 | trace_info = {gender: :unknown, occupation: :master} 87 | UsualSuspect.aggregate_trace trace_info do 88 | suspect = UsualSuspect.new("Sonny").extend Sandthorn::EventInspector 89 | suspect.charge_suspect_of_crime! "Hit and run" 90 | event = suspect.events_with_trace_info.last 91 | expect(event[:event_metadata]).to eq(trace_info) 92 | end 93 | end 94 | it "should record tracing for all events in the trace block" do 95 | trace_info = {user_aggregate_id: "foo-bar-x", gender: :unknown, occupation: :master} 96 | UsualSuspect.aggregate_trace trace_info do 97 | suspect = UsualSuspect.new("Conny").extend Sandthorn::EventInspector 98 | suspect.charge_suspect_of_crime! "Desception" 99 | event = suspect.events_with_trace_info.last 100 | expect(event[:event_metadata]).to eq(trace_info) 101 | end 102 | end 103 | end 104 | 105 | end -------------------------------------------------------------------------------- /spec/aggregate_root_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Sandthorn 4 | module AggregateRoot 5 | class DirtyClass 6 | include Sandthorn::AggregateRoot 7 | attr_reader :name, :age 8 | attr :age 9 | attr_writer :writer 10 | 11 | def initialize args = {} 12 | @name = args.fetch(:name, nil) 13 | @age = args.fetch(:age, nil) 14 | @writer = args.fetch(:writer, nil) 15 | end 16 | 17 | def change_name value 18 | unless name == value 19 | @name = value 20 | commit 21 | end 22 | end 23 | 24 | def change_age value 25 | unless age == value 26 | @age = value 27 | commit 28 | end 29 | end 30 | 31 | def change_writer value 32 | unless writer == value 33 | @writer = value 34 | commit 35 | end 36 | end 37 | 38 | def no_state_change_only_empty_event 39 | commit 40 | end 41 | end 42 | 43 | describe "::event_store" do 44 | let(:klass) { Class.new { include Sandthorn::AggregateRoot } } 45 | it "is available as a class method" do 46 | expect(klass).to respond_to(:event_store) 47 | end 48 | it "sets the event store as a class level variable and returns it" do 49 | klass.event_store(:other) 50 | expect(klass.event_store).to eq(:other) 51 | end 52 | end 53 | 54 | describe "::snapshot" do 55 | let(:klass) { Class.new { include Sandthorn::AggregateRoot } } 56 | it "is available as a class method" do 57 | expect(klass).to respond_to(:snapshot) 58 | end 59 | it "sets the snapshot to true and returns it" do 60 | klass.snapshot(true) 61 | expect(klass.snapshot).to eq(true) 62 | end 63 | end 64 | 65 | describe "when get all aggregates from DirtyClass" do 66 | 67 | before(:each) do 68 | @first = DirtyClass.new.save 69 | @middle = DirtyClass.new.save 70 | @last = DirtyClass.new.save 71 | end 72 | 73 | let(:subject) { DirtyClass.all.map{ |s| s.id} } 74 | let(:ids) { [@first.id, @middle.id, @last.id] } 75 | 76 | context "all" do 77 | it "should all the aggregates" do 78 | expect(subject.length).to eq(3) 79 | end 80 | 81 | it "should include correct aggregates" do 82 | expect(subject).to match_array(ids) 83 | end 84 | end 85 | 86 | end 87 | 88 | 89 | describe "when making a change on a aggregate" do 90 | let(:dirty_object) { 91 | o = DirtyClass.new 92 | o 93 | } 94 | 95 | context "new with args" do 96 | 97 | let(:subject) { DirtyClass.new(name: "Mogge", age: 35, writer: true) } 98 | it "should set the values" do 99 | expect(subject.name).to eq("Mogge") 100 | expect(subject.age).to eq(35) 101 | expect{subject.writer}.to raise_error NoMethodError 102 | end 103 | end 104 | 105 | context "when changing name (attr_reader)" do 106 | 107 | it "should get new_name" do 108 | dirty_object.change_name "new_name" 109 | expect(dirty_object.name).to eq("new_name") 110 | end 111 | 112 | it "should generate one event on new" do 113 | expect(dirty_object.aggregate_events.length).to eq(1) 114 | end 115 | 116 | it "should generate 2 events new and change_name" do 117 | dirty_object.change_name "new_name" 118 | expect(dirty_object.aggregate_events.length).to eq(2) 119 | end 120 | end 121 | 122 | context "when changing age (attr)" do 123 | it "should get new_age" do 124 | dirty_object.change_age "new_age" 125 | expect(dirty_object.age).to eq("new_age") 126 | end 127 | end 128 | 129 | context "when changing writer (attr_writer)" do 130 | it "should raise error" do 131 | expect{dirty_object.change_writer "new_writer"}.to raise_error NameError 132 | end 133 | end 134 | 135 | context "save" do 136 | it "should not have events on aggregate after save" do 137 | expect(dirty_object.save.aggregate_events.length).to eq(0) 138 | end 139 | 140 | it "should have aggregate_originating_version == 0 pre save" do 141 | expect(dirty_object.aggregate_originating_version).to eq(0) 142 | end 143 | 144 | it "should have aggregate_originating_version == 1 post save" do 145 | expect(dirty_object.save.aggregate_originating_version).to eq(1) 146 | end 147 | end 148 | 149 | context "find" do 150 | before(:each) { dirty_object.save } 151 | it "should find by id" do 152 | expect(DirtyClass.find(dirty_object.id).id).to eq(dirty_object.id) 153 | end 154 | 155 | it "should hold changed name" do 156 | dirty_object.change_name("morgan").save 157 | expect(DirtyClass.find(dirty_object.id).name).to eq("morgan") 158 | end 159 | 160 | it "should raise error if trying to find id that not exist" do 161 | expect{DirtyClass.find("666")}.to raise_error Sandthorn::Errors::AggregateNotFound 162 | end 163 | end 164 | 165 | 166 | end 167 | 168 | describe "event data" do 169 | 170 | let(:dirty_object) { 171 | o = DirtyClass.new :name => "old_value", :age => 35 172 | o.save 173 | } 174 | 175 | let(:dirty_object_after_find) { DirtyClass.find dirty_object.id } 176 | 177 | context "after find" do 178 | 179 | it "should set the old_value on the event" do 180 | dirty_object_after_find.change_name "new_name" 181 | expect(dirty_object_after_find.aggregate_events.last[:event_data][:name][:old_value]).to eq("old_value") 182 | end 183 | 184 | end 185 | 186 | context "old_value should be set" do 187 | 188 | it "should set the old_value on the event" do 189 | dirty_object.change_name "new_name" 190 | expect(dirty_object.aggregate_events.last[:event_data][:name][:old_value]).to eq("old_value") 191 | end 192 | 193 | it "should not change aggregate_id" do 194 | dirty_object.change_name "new_name" 195 | expect(dirty_object.aggregate_events.last[:event_data]["attribute_name"]).not_to eq("aggregate_id") 196 | end 197 | 198 | it "should not change age attribute if age method is not runned" do 199 | dirty_object.change_name "new_name" 200 | dirty_object.aggregate_events.each do |event| 201 | expect(event[:event_data]["age"].nil?).to be_truthy 202 | end 203 | end 204 | 205 | it "should not change age attribute if age attribute is the same" do 206 | dirty_object.change_age 35 207 | dirty_object.aggregate_events.each do |event| 208 | expect(event[:event_data]["age"].nil?).to be_truthy 209 | end 210 | end 211 | 212 | it "should set old_value and new_value on age change" do 213 | dirty_object.change_age 36 214 | expect(dirty_object.aggregate_events.last[:event_data][:age][:old_value]).to eq(35) 215 | expect(dirty_object.aggregate_events.last[:event_data][:age][:new_value]).to eq(36) 216 | end 217 | end 218 | end 219 | 220 | context "events should be created event if no state change is made" do 221 | let(:dirty_object) do 222 | DirtyClass.new.save.tap do |o| 223 | o.no_state_change_only_empty_event 224 | end 225 | end 226 | 227 | it "should have the event no_state_change_only_empty_event" do 228 | expect(dirty_object.aggregate_events.first[:event_name]).to eq("no_state_change_only_empty_event") 229 | end 230 | 231 | it "should have event_data set to empty hash" do 232 | expect(dirty_object.aggregate_events.first[:event_data]).to eq({}) 233 | end 234 | 235 | end 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /lib/sandthorn/aggregate_root_base.rb: -------------------------------------------------------------------------------- 1 | module Sandthorn 2 | module AggregateRoot 3 | module Base 4 | 5 | attr_reader :aggregate_id 6 | attr_reader :aggregate_events 7 | attr_reader :aggregate_current_event_version 8 | attr_reader :aggregate_originating_version 9 | attr_reader :aggregate_trace_information 10 | 11 | alias :id :aggregate_id 12 | alias :aggregate_version :aggregate_current_event_version 13 | 14 | 15 | def aggregate_base_initialize 16 | @aggregate_current_event_version = 0 17 | @aggregate_originating_version = 0 18 | @aggregate_events = [] 19 | end 20 | 21 | def save 22 | if aggregate_events.any? 23 | Sandthorn.save_events( 24 | aggregate_events, 25 | aggregate_id, 26 | self.class 27 | ) 28 | @aggregate_events = [] 29 | @aggregate_originating_version = @aggregate_current_event_version 30 | end 31 | 32 | Sandthorn.save_snapshot self if self.class.snapshot 33 | 34 | self 35 | end 36 | 37 | def ==(other) 38 | other.respond_to?(:aggregate_id) && aggregate_id == other.aggregate_id 39 | end 40 | 41 | def unsaved_events? 42 | aggregate_events.any? 43 | end 44 | 45 | def aggregate_trace args 46 | @aggregate_trace_information = args 47 | yield self if block_given? 48 | @aggregate_trace_information = nil 49 | end 50 | 51 | def commit 52 | event_name = caller_locations(1,1)[0].label.gsub(/block ?(.*) in /, "") 53 | commit_with_event_name(event_name) 54 | end 55 | 56 | def default_attributes 57 | #NOOP 58 | end 59 | 60 | alias :record_event :commit 61 | 62 | module ClassMethods 63 | 64 | @@aggregate_trace_information = nil 65 | def aggregate_trace args 66 | @@aggregate_trace_information = args 67 | yield self 68 | @@aggregate_trace_information = nil 69 | end 70 | 71 | def event_store(event_store = nil) 72 | if event_store 73 | @event_store = event_store 74 | else 75 | @event_store 76 | end 77 | end 78 | 79 | def snapshot(value = nil) 80 | if value 81 | @snapshot = value 82 | else 83 | @snapshot 84 | end 85 | end 86 | 87 | def all 88 | Sandthorn.all(self).map { |events| 89 | aggregate_build events, nil 90 | } 91 | end 92 | 93 | def find id 94 | return aggregate_find id unless id.respond_to?(:each) 95 | return id.map { |e| aggregate_find e } 96 | end 97 | 98 | def aggregate_find aggregate_id 99 | begin 100 | aggregate_from_snapshot = Sandthorn.find_snapshot(aggregate_id) if self.snapshot 101 | current_aggregate_version = aggregate_from_snapshot.nil? ? 0 : aggregate_from_snapshot.aggregate_current_event_version 102 | events = Sandthorn.find(aggregate_id, self, current_aggregate_version) 103 | if aggregate_from_snapshot.nil? && events.empty? 104 | raise Errors::AggregateNotFound 105 | end 106 | 107 | return aggregate_build events, aggregate_from_snapshot 108 | rescue Exception 109 | raise Errors::AggregateNotFound 110 | end 111 | 112 | end 113 | 114 | def new *args, &block 115 | aggregate = create_new_empty_aggregate() 116 | aggregate.aggregate_base_initialize 117 | aggregate.aggregate_initialize 118 | 119 | aggregate.default_attributes 120 | aggregate.send :initialize, *args, &block 121 | aggregate.send :set_aggregate_id, Sandthorn.generate_aggregate_id 122 | 123 | aggregate.aggregate_trace @@aggregate_trace_information do |aggr| 124 | aggr.send :commit 125 | return aggr 126 | end 127 | 128 | end 129 | 130 | def aggregate_build events, aggregate_from_snapshot = nil 131 | aggregate = aggregate_from_snapshot || create_new_empty_aggregate 132 | 133 | if events.any? 134 | current_aggregate_version = events.last[:aggregate_version] 135 | aggregate.send :set_orginating_aggregate_version!, current_aggregate_version 136 | aggregate.send :set_current_aggregate_version!, current_aggregate_version 137 | aggregate.send :set_aggregate_id, events.first.fetch(:aggregate_id) 138 | end 139 | attributes = build_instance_vars_from_events events 140 | aggregate.send :clear_aggregate_events 141 | 142 | aggregate.default_attributes 143 | aggregate.send :aggregate_initialize 144 | 145 | aggregate.send :set_instance_variables!, attributes 146 | aggregate 147 | end 148 | 149 | def stateless_events(*event_names) 150 | event_names.each do |name| 151 | define_singleton_method name do |aggregate_id, *args| 152 | event = build_stateless_event(aggregate_id, name.to_s, args) 153 | Sandthorn.save_events([event], aggregate_id, self) 154 | return aggregate_id 155 | end 156 | end 157 | end 158 | 159 | def constructor_events(*event_names) 160 | event_names.each do |name| 161 | define_singleton_method name do |*args, &block| 162 | 163 | create_new_empty_aggregate.tap do |aggregate| 164 | aggregate.aggregate_base_initialize 165 | aggregate.aggregate_initialize 166 | aggregate.send :set_aggregate_id, Sandthorn.generate_aggregate_id 167 | aggregate.instance_eval(&block) if block 168 | aggregate.send :commit_with_event_name, name.to_s 169 | return aggregate 170 | end 171 | 172 | end 173 | self.singleton_class.class_eval { private name.to_s } 174 | end 175 | end 176 | 177 | def events(*event_names) 178 | event_names.each do |name| 179 | define_method(name) do |*args, &block| 180 | block.call() if block 181 | commit_with_event_name(name.to_s) 182 | end 183 | private name.to_s 184 | end 185 | end 186 | 187 | private 188 | 189 | def build_stateless_event aggregate_id, name, args = [] 190 | 191 | deltas = {} 192 | args.first.each do |key, value| 193 | deltas[key.to_sym] = { old_value: nil, new_value: value } 194 | end unless args.empty? 195 | 196 | return { 197 | aggregate_version: nil, 198 | aggregate_id: aggregate_id, 199 | event_name: name, 200 | event_data: deltas, 201 | event_metadata: nil 202 | } 203 | 204 | end 205 | 206 | def build_instance_vars_from_events events 207 | events.each_with_object({}) do |event, instance_vars| 208 | attribute_deltas = event[:event_data] 209 | unless attribute_deltas.nil? 210 | deltas = {} 211 | attribute_deltas.each do |key, value| 212 | deltas[key] = value[:new_value] 213 | end 214 | instance_vars.merge! deltas 215 | end 216 | end 217 | end 218 | 219 | def create_new_empty_aggregate 220 | allocate 221 | end 222 | end 223 | 224 | private 225 | 226 | def set_instance_variables! attributes 227 | attributes.each_pair do |k,v| 228 | self.instance_variable_set "@#{k}", v 229 | end 230 | end 231 | 232 | def extract_relevant_aggregate_instance_variables 233 | instance_variables.select do |variable| 234 | !variable.to_s.start_with?("@aggregate_") 235 | end 236 | end 237 | 238 | def set_orginating_aggregate_version! aggregate_version 239 | @aggregate_originating_version = aggregate_version 240 | end 241 | 242 | def increase_current_aggregate_version! 243 | @aggregate_current_event_version += 1 244 | end 245 | 246 | def set_current_aggregate_version! aggregate_version 247 | @aggregate_current_event_version = aggregate_version 248 | end 249 | 250 | def clear_aggregate_events 251 | @aggregate_events = [] 252 | end 253 | 254 | def aggregate_clear_current_event_version! 255 | @aggregate_current_event_version = 0 256 | end 257 | 258 | def set_aggregate_id aggregate_id 259 | @aggregate_id = aggregate_id 260 | end 261 | 262 | def commit_with_event_name(event_name) 263 | increase_current_aggregate_version! 264 | 265 | @aggregate_events << ({ 266 | aggregate_version: @aggregate_current_event_version, 267 | aggregate_id: @aggregate_id, 268 | event_name: event_name, 269 | event_data: get_delta(), 270 | event_metadata: @aggregate_trace_information 271 | }) 272 | 273 | self 274 | end 275 | 276 | end 277 | end 278 | end 279 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Sandthorn/sandthorn.svg?branch=master)](https://travis-ci.org/Sandthorn/sandthorn) 2 | [![Coverage Status](https://coveralls.io/repos/Sandthorn/sandthorn/badge.svg?branch=master)](https://coveralls.io/r/Sandthorn/sandthorn?branch=master) 3 | [![Code Climate](https://codeclimate.com/github/Sandthorn/sandthorn.svg)](https://codeclimate.com/github/Sandthorn/sandthorn) 4 | [![Gem Version](https://badge.fury.io/rb/sandthorn.svg)](http://badge.fury.io/rb/sandthorn) 5 | 6 | # Sandthorn Event Sourcing 7 | A ruby library for saving an object's state as a series of events. 8 | 9 | ## What is Event Sourcing? 10 | 11 | "Capture all changes to an application state as a sequence of events." 12 | [Event Sourcing](http://martinfowler.com/eaaDev/EventSourcing.html) 13 | 14 | ## When do I need event sourcing? 15 | 16 | When state changes made to an object is important a common technique is to store the changes in a separate history log where the log is generated in parallel with the object internal state. With event sourcing the history log is now integrated within the object and generated based on the actions made to the object. The entries in log is the facts the object is built upon. 17 | 18 | ## Why Sandthorn? 19 | 20 | If you have been following [Uncle Bob](http://blog.8thlight.com/uncle-bob/2014/05/11/FrameworkBound.html) you know what he thinks of the "Rails way" and how we get bound to the Rails framework. We have created Sandthorn to decouple our models from Active Record and restore them to what they should be, i.e., Plain Old Ruby Objects (PORO) with a twist of Sandthorn magic. 21 | 22 | Check out examples of Sandthorn: 23 | 24 | * [Examples](https://github.com/Sandthorn/sandthorn_examples) including a product shop and TicTacToe game. 25 | * Live [demo](http://infinite-mesa-8629.herokuapp.com/) comparing Active Record and Sandthorn. 26 | 27 | # Installation 28 | 29 | Add this line to your application's Gemfile: 30 | 31 | gem 'sandthorn' 32 | 33 | And then execute: 34 | 35 | $ bundle 36 | 37 | Or install it yourself as: 38 | 39 | $ gem install sandthorn 40 | 41 | # Configuring Sandthorn 42 | 43 | ## Driver 44 | Sandthorn can be setup with one or more drivers. A driver is bound to a specific data store where events are saved and loaded from. The current implemented drivers are [sandthorn_driver_sequel](https://github.com/Sandthorn/sandthorn_driver_sequel) for SQL via [Sequel](https://github.com/jeremyevans/sequel) and [sandthorn_driver_event_store](https://github.com/Sandthorn/sandthorn_driver_event_store) that uses [Get Event Store](https://geteventstore.com). 45 | 46 | This means Sandthorn can be used with any data store given that a driver exists. 47 | 48 | Here's an example of setting up Sandthorn with the Sequel driver and a sqlite3 database. 49 | 50 | ```ruby 51 | url = "sqlite://sql.sqlite3" 52 | driver = SandthornDriverSequel.driver_from_url(url: url) 53 | Sandthorn.configure do |conf| 54 | conf.event_stores = { default: driver } 55 | end 56 | ``` 57 | 58 | ## Map aggregate types to event stores 59 | 60 | Its possible to save events from different classes into different stores. Below the events from class FooAggregate are stored into the sql_foo.sqlite3 database and events from class BarAggregate are stored in sql_bar.sqlite3. 61 | 62 | ```ruby 63 | driver_foo = SandthornDriverSequel.driver_from_url(url: "sqlite://sql_foo.sqlite3") 64 | driver_bar = SandthornDriverSequel.driver_from_url(url: "sqlite://sql_bar.sqlite3") 65 | 66 | class FooAggregate 67 | Include Sandthorn::AggregateRoot 68 | end 69 | 70 | class BarAggregate 71 | Include Sandthorn::AggregateRoot 72 | end 73 | 74 | Sandthorn.configure do |conf| 75 | conf.event_stores = { foo: driver_foo, bar: driver_bar } 76 | conf.map_types = { foo: [FooAggregate], bar: [BarAggregate] } 77 | end 78 | ``` 79 | 80 | # Usage 81 | 82 | ## Aggregate Root 83 | 84 | Any object that should have event sourcing capability must include the methods provided by `Sandthorn::AggregateRoot`. These make it possible to `commit` events and `save` changes to an aggregate. Use the `include` directive as follows: 85 | 86 | ```ruby 87 | require 'sandthorn' 88 | 89 | class Board 90 | include Sandthorn::AggregateRoot 91 | end 92 | ``` 93 | 94 | All objects that include `Sandthorn::AggregateRoot` is provided with an `aggregate_id` which is a [UUID](http://en.wikipedia.org/wiki/Universally_unique_identifier). 95 | 96 | ### `Sandthorn::AggregateRoot::events` 97 | 98 | An abstraction over `commit` that creates events methods that can be used from within a command method. 99 | 100 | In this exampel the `events` method will generate a method called `marked`, this method take an block that will be executed before the event is commited and is used to groups the state changes to the event. The block is optional and the state changes could have been made outside the `marked` method. 101 | 102 | ```ruby 103 | class Board 104 | include Sandthorn::AggregateRoot 105 | 106 | events :marked 107 | 108 | def mark player, pos_x, pos_y 109 | # change some state 110 | marked() do 111 | @pos_x = pos_x 112 | @pos_y = pos_y 113 | end 114 | end 115 | end 116 | ``` 117 | 118 | ### `Sandthorn::AggregateRoot::constructor_events` 119 | 120 | With `constructor_events` its possible to be more specific on how an aggregate came to be. The first event will now have the name `board_created` instead of the default `new`. 121 | 122 | ```ruby 123 | class Board 124 | include Sandthorn::AggregateRoot 125 | 126 | # creates a private class method `board_created` 127 | constructor_events :board_created 128 | 129 | def self.create name 130 | 131 | board_created(name) do 132 | @name = name 133 | end 134 | end 135 | end 136 | ``` 137 | 138 | ### `Sandthorn::AggregateRoot::stateless_events` 139 | 140 | Calling `stateless_events` creates public class methods. The first argument is an `aggregate_id` and the second argument is optional but has to be a hash and is stored in the event_data of the event. 141 | 142 | When creating a stateless event, the corresponding aggregate is never loaded and the event is saved without calling the save method. 143 | 144 | ```ruby 145 | class Board 146 | include Sandthorn::AggregateRoot 147 | 148 | stateless_events :player_went_to_toilet 149 | 150 | end 151 | 152 | Board.player_went_to_toilet "board_aggregate_id", {player_id: "1", time: "10:12"} 153 | ``` 154 | 155 | ### `Sandthorn::AggregateRoot::default_attributes` 156 | 157 | Its possible to add a default_attributes method on an aggregate and set default values to new and already created aggregates. 158 | 159 | The `default_attributes` method will be run before initialize on Class.new and before the events when an aggregate is rebuilt. This will make is possible to add default attributes to an aggregate during its hole life cycle. 160 | 161 | ```ruby 162 | def default_attributes 163 | @new_array = [] 164 | end 165 | ``` 166 | 167 | ### `Sandthorn::AggregateRoot.commit` 168 | 169 | To generate an event the commit method has to be called within the aggregate. `commit` extracts the object's delta and locally caches the state changes that has been applied to the aggregate. 170 | 171 | ```ruby 172 | def mark player, pos_x, pos_y 173 | # change some state 174 | marked 175 | end 176 | 177 | def marked 178 | commit 179 | end 180 | ``` 181 | 182 | `commit` determines the state changes by monitoring the object's readable fields. 183 | 184 | The concept `events` have been introduced to abstract away the usage of `commit`. Commit still works as before but we think that the `events` abstraction makes the aggregate more readable. 185 | 186 | ### `Sandthorn::AggregateRoot.save` 187 | 188 | The save method store generated events, this means all commited events will be persisted via a Sandthorn driver. 189 | 190 | ```ruby 191 | board = Board.new 192 | board.mark :o, 0, 1 193 | board.save 194 | ``` 195 | 196 | ### `Sandthorn::AggregateRoot.all` 197 | 198 | Retrieve an array with all instances of a specific aggregate type. 199 | 200 | ```ruby 201 | Board.all 202 | ``` 203 | 204 | Since it return's an `Array` you can, for example, filter on an aggregate's fields 205 | 206 | ```ruby 207 | Board.all.select { |board| board.active == true } 208 | ``` 209 | 210 | ### `Sandthorn::AggregateRoot.find` 211 | 212 | Loads a specific aggregate using it's uuid. 213 | 214 | ```ruby 215 | uuid = '550e8400-e29b-41d4-a716-446655440000' 216 | board = Board.find(uuid) 217 | ``` 218 | 219 | If no aggregate with the specifid uuid is found, a `Sandthorn::Errors::AggregateNotFound` exception is raised. 220 | 221 | ### `Sandthorn::AggregateRoot.aggregate_trace` 222 | 223 | Using `aggregate_trace` one can store meta data on events. The data is not aggregate specific and it can for example store who executed a specific command on the aggregate. 224 | 225 | ```ruby 226 | board.aggregate_trace {player: "Fred"} do |aggregate| 227 | aggregate.mark :o, 0, 1 228 | aggregate.save 229 | end 230 | ``` 231 | 232 | `aggregate_trace` can also be specified on a class. 233 | 234 | ```ruby 235 | Board.aggregate_trace {ip: :127.0.0.1} do 236 | board = Board.new 237 | board.mark :o , 0, 1 238 | board.save 239 | end 240 | ``` 241 | 242 | In this case, the resulting events from the commands `new` and `mark` will have the trace `{ip: :127.0.0.1}` attached to them. 243 | 244 | ### `Sandthorn::AggregateRoot.unsaved_events?` 245 | 246 | Check if there are unsaved events attached to the aggregate. 247 | 248 | ```ruby 249 | board = Board.new 250 | board.mark :o, 0, 1 251 | board.unsaved_events? 252 | => true 253 | ``` 254 | 255 | ## Snapshot 256 | 257 | If there is a lot of events saved to an aggregate it can take some time to reload the current state of the aggregate via the `.find` method. This is because all events belonging to the aggregate has to be fetched and iterated one by one to build its current state. The snapshot functionality makes it possible to store the current aggregate state and re-use it when loading the aggregate. The snapshot is used as a cache where only the events that has occurred after the snapshot has to be fetched and used to build the current state of the aggregate. 258 | 259 | There is one global snapshot store where all snapshots are stored independent on aggregate_type. To enable snapshot on a aggregate_type the Class has to be added to the `snapshot_types` Array when configuring Sandthorn. The aggregate will now be stored to the snapshot_store on every `.save` and when using `.find` it will look for a snapshot of the requested aggregate. 260 | 261 | ```ruby 262 | 263 | class Board 264 | include Sandthorn::AggregateRoot 265 | end 266 | 267 | Sandthorn.configure do |c| 268 | c.snapshot_types = [Board] 269 | end 270 | ``` 271 | 272 | Its possible to take manual snapshots without enabling snapshots on the aggregate_type. 273 | 274 | ```ruby 275 | board = Board.new 276 | board.save 277 | 278 | # Save snapshot of the board aggregate 279 | Sandthorn.save_snapshot board 280 | 281 | # Get snapshot 282 | snapshot = Sandthorn.find_snapshot board.aggregate_id 283 | ``` 284 | 285 | ### External snapshot store 286 | 287 | There is one external snapshot store available [sandthorn_snapshot_memcached](https://github.com/Sandthorn/sandthorn_snapshot_memcached) and it can be configured via `Sandthorn.configure` 288 | 289 | ```ruby 290 | require 'sandthorn_snapshot_memcached' 291 | 292 | snapshot_store = SandthornSnapshotMemcached.from_url "memcached_url" 293 | 294 | Sandthorn.configure do |conf| 295 | conf.snapshot_store = snapshot_store 296 | end 297 | ``` 298 | 299 | **If no external snapshot store is configured snapshots will be stored in the application memory (be careful not draining your application memory space).** 300 | 301 | ## Bounded Context 302 | 303 | A bounded context is a system divider that split large systems into smaller parts. [Bounded Context by Martin Fowler](http://martinfowler.com/bliki/BoundedContext.html) 304 | 305 | A module can include `Sandthorn::BoundedContext` and all aggregates within the module can be retreived via the ::aggregate_types method on the module. A use case is to use it when Sandthorn is configured and setup all aggregates in a bounded context to a driver. 306 | 307 | ```ruby 308 | require 'sandthorn/bounded_context' 309 | 310 | module TicTacToe 311 | include Sandthorn::BoundedContext 312 | 313 | class Board 314 | include Sandthorn::AggregateRoot 315 | end 316 | end 317 | 318 | Sandthorn.configure do |conf| 319 | conf.event_stores = { foo: driver_foo} 320 | conf.map_types = { foo: TicTacToe.aggregate_types } 321 | end 322 | 323 | TicTacToe.aggregate_types -> [TicTacToy::Board] 324 | ``` 325 | 326 | # Development 327 | 328 | Run tests: `rake` 329 | 330 | Run benchmark tests: `rake benchmark` 331 | 332 | Load a console: `rake console` 333 | 334 | # Contributing 335 | 336 | We're happy to accept pull requests that makes the code cleaner or more idiomatic, the documentation more understandable, or improves the testsuite. Even considering opening an issue for what's troubling you or writing a blog post about how you used Sandthorn is worth a lot too! 337 | 338 | In general, the contribution process for code works like this. 339 | 340 | 1. Fork this repo 341 | 2. Create your feature branch (`git checkout -b my-new-feature`) 342 | 3. Commit your changes (`git commit -am 'Add some feature'`) 343 | 4. Push to the branch (`git push origin my-new-feature`) 344 | 5. Create new Pull Request 345 | --------------------------------------------------------------------------------