├── .rspec ├── .tool-versions ├── app ├── events │ ├── todo_added.rb │ ├── todo_amended.rb │ ├── todo_abandoned.rb │ ├── todo_completed.rb │ ├── stakeholder_notified_of_todo_completion.rb │ └── README.md ├── projections │ ├── completed_todos │ │ ├── README.md │ │ ├── query.rb │ │ └── projector.rb │ ├── outstanding_todos │ │ ├── README.md │ │ ├── query.rb │ │ └── projector.rb │ ├── scheduled_todos │ │ ├── README.md │ │ ├── query.rb │ │ └── projector.rb │ └── README.md ├── errors.rb ├── web │ ├── README.md │ └── server.rb ├── commands │ ├── README.md │ └── todo │ │ ├── abandon.rb │ │ ├── complete.rb │ │ ├── amend.rb │ │ └── add.rb ├── utils.rb ├── aggregates │ ├── README.md │ └── todo.rb └── reactors │ ├── README.md │ └── todo_completed_notifier.rb ├── Procfile ├── config.ru ├── scripts ├── server ├── setup └── request ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-auto-merge.yml │ └── test.yml ├── app.json ├── spec ├── support │ └── request_helpers.rb ├── commands │ └── todo │ │ ├── abandon_command_spec.rb │ │ ├── complete_command_spec.rb │ │ ├── amend_command_spec.rb │ │ └── add_command_spec.rb ├── spec_helper.rb ├── requests │ ├── add_todo_spec.rb │ ├── outstanding_todos_spec.rb │ ├── completed_todos_spec.rb │ ├── amend_todo_spec.rb │ ├── scheduled_todos_spec.rb │ ├── abandon_todo_spec.rb │ └── complete_todo_spec.rb └── reactors │ └── todo_completed_notifier_spec.rb ├── Gemfile ├── LICENSE.txt ├── config └── environment.rb ├── Gemfile.lock ├── Rakefile ├── CODE_OF_CONDUCT.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.2.8 2 | -------------------------------------------------------------------------------- /app/events/todo_added.rb: -------------------------------------------------------------------------------- 1 | TodoAdded = Class.new(EventSourcery::Event) 2 | -------------------------------------------------------------------------------- /app/events/todo_amended.rb: -------------------------------------------------------------------------------- 1 | TodoAmended = Class.new(EventSourcery::Event) 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./scripts/server 2 | processors: bundle exec rake run_processors 3 | -------------------------------------------------------------------------------- /app/events/todo_abandoned.rb: -------------------------------------------------------------------------------- 1 | TodoAbandoned = Class.new(EventSourcery::Event) 2 | -------------------------------------------------------------------------------- /app/events/todo_completed.rb: -------------------------------------------------------------------------------- 1 | TodoCompleted = Class.new(EventSourcery::Event) 2 | -------------------------------------------------------------------------------- /app/events/stakeholder_notified_of_todo_completion.rb: -------------------------------------------------------------------------------- 1 | StakeholderNotifiedOfTodoCompletion = Class.new(EventSourcery::Event) 2 | -------------------------------------------------------------------------------- /app/projections/completed_todos/README.md: -------------------------------------------------------------------------------- 1 | # Completed Todos 2 | 3 | A projection that shows the list of completed todos. 4 | -------------------------------------------------------------------------------- /app/projections/outstanding_todos/README.md: -------------------------------------------------------------------------------- 1 | # Outstanding Todos 2 | 3 | A projection that shows the list of outstanding todos. 4 | -------------------------------------------------------------------------------- /app/projections/scheduled_todos/README.md: -------------------------------------------------------------------------------- 1 | # Scheduled Todos 2 | 3 | A projection that shows the list of todos that have a due date. 4 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | $LOAD_PATH << '.' 2 | 3 | require 'config/environment' 4 | require 'app/web/server' 5 | 6 | run EventSourceryTodoApp::Server.new 7 | -------------------------------------------------------------------------------- /app/errors.rb: -------------------------------------------------------------------------------- 1 | module EventSourceryTodoApp 2 | UnprocessableEntity = Class.new(StandardError) 3 | BadRequest = Class.new(StandardError) 4 | end 5 | -------------------------------------------------------------------------------- /app/web/README.md: -------------------------------------------------------------------------------- 1 | # Web Layer 2 | 3 | This is the web front end of the application. It uses Sinatra and forwards requests to command handlers to do work. 4 | -------------------------------------------------------------------------------- /scripts/server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $RACK_ENV == production ]]; then 4 | bundle exec rackup -p $PORT 5 | else 6 | bundle exec shotgun -p 3000 7 | fi 8 | -------------------------------------------------------------------------------- /app/commands/README.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | The set of command handlers and commands that can be issued against the system. These form an interface between the web API and the domain model in the aggregate. 4 | -------------------------------------------------------------------------------- /app/events/README.md: -------------------------------------------------------------------------------- 1 | # Events 2 | 3 | These are our domain events. They are stored in our event store as a list of immutable facts over time. Together they form the source of truth for our application's state. 4 | -------------------------------------------------------------------------------- /app/utils.rb: -------------------------------------------------------------------------------- 1 | # Monkey patch 2 | class Hash 3 | def slice(*keys) 4 | keys.map! { |key| convert_key(key) } if respond_to?(:convert_key, true) 5 | keys.each_with_object(self.class.new) { |k, hash| hash[k] = self[k] if has_key?(k) } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/projections/README.md: -------------------------------------------------------------------------------- 1 | # Projections 2 | 3 | You can think of projections as read-only models. They are created and updated by projectors and in this case show different current state views over the events that are the source of truth for our application state. 4 | -------------------------------------------------------------------------------- /app/aggregates/README.md: -------------------------------------------------------------------------------- 1 | # Aggregates 2 | 3 | The domain is modeled via aggregates. In this application we only have one aggregate root: `Todo`. It loads its state from the event store (via the `repository`), executes commands, and raises new events which are saved back to the store (again via the `repository`). 4 | -------------------------------------------------------------------------------- /app/reactors/README.md: -------------------------------------------------------------------------------- 1 | # Reactors 2 | 3 | Reactors listen for events and take some action. Often these actions will involve emitting other events into the store. Sometimes it may involve triggering side effects in external systems. 4 | 5 | Reactors can be used to build [process managers or sagas](https://msdn.microsoft.com/en-us/library/jj591569.aspx). 6 | -------------------------------------------------------------------------------- /app/projections/completed_todos/query.rb: -------------------------------------------------------------------------------- 1 | module EventSourceryTodoApp 2 | module Projections 3 | module CompletedTodos 4 | # Query handler that queries the projection table. 5 | class Query 6 | def self.handle 7 | EventSourceryTodoApp.projections_database[:query_completed_todos].all 8 | end 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/projections/outstanding_todos/query.rb: -------------------------------------------------------------------------------- 1 | module EventSourceryTodoApp 2 | module Projections 3 | module Outstanding 4 | # Query handler that queries the projection table. 5 | class Query 6 | def self.handle 7 | EventSourceryTodoApp.projections_database[:query_outstanding_todos].all 8 | end 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/projections/scheduled_todos/query.rb: -------------------------------------------------------------------------------- 1 | module EventSourceryTodoApp 2 | module Projections 3 | module Scheduled 4 | # Query handler that queries the projection table. 5 | class Query 6 | def self.handle 7 | EventSourceryTodoApp.projections_database[:query_scheduled_todos].exclude(due_date: nil).all 8 | end 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - directory: / 4 | package-ecosystem: bundler 5 | versioning-strategy: lockfile-only 6 | allow: 7 | - dependency-type: all 8 | schedule: 9 | interval: monthly 10 | open-pull-requests-limit: 50 11 | groups: 12 | ruby: 13 | patterns: 14 | - '*' 15 | commit-message: 16 | prefix: Dependabot 17 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Event Sourcery Todo App", 3 | "description": "An example Todo List app using Event Sourcery", 4 | "keywords": ["ruby", "event_sourcery", "cqrs", "todo", "sample"], 5 | "formation": { 6 | "web": { 7 | "quantity": 1 8 | }, 9 | "processors": { 10 | "quantity": 1 11 | } 12 | }, 13 | "scripts": { 14 | "postdeploy": "bundle exec rake db:migrate" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spec/support/request_helpers.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module RequestHelpers 4 | def app 5 | @@app ||= EventSourceryTodoApp::Server 6 | end 7 | 8 | def last_event(aggregate_id) 9 | EventSourceryTodoApp.event_store 10 | .get_events_for_aggregate_id(aggregate_id).last 11 | end 12 | 13 | def post_json(uri, body_hash={}) 14 | post(uri, body_hash.to_json, {"CONTENT_TYPE" => "application/json"}) 15 | end 16 | 17 | def put_json(uri, body_hash={}) 18 | put(uri, body_hash.to_json, {"CONTENT_TYPE" => "application/json"}) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'event_sourcery' 6 | gem 'event_sourcery-postgres' 7 | 8 | gem 'rake' 9 | gem 'sinatra' 10 | gem 'puma' 11 | # NOTE: pg is an implicit dependency of event_sourcery-postgres but we need to 12 | # lock to an older version for deprecation warnings. 13 | gem 'pg', '1.5.4' 14 | 15 | group :development, :test do 16 | gem 'pry-byebug' 17 | gem 'rspec' 18 | gem 'rack-test' 19 | gem 'database_cleaner-sequel' 20 | gem 'shotgun', git: 'https://github.com/delonnewman/shotgun.git' 21 | gem 'commander' 22 | gem 'better_errors' 23 | end 24 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | 3 | on: 4 | pull_request_target: 5 | paths: 6 | - Gemfile.lock 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | dependabot: 14 | runs-on: ubuntu-latest 15 | if: ${{ github.actor == 'dependabot[bot]' }} 16 | steps: 17 | - name: Dependabot metadata 18 | id: metadata 19 | uses: dependabot/fetch-metadata@v1 20 | with: 21 | github-token: "${{ secrets.GITHUB_TOKEN }}" 22 | - name: Enable auto-merge for Dependabot PRs 23 | run: gh pr merge "$PR_URL" --auto --squash 24 | env: 25 | PR_URL: ${{ github.event.pull_request.html_url }} 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! type -t "bundle" > /dev/null; then 4 | echo 5 | echo "--- Installing Bundler" 6 | echo 7 | 8 | gem install 'bundler:$(tail -1 Gemfile.lock | tr -d '[:space:]')' 9 | fi 10 | 11 | if ! type -t "foreman" > /dev/null; then 12 | echo 13 | echo "--- Installing Foreman" 14 | echo 15 | 16 | gem install foreman 17 | fi 18 | 19 | echo 20 | echo "--- Installing gems" 21 | echo 22 | 23 | bundle install 24 | 25 | echo 26 | echo "--- Creating and migrating databases" 27 | echo 28 | 29 | bundle exec rake db:create db:migrate 30 | RACK_ENV=test bundle exec rake db:create db:migrate 31 | 32 | 33 | echo 34 | echo "--- Setting up event processors" 35 | echo 36 | 37 | bundle exec rake setup_processors 38 | RACK_ENV=test bundle exec rake setup_processors 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tests 3 | on: [ push, pull_request ] 4 | jobs: 5 | test: 6 | name: Test (Ruby ${{ matrix.ruby }}) 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: [ '3.2', '3.1' ] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Ruby ${{ matrix.ruby }} 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby }} 18 | bundler-cache: true 19 | - name: RSpec 20 | run: bundle exec rake --trace db:migrate spec 21 | env: 22 | DATABASE_URL: postgres://postgres:secretdb@localhost:25432/event_sourcery_todo_app_test 23 | RACK_ENV: test 24 | services: 25 | postgres: 26 | image: postgres 27 | env: 28 | POSTGRES_DB: event_sourcery_todo_app_test 29 | POSTGRES_PASSWORD: secretdb 30 | ports: 31 | - 25432:5432 32 | 33 | -------------------------------------------------------------------------------- /spec/commands/todo/abandon_command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'app/commands/todo/abandon' 2 | 3 | RSpec.describe EventSourceryTodoApp::Commands::Todo::Abandon::Command do 4 | describe '.build' do 5 | subject(:build) { 6 | described_class.build(params) 7 | } 8 | 9 | context 'without a todo_id' do 10 | let(:params) { 11 | { 12 | abandoned_on: '2017-06-16' 13 | } 14 | } 15 | it 'raises as error' do 16 | expect { build }.to raise_error( 17 | EventSourceryTodoApp::BadRequest, 18 | 'todo_id is blank' 19 | ) 20 | end 21 | end 22 | 23 | context 'with an invalid abandoned_on date' do 24 | let(:params) { 25 | { 26 | todo_id: SecureRandom.uuid, 27 | abandoned_on: 'not a date' 28 | } 29 | } 30 | it 'raises as error' do 31 | expect { build }.to raise_error( 32 | EventSourceryTodoApp::BadRequest, 33 | 'abandoned_on is invalid' 34 | ) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/commands/todo/complete_command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'app/commands/todo/complete' 2 | 3 | RSpec.describe EventSourceryTodoApp::Commands::Todo::Complete::Command do 4 | describe '.build' do 5 | subject(:build) { 6 | described_class.build(params) 7 | } 8 | 9 | context 'without a todo_id' do 10 | let(:params) { 11 | { 12 | completed_on: '2017-06-16' 13 | } 14 | } 15 | it 'raises as error' do 16 | expect { build }.to raise_error( 17 | EventSourceryTodoApp::BadRequest, 18 | 'todo_id is blank' 19 | ) 20 | end 21 | end 22 | 23 | context 'with an invalid completed_on date' do 24 | let(:params) { 25 | { 26 | todo_id: SecureRandom.uuid, 27 | completed_on: 'not a date' 28 | } 29 | } 30 | it 'raises as error' do 31 | expect { build }.to raise_error( 32 | EventSourceryTodoApp::BadRequest, 33 | 'completed_on is invalid' 34 | ) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/commands/todo/amend_command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'app/commands/todo/amend' 2 | 3 | RSpec.describe EventSourceryTodoApp::Commands::Todo::Amend::Command do 4 | describe '.build' do 5 | subject(:build) { 6 | described_class.build(params) 7 | } 8 | 9 | context 'without a todo_id' do 10 | let(:params) { 11 | { 12 | title: 'Stick around', 13 | due_date: '2017-06-16' 14 | } 15 | } 16 | it 'raises as error' do 17 | expect { build }.to raise_error( 18 | EventSourceryTodoApp::BadRequest, 19 | 'todo_id is blank' 20 | ) 21 | end 22 | end 23 | 24 | context 'with an invalid abandoned_on date' do 25 | let(:params) { 26 | { 27 | todo_id: SecureRandom.uuid, 28 | title: 'Stick around', 29 | due_date: 'not a date' 30 | } 31 | } 32 | it 'raises as error' do 33 | expect { build }.to raise_error( 34 | EventSourceryTodoApp::BadRequest, 35 | 'due_date is invalid' 36 | ) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Envato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RACK_ENV'] = 'test' 2 | 3 | require 'rack/test' 4 | require 'securerandom' 5 | require 'database_cleaner/sequel' 6 | 7 | $LOAD_PATH << '.' 8 | 9 | require 'config/environment' 10 | require 'app/web/server' 11 | require 'spec/support/request_helpers' 12 | 13 | RSpec.configure do |config| 14 | config.include(Rack::Test::Methods, type: :request) 15 | config.include(RequestHelpers, type: :request) 16 | 17 | config.expect_with :rspec do |expectations| 18 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 19 | end 20 | 21 | config.mock_with :rspec do |mocks| 22 | mocks.verify_partial_doubles = true 23 | end 24 | 25 | config.shared_context_metadata_behavior = :apply_to_host_groups 26 | config.disable_monkey_patching! 27 | config.order = :random 28 | Kernel.srand config.seed 29 | 30 | EventSourcery.configure do |config| 31 | config.logger = Logger.new(nil) 32 | end 33 | 34 | config.before(:suite) do 35 | DatabaseCleaner.strategy = :truncation 36 | end 37 | 38 | config.around(:each) do |example| 39 | DatabaseCleaner.cleaning do 40 | example.run 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/commands/todo/add_command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'app/commands/todo/add' 2 | 3 | RSpec.describe EventSourceryTodoApp::Commands::Todo::Add::Command do 4 | describe '.build' do 5 | subject(:build) { 6 | described_class.build(params) 7 | } 8 | 9 | context 'without a todo_id' do 10 | let(:params) { 11 | { 12 | title: 'Stick around', 13 | due_date: '2017-06-16' 14 | } 15 | } 16 | it 'raises as error' do 17 | expect { build }.to raise_error( 18 | EventSourceryTodoApp::BadRequest, 19 | 'todo_id is blank' 20 | ) 21 | end 22 | end 23 | 24 | context 'without a title' do 25 | let(:params) { 26 | { 27 | todo_id: SecureRandom.uuid, 28 | due_date: '2017-06-16' 29 | } 30 | } 31 | it 'raises as error' do 32 | expect { build }.to raise_error( 33 | EventSourceryTodoApp::BadRequest, 34 | 'title is blank' 35 | ) 36 | end 37 | end 38 | 39 | context 'with an invalid abandoned_on date' do 40 | let(:params) { 41 | { 42 | todo_id: SecureRandom.uuid, 43 | title: 'Stick around', 44 | due_date: 'not a date' 45 | } 46 | } 47 | it 'raises as error' do 48 | expect { build }.to raise_error( 49 | EventSourceryTodoApp::BadRequest, 50 | 'due_date is invalid' 51 | ) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /app/projections/outstanding_todos/projector.rb: -------------------------------------------------------------------------------- 1 | module EventSourceryTodoApp 2 | module Projections 3 | module OutstandingTodos 4 | class Projector 5 | include EventSourcery::Postgres::Projector 6 | 7 | projector_name :outstanding_todos 8 | 9 | # Database tables that form the projection. 10 | 11 | table :query_outstanding_todos do 12 | column :todo_id, 'UUID NOT NULL' 13 | column :title, :text 14 | column :description, :text 15 | column :due_date, DateTime 16 | column :stakeholder_email, :text 17 | end 18 | 19 | # Event handlers that update the projection in response to different events 20 | # from the store. 21 | 22 | project TodoAdded do |event| 23 | table.insert( 24 | todo_id: event.aggregate_id, 25 | title: event.body['title'], 26 | description: event.body['description'], 27 | due_date: event.body['due_date'], 28 | stakeholder_email: event.body['stakeholder_email'], 29 | ) 30 | end 31 | 32 | project TodoAmended do |event| 33 | table.where( 34 | todo_id: event.aggregate_id, 35 | ).update( 36 | event.body.slice('title', 'description', 'due_date', 'stakeholder_email') 37 | ) 38 | end 39 | 40 | project TodoCompleted, TodoAbandoned do |event| 41 | table.where(todo_id: event.aggregate_id).delete 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/commands/todo/abandon.rb: -------------------------------------------------------------------------------- 1 | require 'app/aggregates/todo' 2 | 3 | module EventSourceryTodoApp 4 | module Commands 5 | module Todo 6 | module Abandon 7 | class Command 8 | attr_reader :payload, :aggregate_id 9 | 10 | def self.build(params) 11 | new(params).tap(&:validate) 12 | end 13 | 14 | def initialize(params) 15 | @payload = params.slice(:todo_id, :abandoned_on) 16 | @aggregate_id = payload.delete(:todo_id) 17 | end 18 | 19 | def validate 20 | raise BadRequest, 'todo_id is blank' if aggregate_id.nil? 21 | raise BadRequest, 'abandoned_on is blank' if payload[:abandoned_on].nil? 22 | begin 23 | Date.parse(payload[:abandoned_on]) if payload[:abandoned_on] 24 | rescue ArgumentError 25 | raise BadRequest, 'abandoned_on is invalid' 26 | end 27 | end 28 | end 29 | 30 | class CommandHandler 31 | def initialize(repository: EventSourceryTodoApp.repository) 32 | @repository = repository 33 | end 34 | 35 | # Handle loads the aggregate state from the store using the repository, 36 | # defers to the aggregate to execute the command, and saves off any newly 37 | # raised events to the store. 38 | def handle(command) 39 | aggregate = repository.load(Aggregates::Todo, command.aggregate_id) 40 | aggregate.abandon(command.payload) 41 | repository.save(aggregate) 42 | end 43 | 44 | private 45 | 46 | attr_reader :repository 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/commands/todo/complete.rb: -------------------------------------------------------------------------------- 1 | require 'app/aggregates/todo' 2 | 3 | module EventSourceryTodoApp 4 | module Commands 5 | module Todo 6 | module Complete 7 | class Command 8 | attr_reader :payload, :aggregate_id 9 | 10 | def self.build(params) 11 | new(params).tap(&:validate) 12 | end 13 | 14 | def initialize(params) 15 | @payload = params.slice(:todo_id, :completed_on) 16 | @aggregate_id = payload.delete(:todo_id) 17 | end 18 | 19 | def validate 20 | raise BadRequest, 'todo_id is blank' if aggregate_id.nil? 21 | raise BadRequest, 'completed_on is blank' if payload[:completed_on].nil? 22 | begin 23 | Date.parse(payload[:completed_on]) if payload[:completed_on] 24 | rescue ArgumentError 25 | raise BadRequest, 'completed_on is invalid' 26 | end 27 | end 28 | end 29 | 30 | class CommandHandler 31 | def initialize(repository: EventSourceryTodoApp.repository) 32 | @repository = repository 33 | end 34 | 35 | # Handle loads the aggregate state from the store using the repository, 36 | # defers to the aggregate to execute the command, and saves off any newly 37 | # raised events to the store. 38 | def handle(command) 39 | aggregate = repository.load(Aggregates::Todo, command.aggregate_id) 40 | aggregate.complete(command.payload) 41 | repository.save(aggregate) 42 | end 43 | 44 | private 45 | 46 | attr_reader :repository 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/projections/scheduled_todos/projector.rb: -------------------------------------------------------------------------------- 1 | module EventSourceryTodoApp 2 | module Projections 3 | module ScheduledTodos 4 | class Projector 5 | include EventSourcery::Postgres::Projector 6 | 7 | projector_name :scheduled_todos 8 | 9 | # Database tables that form the projection. 10 | 11 | table :query_scheduled_todos do 12 | column :todo_id, 'UUID NOT NULL' 13 | column :title, :text 14 | column :description, :text 15 | column :due_date, DateTime 16 | column :stakeholder_email, :text 17 | 18 | index :todo_id, unique: true 19 | index :due_date 20 | end 21 | 22 | # Event handlers that update the projection in response to different events 23 | # from the store. 24 | 25 | project TodoAdded do |event| 26 | table.insert( 27 | todo_id: event.aggregate_id, 28 | title: event.body['title'], 29 | description: event.body['description'], 30 | due_date: event.body['due_date'], 31 | stakeholder_email: event.body['stakeholder_email'], 32 | ) 33 | end 34 | 35 | project TodoAmended do |event| 36 | table.where( 37 | todo_id: event.aggregate_id, 38 | ).update( 39 | slice(event.body, 'title', 'description', 'due_date', 'stakeholder_email') 40 | ) 41 | end 42 | 43 | project TodoCompleted, TodoAbandoned do |event| 44 | table.where(todo_id: event.aggregate_id).delete 45 | end 46 | 47 | private 48 | 49 | def slice(hash, *keys) 50 | hash.select { |k, v| keys.include?(k) } 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /app/commands/todo/amend.rb: -------------------------------------------------------------------------------- 1 | require 'app/aggregates/todo' 2 | 3 | module EventSourceryTodoApp 4 | module Commands 5 | module Todo 6 | module Amend 7 | class Command 8 | attr_reader :payload, :aggregate_id 9 | 10 | def self.build(params) 11 | new(params).tap(&:validate) 12 | end 13 | 14 | def initialize(params) 15 | @payload = params.slice( 16 | :todo_id, 17 | :title, 18 | :description, 19 | :due_date, 20 | :stakeholder_email 21 | ) 22 | @aggregate_id = payload.delete(:todo_id) 23 | end 24 | 25 | def validate 26 | raise BadRequest, 'todo_id is blank' if aggregate_id.nil? 27 | begin 28 | Date.parse(payload[:due_date]) if payload[:due_date] 29 | rescue ArgumentError 30 | raise BadRequest, 'due_date is invalid' 31 | end 32 | end 33 | end 34 | 35 | class CommandHandler 36 | def initialize(repository: EventSourceryTodoApp.repository) 37 | @repository = repository 38 | end 39 | 40 | # Handle loads the aggregate state from the store using the repository, 41 | # defers to the aggregate to execute the command, and saves off any newly 42 | # raised events to the store. 43 | def handle(command) 44 | aggregate = repository.load(Aggregates::Todo, command.aggregate_id) 45 | aggregate.amend(command.payload) 46 | repository.save(aggregate) 47 | end 48 | 49 | private 50 | 51 | attr_reader :repository 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /app/commands/todo/add.rb: -------------------------------------------------------------------------------- 1 | require 'app/aggregates/todo' 2 | 3 | module EventSourceryTodoApp 4 | module Commands 5 | module Todo 6 | module Add 7 | class Command 8 | attr_reader :payload, :aggregate_id 9 | 10 | def self.build(params) 11 | new(params).tap(&:validate) 12 | end 13 | 14 | def initialize(params) 15 | @payload = params.slice( 16 | :todo_id, 17 | :title, 18 | :description, 19 | :due_date, 20 | :stakeholder_email 21 | ) 22 | @aggregate_id = payload.delete(:todo_id) 23 | end 24 | 25 | def validate 26 | raise BadRequest, 'todo_id is blank' if aggregate_id.nil? 27 | raise BadRequest, 'title is blank' if payload[:title].nil? 28 | begin 29 | Date.parse(payload[:due_date]) if payload[:due_date] 30 | rescue ArgumentError 31 | raise BadRequest, 'due_date is invalid' 32 | end 33 | end 34 | end 35 | 36 | class CommandHandler 37 | def initialize(repository: EventSourceryTodoApp.repository) 38 | @repository = repository 39 | end 40 | 41 | # Handle loads the aggregate state from the store using the repository, 42 | # defers to the aggregate to execute the command, and saves off any newly 43 | # raised events to the store. 44 | def handle(command) 45 | aggregate = repository.load(Aggregates::Todo, command.aggregate_id) 46 | aggregate.add(command.payload) 47 | repository.save(aggregate) 48 | end 49 | 50 | private 51 | 52 | attr_reader :repository 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/requests/add_todo_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'add todo', type: :request do 2 | describe 'POST /todo/:todo_id' do 3 | let(:todo_id) { SecureRandom.uuid } 4 | 5 | it 'returns success' do 6 | post_json "/todo/#{todo_id}", { 7 | title: '2000 squats', 8 | description: 'Leg day.', 9 | due_date: '2017-07-13', 10 | stakeholder_email: 'the-governator@example.com', 11 | } 12 | 13 | expect(last_response.status).to be 201 14 | expect(last_event(todo_id)).to be_a TodoAdded 15 | expect(last_event(todo_id).aggregate_id).to eq todo_id 16 | expect(last_event(todo_id).body).to eq( 17 | 'title' => '2000 squats', 18 | 'description' => 'Leg day.', 19 | 'due_date' => '2017-07-13', 20 | 'stakeholder_email' => 'the-governator@example.com', 21 | ) 22 | end 23 | 24 | context 'when the Todo already exists' do 25 | before do 26 | post_json "/todo/#{todo_id}", title: 'Bicep curls for days' 27 | end 28 | 29 | it 'returns unprocessable entity' do 30 | post_json "/todo/#{todo_id}", title: 'Get to the chopper!' 31 | 32 | expect(last_response.status).to be 422 33 | expect(last_response.body).to eq %Q{Unprocessable Entity: Todo "#{todo_id}" already exists} 34 | end 35 | end 36 | 37 | context 'with a missing title' do 38 | it 'returns bad request' do 39 | post_json "/todo/#{todo_id}" 40 | 41 | expect(last_response.status).to be 400 42 | expect(last_response.body).to eq 'Bad Request: title is blank' 43 | end 44 | end 45 | 46 | context 'with an invalid date' do 47 | it 'returns bad request' do 48 | post_json "/todo/#{todo_id}", title: "It's not a tumor", due_date: 'invalid' 49 | 50 | expect(last_response.status).to be 400 51 | expect(last_response.body).to eq 'Bad Request: due_date is invalid' 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | require 'event_sourcery' 2 | require 'event_sourcery/postgres' 3 | 4 | require 'app/utils' 5 | require 'app/events/todo_abandoned' 6 | require 'app/events/todo_added' 7 | require 'app/events/todo_amended' 8 | require 'app/events/todo_completed' 9 | require 'app/events/stakeholder_notified_of_todo_completion' 10 | require 'app/errors' 11 | require 'app/projections/completed_todos/projector' 12 | require 'app/projections/outstanding_todos/projector' 13 | require 'app/projections/scheduled_todos/projector' 14 | require 'app/reactors/todo_completed_notifier' 15 | 16 | # Configure EventSourcery and our Postgres event store. 17 | 18 | module EventSourceryTodoApp 19 | class Config 20 | attr_accessor :database_url 21 | end 22 | 23 | def self.config 24 | @config ||= Config.new 25 | end 26 | 27 | def self.configure 28 | yield config 29 | end 30 | 31 | def self.environment 32 | ENV.fetch('RACK_ENV', 'development') 33 | end 34 | 35 | def self.event_store 36 | EventSourcery::Postgres.config.event_store 37 | end 38 | 39 | def self.event_source 40 | EventSourcery::Postgres.config.event_source 41 | end 42 | 43 | def self.tracker 44 | EventSourcery::Postgres.config.event_tracker 45 | end 46 | 47 | def self.event_sink 48 | EventSourcery::Postgres.config.event_sink 49 | end 50 | 51 | def self.projections_database 52 | EventSourcery::Postgres.config.projections_database 53 | end 54 | 55 | def self.repository 56 | @repository ||= EventSourcery::Repository.new( 57 | event_source: event_source, 58 | event_sink: event_sink 59 | ) 60 | end 61 | end 62 | 63 | EventSourceryTodoApp.configure do |config| 64 | postgres_port = ENV['BOXEN_POSTGRESQL_PORT'] || 5432 65 | config.database_url = ENV['DATABASE_URL'] || "postgres://127.0.0.1:#{postgres_port}/event_sourcery_todo_app_#{EventSourceryTodoApp.environment}" 66 | end 67 | 68 | EventSourcery::Postgres.configure do |config| 69 | database = Sequel.connect(EventSourceryTodoApp.config.database_url, test: false) 70 | 71 | # NOTE: Often we choose to split our events and projections into separate 72 | # databases. For the purposes of this example we'll use one. 73 | config.event_store_database = database 74 | config.projections_database = database 75 | end 76 | -------------------------------------------------------------------------------- /spec/requests/outstanding_todos_spec.rb: -------------------------------------------------------------------------------- 1 | require 'app/projections/outstanding_todos/projector' 2 | 3 | RSpec.describe 'outstanding todos', type: :request do 4 | describe 'GET /todos/outstanding' do 5 | let(:todo_id_1) { SecureRandom.uuid } 6 | let(:todo_id_2) { SecureRandom.uuid } 7 | let(:todo_id_3) { SecureRandom.uuid } 8 | let(:todo_id_4) { SecureRandom.uuid } 9 | let(:events) do 10 | [ 11 | TodoAdded.new(aggregate_id: todo_id_1, body: { 12 | title: "I don't do requests", 13 | }), 14 | TodoAdded.new(aggregate_id: todo_id_2, body: { 15 | title: "If it's hard to remember, it will be difficult to forget", 16 | due_date: '2017-06-13', 17 | }), 18 | TodoCompleted.new(aggregate_id: todo_id_1, body: { 19 | completed_on: '2017-06-13', 20 | }), 21 | TodoAmended.new(aggregate_id: todo_id_2, body: { 22 | title: "If it's hard to remember, it...", 23 | description: "Hmm...", 24 | }), 25 | TodoAdded.new(aggregate_id: todo_id_3, body: { 26 | title: 'Milk is for babies', 27 | }), 28 | TodoAdded.new(aggregate_id: todo_id_4, body: { 29 | title: 'Your clothes, give them to me, now!', 30 | }), 31 | TodoAbandoned.new(aggregate_id: todo_id_4, body: { 32 | abandoned_on: '2017-06-01', 33 | }), 34 | ] 35 | end 36 | let(:projector) { EventSourceryTodoApp::Projections::OutstandingTodos::Projector.new } 37 | 38 | it 'returns a list of outstanding Todos' do 39 | projector.setup 40 | 41 | events.each do |event| 42 | projector.process(event) 43 | end 44 | 45 | get '/todos/outstanding' 46 | 47 | expect(last_response.status).to be 200 48 | expect(JSON.parse(last_response.body, symbolize_names: true)).to eq([ 49 | { 50 | todo_id: todo_id_2, 51 | title: "If it's hard to remember, it...", 52 | description: "Hmm...", 53 | due_date: '2017-06-13 00:00:00 UTC', 54 | stakeholder_email: nil, 55 | }, 56 | { 57 | todo_id: todo_id_3, 58 | title: 'Milk is for babies', 59 | description: nil, 60 | due_date: nil, 61 | stakeholder_email: nil, 62 | }, 63 | ]) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /app/reactors/todo_completed_notifier.rb: -------------------------------------------------------------------------------- 1 | module EventSourceryTodoApp 2 | module Reactors 3 | class TodoCompletedNotifier 4 | include EventSourcery::Postgres::Reactor 5 | 6 | SendEmail = ->(params) do 7 | puts <<~EMAIL 8 | -- Email Sent 9 | To: #{params[:email]} 10 | Message: #{params[:message]} 11 | EMAIL 12 | end 13 | 14 | processor_name :todo_completed_notifier 15 | emits_events StakeholderNotifiedOfTodoCompletion 16 | 17 | # Reactors often need to persist state so they can track what work they need 18 | # to do. Here we use a database table. 19 | table :reactor_todo_completed_notifier do 20 | column :todo_id, 'UUID NOT NULL' 21 | column :title, :text 22 | column :stakeholder_email, :text 23 | 24 | index :todo_id, unique: true 25 | end 26 | 27 | # Event handlers where we do our work. This can include updating internal, 28 | # emitting events, and/or calling external systems. 29 | 30 | process TodoAdded do |event| 31 | table.insert( 32 | todo_id: event.aggregate_id, 33 | title: event.body['title'], 34 | stakeholder_email: event.body['stakeholder_email'], 35 | ) 36 | end 37 | 38 | process TodoAmended do |event| 39 | table.where(todo_id: event.aggregate_id).update( 40 | event.body.slice('title', 'stakeholder_email'), 41 | ) 42 | end 43 | 44 | process TodoAbandoned do |event| 45 | table.where(todo_id: event.aggregate_id).delete 46 | end 47 | 48 | process TodoCompleted do |event| 49 | todo = table.where(todo_id: event.aggregate_id).first 50 | 51 | # Here we send an email to the stakeholder and record that fact using 52 | # an event in the store. 53 | unless todo[:stakeholder_email].to_s == '' 54 | SendEmail.call( 55 | email: todo[:stakeholder_email], 56 | message: "Your todo item #{todo[:title]} has been completed!", 57 | ) 58 | 59 | emit_event( 60 | StakeholderNotifiedOfTodoCompletion.new( 61 | aggregate_id: event.aggregate_id, 62 | body: { notified_on: DateTime.now.new_offset(0) } 63 | ) 64 | ) 65 | end 66 | 67 | table.where(todo_id: event.aggregate_id).delete 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/requests/completed_todos_spec.rb: -------------------------------------------------------------------------------- 1 | require 'app/projections/completed_todos/projector' 2 | 3 | RSpec.describe 'completed todos', type: :request do 4 | describe 'GET /todos/completed' do 5 | let(:todo_id_1) { SecureRandom.uuid } 6 | let(:todo_id_2) { SecureRandom.uuid } 7 | let(:todo_id_3) { SecureRandom.uuid } 8 | let(:todo_id_4) { SecureRandom.uuid } 9 | let(:events) do 10 | [ 11 | TodoAdded.new(aggregate_id: todo_id_1, body: { 12 | title: "I don't do requests", 13 | }), 14 | TodoAdded.new(aggregate_id: todo_id_2, body: { 15 | title: "If it's hard to remember, it will be difficult to forget", 16 | due_date: '2017-06-13', 17 | }), 18 | TodoCompleted.new(aggregate_id: todo_id_1, body: { 19 | completed_on: '2017-06-13', 20 | }), 21 | TodoAmended.new(aggregate_id: todo_id_2, body: { 22 | title: "If it's hard to remember, it...", 23 | description: "Hmm...", 24 | }), 25 | TodoCompleted.new(aggregate_id: todo_id_2, body: { 26 | completed_on: '2017-06-15', 27 | }), 28 | TodoAdded.new(aggregate_id: todo_id_3, body: { 29 | title: 'Milk is for babies', 30 | }), 31 | TodoAdded.new(aggregate_id: todo_id_4, body: { 32 | title: 'Your clothes, give them to me, now!', 33 | }), 34 | TodoAbandoned.new(aggregate_id: todo_id_4, body: { 35 | abandoned_on: '2017-06-01', 36 | }), 37 | ] 38 | end 39 | let(:projector) { EventSourceryTodoApp::Projections::CompletedTodos::Projector.new } 40 | 41 | it 'returns a list of completed Todos' do 42 | projector.setup 43 | 44 | events.each do |event| 45 | projector.process(event) 46 | end 47 | 48 | get '/todos/completed' 49 | 50 | expect(last_response.status).to be 200 51 | expect(JSON.parse(last_response.body, symbolize_names: true)).to eq([ 52 | { 53 | todo_id: todo_id_1, 54 | title: "I don't do requests", 55 | description: nil, 56 | due_date: nil, 57 | stakeholder_email: nil, 58 | completed_on: '2017-06-13 00:00:00 UTC', 59 | }, 60 | { 61 | todo_id: todo_id_2, 62 | title: "If it's hard to remember, it...", 63 | description: "Hmm...", 64 | due_date: '2017-06-13 00:00:00 UTC', 65 | stakeholder_email: nil, 66 | completed_on: '2017-06-15 00:00:00 UTC', 67 | }, 68 | ]) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /app/projections/completed_todos/projector.rb: -------------------------------------------------------------------------------- 1 | module EventSourceryTodoApp 2 | module Projections 3 | module CompletedTodos 4 | class Projector 5 | include EventSourcery::Postgres::Projector 6 | 7 | projector_name :completed_todos 8 | 9 | # Database tables that form the projection. 10 | 11 | table :query_completed_todos do 12 | column :todo_id, 'UUID NOT NULL' 13 | column :title, :text 14 | column :description, :text 15 | column :due_date, DateTime 16 | column :stakeholder_email, :text 17 | column :completed_on, DateTime 18 | end 19 | 20 | table :query_completed_todos_incomplete_todos do 21 | column :todo_id, 'UUID NOT NULL' 22 | column :title, :text 23 | column :description, :text 24 | column :due_date, DateTime 25 | column :stakeholder_email, :text 26 | end 27 | 28 | # Event handlers that update the projection in response to different events 29 | # from the store. 30 | 31 | project TodoAdded do |event| 32 | table(:query_completed_todos_incomplete_todos).insert( 33 | todo_id: event.aggregate_id, 34 | title: event.body['title'], 35 | description: event.body['description'], 36 | due_date: event.body['due_date'], 37 | stakeholder_email: event.body['stakeholder_email'], 38 | ) 39 | end 40 | 41 | project TodoAmended do |event| 42 | table(:query_completed_todos_incomplete_todos).where( 43 | todo_id: event.aggregate_id, 44 | ).update( 45 | event.body.slice('title', 'description', 'due_date', 'stakeholder_email') 46 | ) 47 | end 48 | 49 | project TodoAbandoned do |event| 50 | table(:query_completed_todos_incomplete_todos).where(todo_id: event.aggregate_id).delete 51 | end 52 | 53 | project TodoCompleted do |event| 54 | todo = table(:query_completed_todos_incomplete_todos).where(todo_id: event.aggregate_id).first 55 | 56 | table(:query_completed_todos).insert( 57 | todo_id: event.aggregate_id, 58 | title: todo[:title], 59 | description: todo[:description], 60 | due_date: todo[:due_date], 61 | stakeholder_email: todo[:stakeholder_email], 62 | completed_on: event.body['completed_on'], 63 | ) 64 | 65 | table(:query_completed_todos_incomplete_todos).where(todo_id: event.aggregate_id).delete 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/requests/amend_todo_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'amend todo', type: :request do 2 | describe 'PUT /todo/:todo_id' do 3 | let(:todo_id) { SecureRandom.uuid } 4 | 5 | context 'when updating an attribute' do 6 | before do 7 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id, body: { 8 | title: '2000 squats', 9 | description: 'Leg day.', 10 | due_date: '2017-07-13', 11 | stakeholder_email: 'the-governator@example.com', 12 | }) 13 | end 14 | 15 | it 'returns success' do 16 | put_json "/todo/#{todo_id}", { 17 | description: 'It IS leg day!', 18 | } 19 | 20 | expect(last_response.status).to be 200 21 | expect(last_event(todo_id)).to be_a TodoAmended 22 | expect(last_event(todo_id).aggregate_id).to eq todo_id 23 | expect(last_event(todo_id).body).to eq( 24 | 'description' => 'It IS leg day!', 25 | ) 26 | end 27 | end 28 | 29 | context 'with an invalid date' do 30 | it 'returns bad request' do 31 | put_json "/todo/#{todo_id}", due_date: 'invalid' 32 | 33 | expect(last_response.status).to be 400 34 | expect(last_response.body).to eq 'Bad Request: due_date is invalid' 35 | end 36 | end 37 | 38 | context 'when the Todo does not exist' do 39 | it 'returns unprocessable entity' do 40 | put_json "/todo/#{todo_id}" 41 | 42 | expect(last_response.status).to be 422 43 | expect(last_response.body).to eq %Q{Unprocessable Entity: Todo "#{todo_id}" does not exist} 44 | end 45 | end 46 | 47 | context 'when the Todo is already complete' do 48 | before do 49 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 50 | EventSourceryTodoApp.event_sink.sink TodoCompleted.new(aggregate_id: todo_id) 51 | end 52 | 53 | it 'returns unprocessable entity' do 54 | put_json "/todo/#{todo_id}" 55 | 56 | expect(last_response.status).to be 422 57 | expect(last_response.body).to eq %Q{Unprocessable Entity: Todo "#{todo_id}" is complete} 58 | end 59 | end 60 | 61 | context 'when the Todo is already abandoned' do 62 | before do 63 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 64 | EventSourceryTodoApp.event_sink.sink TodoAbandoned.new(aggregate_id: todo_id) 65 | end 66 | 67 | it 'returns unprocessable entity' do 68 | put_json "/todo/#{todo_id}" 69 | 70 | expect(last_response.status).to be 422 71 | expect(last_response.body).to eq %Q{Unprocessable Entity: Todo "#{todo_id}" is abandoned} 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /app/aggregates/todo.rb: -------------------------------------------------------------------------------- 1 | module EventSourceryTodoApp 2 | module Aggregates 3 | class Todo 4 | include EventSourcery::AggregateRoot 5 | 6 | # These apply methods are the hook that this aggregate uses to update 7 | # its internal state from events. 8 | 9 | apply TodoAdded do |event| 10 | # We track the ID when a todo is added so we can ensure the same todo isn't 11 | # added twice. 12 | # 13 | # We can save more attributes off the event in here as necessary. 14 | @aggregate_id = event.aggregate_id 15 | end 16 | 17 | apply TodoAmended do |event| 18 | end 19 | 20 | apply TodoCompleted do |event| 21 | @completed = true 22 | end 23 | 24 | apply TodoAbandoned do |event| 25 | @abandoned = true 26 | end 27 | 28 | apply StakeholderNotifiedOfTodoCompletion do |event| 29 | end 30 | 31 | def add(payload) 32 | raise UnprocessableEntity, "Todo #{id.inspect} already exists" if added? 33 | 34 | apply_event(TodoAdded, 35 | aggregate_id: id, 36 | body: payload, 37 | ) 38 | end 39 | 40 | # The methods below are how this aggregate handles different commands. 41 | # Note how they raise new events to indicate the change in state. 42 | 43 | def amend(payload) 44 | raise UnprocessableEntity, "Todo #{id.inspect} does not exist" unless added? 45 | raise UnprocessableEntity, "Todo #{id.inspect} is complete" if completed 46 | raise UnprocessableEntity, "Todo #{id.inspect} is abandoned" if abandoned 47 | 48 | apply_event(TodoAmended, 49 | aggregate_id: id, 50 | body: payload, 51 | ) 52 | end 53 | 54 | def complete(payload) 55 | raise UnprocessableEntity, "Todo #{id.inspect} does not exist" unless added? 56 | raise UnprocessableEntity, "Todo #{id.inspect} already complete" if completed 57 | raise UnprocessableEntity, "Todo #{id.inspect} already abandoned" if abandoned 58 | 59 | apply_event(TodoCompleted, 60 | aggregate_id: id, 61 | body: payload, 62 | ) 63 | end 64 | 65 | def abandon(payload) 66 | raise UnprocessableEntity, "Todo #{id.inspect} does not exist" unless added? 67 | raise UnprocessableEntity, "Todo #{id.inspect} already complete" if completed 68 | raise UnprocessableEntity, "Todo #{id.inspect} already abandoned" if abandoned 69 | 70 | apply_event(TodoAbandoned, 71 | aggregate_id: id, 72 | body: payload, 73 | ) 74 | end 75 | 76 | private 77 | 78 | def added? 79 | @aggregate_id 80 | end 81 | 82 | attr_reader :completed, :abandoned 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/delonnewman/shotgun.git 3 | revision: 600b3987db7f4774e2305247ff374327f1837857 4 | specs: 5 | shotgun (0.9.2) 6 | rack (>= 1.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | base64 (0.3.0) 12 | better_errors (2.10.1) 13 | erubi (>= 1.0.0) 14 | rack (>= 0.9.0) 15 | rouge (>= 1.0.0) 16 | bigdecimal (3.3.1) 17 | byebug (12.0.0) 18 | coderay (1.1.3) 19 | commander (5.0.0) 20 | highline (~> 3.0.0) 21 | database_cleaner-core (2.0.1) 22 | database_cleaner-sequel (2.0.2) 23 | database_cleaner-core (~> 2.0.0) 24 | sequel 25 | diff-lcs (1.6.2) 26 | erubi (1.13.1) 27 | event_sourcery (1.0.0) 28 | event_sourcery-postgres (0.9.1) 29 | event_sourcery (>= 0.14.0) 30 | pg 31 | sequel (>= 4.38) 32 | highline (3.0.1) 33 | logger (1.7.0) 34 | method_source (1.1.0) 35 | mustermann (3.0.4) 36 | ruby2_keywords (~> 0.0.1) 37 | nio4r (2.7.5) 38 | pg (1.5.4) 39 | pry (0.15.2) 40 | coderay (~> 1.1) 41 | method_source (~> 1.0) 42 | pry-byebug (3.11.0) 43 | byebug (~> 12.0) 44 | pry (>= 0.13, < 0.16) 45 | puma (7.1.0) 46 | nio4r (~> 2.0) 47 | rack (3.2.4) 48 | rack-protection (4.2.1) 49 | base64 (>= 0.1.0) 50 | logger (>= 1.6.0) 51 | rack (>= 3.0.0, < 4) 52 | rack-session (2.1.1) 53 | base64 (>= 0.1.0) 54 | rack (>= 3.0.0) 55 | rack-test (2.2.0) 56 | rack (>= 1.3) 57 | rake (13.3.1) 58 | rouge (4.6.1) 59 | rspec (3.13.2) 60 | rspec-core (~> 3.13.0) 61 | rspec-expectations (~> 3.13.0) 62 | rspec-mocks (~> 3.13.0) 63 | rspec-core (3.13.6) 64 | rspec-support (~> 3.13.0) 65 | rspec-expectations (3.13.5) 66 | diff-lcs (>= 1.2.0, < 2.0) 67 | rspec-support (~> 3.13.0) 68 | rspec-mocks (3.13.7) 69 | diff-lcs (>= 1.2.0, < 2.0) 70 | rspec-support (~> 3.13.0) 71 | rspec-support (3.13.6) 72 | ruby2_keywords (0.0.5) 73 | sequel (5.98.0) 74 | bigdecimal 75 | sinatra (4.2.1) 76 | logger (>= 1.6.0) 77 | mustermann (~> 3.0) 78 | rack (>= 3.0.0, < 4) 79 | rack-protection (= 4.2.1) 80 | rack-session (>= 2.0.0, < 3) 81 | tilt (~> 2.0) 82 | tilt (2.6.1) 83 | 84 | PLATFORMS 85 | aarch64-linux 86 | arm64-darwin 87 | ruby 88 | x86_64-darwin 89 | x86_64-linux 90 | 91 | DEPENDENCIES 92 | better_errors 93 | commander 94 | database_cleaner-sequel 95 | event_sourcery 96 | event_sourcery-postgres 97 | pg (= 1.5.4) 98 | pry-byebug 99 | puma 100 | rack-test 101 | rake 102 | rspec 103 | shotgun! 104 | sinatra 105 | 106 | BUNDLED WITH 107 | 2.6.8 108 | -------------------------------------------------------------------------------- /app/web/server.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'json' 3 | 4 | require 'app/commands/todo/abandon' 5 | require 'app/commands/todo/add' 6 | require 'app/commands/todo/amend' 7 | require 'app/commands/todo/complete' 8 | require 'app/projections/completed_todos/query' 9 | require 'app/projections/outstanding_todos/query' 10 | require 'app/projections/scheduled_todos/query' 11 | 12 | module EventSourceryTodoApp 13 | class Server < Sinatra::Base 14 | # Ensure our error handlers are triggered in development 15 | set :show_exceptions, :after_handler 16 | 17 | configure :development do 18 | require 'better_errors' 19 | use BetterErrors::Middleware 20 | BetterErrors.application_root = __dir__ 21 | end 22 | 23 | error UnprocessableEntity do |error| 24 | body "Unprocessable Entity: #{error.message}" 25 | status 422 26 | end 27 | 28 | error BadRequest do |error| 29 | body "Bad Request: #{error.message}" 30 | status 400 31 | end 32 | 33 | before do 34 | content_type :json 35 | end 36 | 37 | def json_params 38 | # Coerce this into a symbolised Hash so Sintra data structures 39 | # don't leak into the command layer. 40 | Hash[ 41 | params.merge( 42 | JSON.parse(request.body.read) 43 | ).map{ |k, v| [k.to_sym, v] } 44 | ] 45 | end 46 | 47 | # Commands 48 | 49 | post '/todo/:todo_id' do 50 | command = Commands::Todo::Add::Command.build(json_params) 51 | Commands::Todo::Add::CommandHandler.new.handle(command) 52 | status 201 53 | end 54 | 55 | put '/todo/:todo_id' do 56 | command = Commands::Todo::Amend::Command.build(json_params) 57 | Commands::Todo::Amend::CommandHandler.new.handle(command) 58 | status 200 59 | end 60 | 61 | post '/todo/:todo_id/complete' do 62 | command = Commands::Todo::Complete::Command.build(json_params) 63 | Commands::Todo::Complete::CommandHandler.new.handle(command) 64 | status 200 65 | end 66 | 67 | post '/todo/:todo_id/abandon' do 68 | command = Commands::Todo::Abandon::Command.build(json_params) 69 | Commands::Todo::Abandon::CommandHandler.new.handle(command) 70 | status 200 71 | end 72 | 73 | # Queries 74 | 75 | get '/todos/outstanding' do 76 | body JSON.pretty_generate( 77 | EventSourceryTodoApp::Projections::Outstanding::Query.handle 78 | ) 79 | status 200 80 | end 81 | 82 | get '/todos/scheduled' do 83 | body JSON.pretty_generate( 84 | EventSourceryTodoApp::Projections::Scheduled::Query.handle 85 | ) 86 | status 200 87 | end 88 | 89 | get '/todos/completed' do 90 | body JSON.pretty_generate( 91 | EventSourceryTodoApp::Projections::CompletedTodos::Query.handle 92 | ) 93 | status 200 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift '.' 2 | 3 | # Create our application's ESPs 4 | def processors(db_connection, tracker) 5 | [ 6 | EventSourceryTodoApp::Projections::CompletedTodos::Projector.new( 7 | tracker: tracker, 8 | db_connection: db_connection, 9 | ), 10 | EventSourceryTodoApp::Projections::OutstandingTodos::Projector.new( 11 | tracker: tracker, 12 | db_connection: db_connection, 13 | ), 14 | EventSourceryTodoApp::Projections::ScheduledTodos::Projector.new( 15 | tracker: tracker, 16 | db_connection: db_connection, 17 | ), 18 | EventSourceryTodoApp::Reactors::TodoCompletedNotifier.new( 19 | tracker: tracker, 20 | db_connection: db_connection, 21 | ) 22 | ] 23 | end 24 | 25 | task :environment do 26 | require 'config/environment' 27 | end 28 | 29 | desc "Loads the project and starts Pry" 30 | task console: :environment do 31 | require 'pry' 32 | Pry.start 33 | end 34 | 35 | 36 | desc 'Setup Event Stream Processors' 37 | task setup_processors: :environment do 38 | puts "Setting up Event Stream processors" 39 | 40 | processors(EventSourceryTodoApp.projections_database, EventSourceryTodoApp.tracker).each(&:setup) 41 | end 42 | 43 | desc 'Run Event Stream Processors' 44 | task run_processors: :environment do 45 | puts "Starting Event Stream processors" 46 | 47 | # Need to disconnect before starting the processors so 48 | # that the forked processes have their own connection / fork safety. 49 | EventSourceryTodoApp.projections_database.disconnect 50 | 51 | # Show our ESP logs immediately under Foreman 52 | $stdout.sync = true 53 | 54 | esps = processors(EventSourceryTodoApp.projections_database, EventSourceryTodoApp.tracker) 55 | 56 | # The ESPRunner will fork child processes for each of the ESPs passed to it. 57 | EventSourcery::EventProcessing::ESPRunner.new( 58 | event_processors: esps, 59 | event_source: EventSourceryTodoApp.event_source, 60 | ).start! 61 | end 62 | 63 | namespace :db do 64 | desc 'Create database' 65 | task create: :environment do 66 | url = EventSourceryTodoApp.config.database_url 67 | database_name = File.basename(url) 68 | database = Sequel.connect URI.join(url, '/template1').to_s 69 | database.run(<<~DB_QUERY) 70 | CREATE DATABASE #{database_name}; 71 | DB_QUERY 72 | 73 | database.disconnect 74 | end 75 | 76 | desc 'Drop database' 77 | task drop: :environment do 78 | url = EventSourceryTodoApp.config.database_url 79 | database_name = File.basename(url) 80 | database = Sequel.connect URI.join(url, '/template1').to_s 81 | database.run("DROP DATABASE IF EXISTS #{database_name}") 82 | database.disconnect 83 | end 84 | 85 | desc 'Migrate database' 86 | task migrate: :environment do 87 | database = EventSourcery::Postgres.config.event_store_database 88 | EventSourcery::Postgres::Schema.create_event_store(db: database) 89 | end 90 | end 91 | 92 | begin 93 | require 'rspec/core/rake_task' 94 | RSpec::Core::RakeTask.new(:spec) 95 | task default: :spec 96 | rescue LoadError 97 | end 98 | -------------------------------------------------------------------------------- /spec/requests/scheduled_todos_spec.rb: -------------------------------------------------------------------------------- 1 | require 'app/projections/scheduled_todos/projector' 2 | 3 | RSpec.describe 'scheduled todos', type: :request do 4 | describe 'GET /todos/scheduled' do 5 | let(:todo_id_1) { SecureRandom.uuid } 6 | let(:todo_id_2) { SecureRandom.uuid } 7 | let(:todo_id_3) { SecureRandom.uuid } 8 | let(:todo_id_4) { SecureRandom.uuid } 9 | let(:todo_id_5) { SecureRandom.uuid } 10 | let(:todo_id_6) { SecureRandom.uuid } 11 | let(:events) do 12 | [ 13 | TodoAdded.new(aggregate_id: todo_id_1, body: { 14 | title: "I don't do requests", 15 | }), 16 | TodoAdded.new(aggregate_id: todo_id_2, body: { 17 | title: "If it's hard to remember, it will be difficult to forget", 18 | }), 19 | TodoCompleted.new(aggregate_id: todo_id_1, body: { 20 | completed_on: '2017-06-13', 21 | }), 22 | TodoAmended.new(aggregate_id: todo_id_2, body: { 23 | title: "If it's hard to remember, it...", 24 | description: "Hmm...", 25 | due_date: '2017-06-13', 26 | }), 27 | TodoAdded.new(aggregate_id: todo_id_3, body: { 28 | title: 'Milk is for babies', 29 | }), 30 | TodoAdded.new(aggregate_id: todo_id_4, body: { 31 | title: 'Your clothes, give them to me, now!', 32 | due_date: '2017-06-13', 33 | }), 34 | TodoAbandoned.new(aggregate_id: todo_id_4, body: { 35 | abandoned_on: '2017-06-01', 36 | }), 37 | TodoAdded.new(aggregate_id: todo_id_5, body: { 38 | title: 'Your clothes, give them to me, now!', 39 | due_date: '2017-06-18', 40 | }), 41 | TodoAmended.new(aggregate_id: todo_id_5, body: { 42 | title: 'Your clothes, give them to me, now!', 43 | due_date: nil, 44 | }), 45 | TodoAdded.new(aggregate_id: todo_id_6, body: { 46 | title: "Tell Obama he needs to do something about those skinny legs. I'm going to make him do some squats", 47 | due_date: '2017-06-22', 48 | }), 49 | ] 50 | end 51 | let(:projector) { EventSourceryTodoApp::Projections::ScheduledTodos::Projector.new } 52 | 53 | it 'returns a list of scheduled Todos' do 54 | projector.setup 55 | 56 | events.each do |event| 57 | projector.process(event) 58 | end 59 | 60 | get '/todos/scheduled' 61 | 62 | expect(last_response.status).to be 200 63 | expect(JSON.parse(last_response.body, symbolize_names: true)).to eq([ 64 | { 65 | todo_id: todo_id_2, 66 | title: "If it's hard to remember, it...", 67 | description: "Hmm...", 68 | due_date: '2017-06-13 00:00:00 UTC', 69 | stakeholder_email: nil, 70 | }, 71 | { 72 | todo_id: todo_id_6, 73 | title: "Tell Obama he needs to do something about those skinny legs. I'm going to make him do some squats", 74 | description: nil, 75 | due_date: '2017-06-22 00:00:00 UTC', 76 | stakeholder_email: nil, 77 | }, 78 | ]) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/requests/abandon_todo_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'abandon todo', type: :request do 2 | describe 'POST /todo/:todo_id/abandon' do 3 | let(:todo_id) { SecureRandom.uuid } 4 | 5 | it 'returns success' do 6 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 7 | 8 | post_json "/todo/#{todo_id}/abandon", { 9 | abandoned_on: '2017-07-13', 10 | } 11 | 12 | expect(last_response.status).to be 200 13 | expect(last_event(todo_id)).to be_a TodoAbandoned 14 | expect(last_event(todo_id).aggregate_id).to eq todo_id 15 | expect(last_event(todo_id).body).to eq( 16 | 'abandoned_on' => '2017-07-13', 17 | ) 18 | end 19 | 20 | context 'when the Todo does not exist' do 21 | it 'returns unprocessable entity' do 22 | post_json "/todo/#{todo_id}/abandon", { 23 | abandoned_on: '2017-07-13', 24 | } 25 | 26 | expect(last_response.status).to be 422 27 | expect(last_response.body).to eq %Q{Unprocessable Entity: Todo "#{todo_id}" does not exist} 28 | end 29 | end 30 | 31 | context 'when the Todo has already been completed' do 32 | before do 33 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 34 | EventSourceryTodoApp.event_sink.sink TodoCompleted.new(aggregate_id: todo_id) 35 | end 36 | 37 | it 'returns unprocessable entity' do 38 | post_json "/todo/#{todo_id}/abandon", { 39 | abandoned_on: '2017-07-14', 40 | } 41 | 42 | expect(last_response.status).to be 422 43 | expect(last_response.body).to eq %Q{Unprocessable Entity: Todo "#{todo_id}" already complete} 44 | end 45 | end 46 | 47 | context 'when the Todo has already been abandoned' do 48 | before do 49 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 50 | EventSourceryTodoApp.event_sink.sink TodoAbandoned.new(aggregate_id: todo_id) 51 | end 52 | 53 | it 'returns unprocessable entity' do 54 | post_json "/todo/#{todo_id}/abandon", { 55 | abandoned_on: '2017-07-14', 56 | } 57 | 58 | expect(last_response.status).to be 422 59 | expect(last_response.body).to eq %Q{Unprocessable Entity: Todo "#{todo_id}" already abandoned} 60 | end 61 | end 62 | 63 | context 'with a missing date' do 64 | it 'returns bad request entity' do 65 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 66 | 67 | post_json "/todo/#{todo_id}/abandon" 68 | 69 | expect(last_response.status).to be 400 70 | expect(last_response.body).to eq 'Bad Request: abandoned_on is blank' 71 | end 72 | end 73 | 74 | context 'with an invalid date' do 75 | it 'returns bad request entity' do 76 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 77 | 78 | post_json "/todo/#{todo_id}/abandon", { 79 | abandoned_on: 'invalid', 80 | } 81 | 82 | expect(last_response.status).to be 400 83 | expect(last_response.body).to eq 'Bad Request: abandoned_on is invalid' 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/requests/complete_todo_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'complete todo', type: :request do 2 | describe 'POST /todo/:todo_id/complete' do 3 | let(:todo_id) { SecureRandom.uuid } 4 | 5 | it 'returns success' do 6 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 7 | 8 | post_json "/todo/#{todo_id}/complete", { 9 | completed_on: '2017-07-13', 10 | } 11 | 12 | expect(last_response.status).to be 200 13 | expect(last_event(todo_id)).to be_a TodoCompleted 14 | expect(last_event(todo_id).aggregate_id).to eq todo_id 15 | expect(last_event(todo_id).body).to eq( 16 | 'completed_on' => '2017-07-13', 17 | ) 18 | end 19 | 20 | context 'when the Todo does not exist' do 21 | it 'returns unprocessable entity' do 22 | post_json "/todo/#{todo_id}/complete", { 23 | completed_on: '2017-07-13', 24 | } 25 | 26 | expect(last_response.status).to be 422 27 | expect(last_response.body).to eq %Q{Unprocessable Entity: Todo "#{todo_id}" does not exist} 28 | end 29 | end 30 | 31 | context 'when the Todo is already complete' do 32 | before do 33 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 34 | 35 | post_json "/todo/#{todo_id}/complete", { 36 | completed_on: '2017-07-13', 37 | } 38 | end 39 | 40 | it 'returns unprocessable entity' do 41 | post_json "/todo/#{todo_id}/complete", { 42 | completed_on: '2017-07-14', 43 | } 44 | 45 | expect(last_response.status).to be 422 46 | expect(last_response.body).to eq %Q{Unprocessable Entity: Todo "#{todo_id}" already complete} 47 | end 48 | end 49 | 50 | context 'when the Todo has been abandoned' do 51 | before do 52 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 53 | EventSourceryTodoApp.event_sink.sink TodoAbandoned.new(aggregate_id: todo_id) 54 | end 55 | 56 | it 'returns unprocessable entity' do 57 | post_json "/todo/#{todo_id}/complete", { 58 | completed_on: '2017-07-14', 59 | } 60 | 61 | expect(last_response.status).to be 422 62 | expect(last_response.body).to eq %Q{Unprocessable Entity: Todo "#{todo_id}" already abandoned} 63 | end 64 | end 65 | 66 | context 'with a missing date' do 67 | it 'returns bad request entity' do 68 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 69 | 70 | post_json "/todo/#{todo_id}/complete" 71 | 72 | expect(last_response.status).to be 400 73 | expect(last_response.body).to eq 'Bad Request: completed_on is blank' 74 | end 75 | end 76 | 77 | context 'with an invalid date' do 78 | it 'returns bad request entity' do 79 | EventSourceryTodoApp.event_sink.sink TodoAdded.new(aggregate_id: todo_id) 80 | 81 | post_json "/todo/#{todo_id}/complete", { 82 | completed_on: 'invalid', 83 | } 84 | 85 | expect(last_response.status).to be 400 86 | expect(last_response.body).to eq 'Bad Request: completed_on is invalid' 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at odindutton@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /spec/reactors/todo_completed_notifier_spec.rb: -------------------------------------------------------------------------------- 1 | require 'app/reactors/todo_completed_notifier' 2 | 3 | RSpec.describe EventSourceryTodoApp::Reactors::TodoCompletedNotifier do 4 | subject(:reactor) do 5 | described_class.new( 6 | event_source: EventSourceryTodoApp.event_source, 7 | event_sink: EventSourceryTodoApp.event_sink, 8 | db_connection: EventSourceryTodoApp.projections_database, 9 | ) 10 | end 11 | 12 | describe '#process' do 13 | subject(:process) { stream.each { |event| reactor.process(event) } } 14 | 15 | let(:todo_id) { SecureRandom.uuid } 16 | let(:current_date) { DateTime.parse('2017-06-13 00:00:00 +10:00') } 17 | let(:stream) { [] } 18 | 19 | before do 20 | reactor.setup 21 | allow(described_class::SendEmail).to receive(:call) 22 | allow(DateTime).to receive(:now).and_return(current_date) 23 | process 24 | end 25 | 26 | context 'when a todo is added' do 27 | let(:stream) do 28 | [ 29 | TodoAdded.new(aggregate_id: todo_id, body: { 30 | title: 'You are terminated!', 31 | stakeholder_email: 'the-governator@example.com', 32 | }), 33 | ] 34 | end 35 | 36 | it 'adds a row to the reactor table' do 37 | row = EventSourceryTodoApp.projections_database[:reactor_todo_completed_notifier].first 38 | expect(row).to eq( 39 | todo_id: todo_id, 40 | title: 'You are terminated!', 41 | stakeholder_email: 'the-governator@example.com', 42 | ) 43 | end 44 | end 45 | 46 | context 'when a todo is amended' do 47 | let(:stream) do 48 | [ 49 | TodoAdded.new(aggregate_id: todo_id, body: { 50 | title: 'You are terminated!', 51 | stakeholder_email: 'the-governator@example.com', 52 | }), 53 | TodoAmended.new(aggregate_id: todo_id, body: { 54 | stakeholder_email: 'the-governator@example.gov', 55 | }), 56 | ] 57 | end 58 | 59 | it 'updates the row in the reactor table' do 60 | row = EventSourceryTodoApp.projections_database[:reactor_todo_completed_notifier].first 61 | expect(row).to eq( 62 | todo_id: todo_id, 63 | title: 'You are terminated!', 64 | stakeholder_email: 'the-governator@example.gov', 65 | ) 66 | end 67 | end 68 | 69 | context 'when a todo is abandoned' do 70 | let(:stream) do 71 | [ 72 | TodoAdded.new(aggregate_id: todo_id, body: { 73 | title: 'You are terminated!', 74 | stakeholder_email: 'the-governator@example.com', 75 | }), 76 | TodoAbandoned.new(aggregate_id: todo_id), 77 | ] 78 | end 79 | 80 | it 'deletes the row from the reactor table' do 81 | row = EventSourceryTodoApp.projections_database[:reactor_todo_completed_notifier].first 82 | expect(row).to be_nil 83 | end 84 | end 85 | 86 | context 'when a todo is completed' do 87 | context 'when the todo has a stakeholder' do 88 | let(:stream) { 89 | [ 90 | TodoAdded.new(aggregate_id: todo_id, body: { 91 | title: 'You are terminated!', 92 | stakeholder_email: 'the-governator@example.com', 93 | }), 94 | TodoCompleted.new(aggregate_id: todo_id), 95 | ] 96 | } 97 | 98 | it 'sends an email' do 99 | expect(described_class::SendEmail).to have_received(:call).with( 100 | email: 'the-governator@example.com', 101 | message: 'Your todo item You are terminated! has been completed!', 102 | ) 103 | end 104 | 105 | it 'emits a StakeholderNotifiedOfTodoCompletion event' do 106 | emitted_event = EventSourceryTodoApp.event_source.get_next_from(1).first 107 | 108 | expect(emitted_event).to be_a(StakeholderNotifiedOfTodoCompletion) 109 | expect(emitted_event.body.to_h).to include('notified_on' => '2017-06-12T14:00:00+00:00') 110 | end 111 | 112 | it 'deletes the row from the reactor table' do 113 | row = EventSourceryTodoApp.projections_database[:reactor_todo_completed_notifier].first 114 | expect(row).to be_nil 115 | end 116 | end 117 | 118 | context 'when the todo does not have a stakeholder' do 119 | let(:stream) { 120 | [ 121 | TodoAdded.new(aggregate_id: todo_id, body: { 122 | title: 'You are terminated!', 123 | }), 124 | TodoCompleted.new(aggregate_id: todo_id), 125 | ] 126 | } 127 | 128 | it 'does not send an email' do 129 | expect(described_class::SendEmail).to_not have_received(:call) 130 | end 131 | 132 | it 'does not emit a StakeholderNotifiedOfTodoCompletion event' do 133 | emitted_event = EventSourceryTodoApp.event_source.get_next_from(1).first 134 | expect(emitted_event).to_not be_a(StakeholderNotifiedOfTodoCompletion) 135 | end 136 | 137 | it 'deletes the row from the reactor table' do 138 | row = EventSourceryTodoApp.projections_database[:reactor_todo_completed_notifier].first 139 | expect(row).to be_nil 140 | end 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /scripts/request: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'securerandom' 4 | require 'uri' 5 | require 'net/http' 6 | require 'json' 7 | require 'commander/import' 8 | 9 | class Request 10 | def self.call(*args) 11 | new(*args).call 12 | end 13 | 14 | def initialize(method, path, payload = {}) 15 | @method = method 16 | @path = path 17 | @payload = payload 18 | end 19 | 20 | def call 21 | response = Net::HTTP.new(uri.host, uri.port).request(request) 22 | 23 | if response.is_a?(Net::HTTPSuccess) 24 | puts response.body 25 | puts 'Success.' 26 | else 27 | $stderr.puts 28 | $stderr.puts 'Error:' 29 | $stderr.puts " #{response.body}" 30 | exit 1 31 | end 32 | end 33 | 34 | private 35 | 36 | attr_reader :method, :path, :payload 37 | 38 | def uri 39 | @uri ||= URI.parse("http://localhost:3000#{path}") 40 | end 41 | 42 | def request 43 | case method 44 | when :post 45 | Net::HTTP::Post.new(uri.path, 'Content-Type': 'application/json').tap { |r| r.body = payload.to_json } 46 | when :put 47 | Net::HTTP::Put.new(uri.path, 'Content-Type': 'application/json').tap { |r| r.body = payload.to_json } 48 | when :get 49 | Net::HTTP::Get.new(uri.path, 'Content-Type': 'application/json') 50 | end 51 | end 52 | end 53 | 54 | def slice_and_compact(hash, *keys) 55 | hash.select { |key, value| keys.include?(key) && !value.nil? } 56 | end 57 | 58 | program :name, 'Todo' 59 | program :version, '0.0.1' 60 | program :description, 'Interact with the Event Sourcery Todo Example App.' 61 | 62 | never_trace! 63 | 64 | script_name = $0 65 | 66 | command :add do |c| 67 | c.syntax = "#{script_name} add [options]" 68 | c.summary = 'Add a Todo item via the Todo web API' 69 | c.example 'Add a Todo', %Q{#{script_name} add -i 0b341422-c516-4ee4-8f3e-ef1992dfff32 -t "My task"} 70 | c.example 'Add a more complex Todo', %Q{#{script_name} add -i 0b341422-c516-4ee4-8f3e-ef1992dfff32 -t "My task" -d "My task description" -D 2017-01-01 -s stakeholder@example.com} 71 | c.example 'Add a Todo', %Q{#{script_name} add -i $(#{script_name} uuid) -t "My task"} 72 | c.option '-i ID', '--id ID', 'Todo ID' 73 | c.option '-t TITLE', '--title TITLE', 'Title' 74 | c.option '-d DESCRIPTION', '--description DESCRIPTION', 'Description' 75 | c.option '-D DUE_DATE', '--due_date DUE_DATE', 'Due date' 76 | c.option '-s STAKEHOLDER_EMAIL', '--stakeholder_email STAKEHOLDER_EMAIL', 'Stakeholder email' 77 | c.action do |args, options| 78 | todo_id = options.id 79 | unless todo_id 80 | $stderr.puts "Error: you must specify a Todo ID for the new Todo" 81 | $stderr.puts "You can generate one using `#{script_name} uuid`." 82 | exit 1 83 | end 84 | payload = slice_and_compact(options.default, :title, :description, :due_date, :stakeholder_email) 85 | puts "Adding todo [#{todo_id}]: #{payload}" 86 | Request.call(:post, "/todo/#{todo_id}", payload) 87 | end 88 | end 89 | 90 | command :amend do |c| 91 | c.syntax = "#{script_name} amend [options]" 92 | c.summary = 'Amend a Todo item via the Todo web API' 93 | c.example 'Amend the title of a Todo', %Q{#{script_name} add -i 0b341422-c516-4ee4-8f3e-ef1992dfff32 -t "My task"} 94 | c.option '-i ID', '--id ID', 'Existing Todo ID' 95 | c.option '-t TITLE', '--title TITLE', 'Title' 96 | c.option '-d DESCRIPTION', '--description DESCRIPTION', 'Description' 97 | c.option '-D DUE_DATE', '--due_date DUE_DATE', 'Due date' 98 | c.option '-s STAKEHOLDER_EMAIL', '--stakeholder_email STAKEHOLDER_EMAIL', 'Stakeholder email' 99 | c.action do |args, options| 100 | todo_id = options.id 101 | unless todo_id 102 | $stderr.puts "Error: you must specify a Todo ID to amend" 103 | exit 1 104 | end 105 | payload = slice_and_compact(options.default, :title, :description, :due_date, :stakeholder_email) 106 | puts "Amending todo [#{todo_id}]: #{payload}" 107 | Request.call(:put, "/todo/#{todo_id}", payload) 108 | end 109 | end 110 | 111 | command :abandon do |c| 112 | c.syntax = "#{script_name} abandon [options]" 113 | c.summary = 'Abandon a Todo item via the Todo web API' 114 | c.example 'Abandon a Todo', %Q{#{script_name} abandon -i 0b341422-c516-4ee4-8f3e-ef1992dfff32 -D 2017-01-01} 115 | c.option '-i ID', '--id ID', 'Existing Todo ID' 116 | c.option '-D ABANDONED_ON', '--abandoned_on ABANDONED_ON', 'Abandoned on' 117 | c.action do |args, options| 118 | todo_id = options.id 119 | payload = slice_and_compact(options.default, :abandoned_on) 120 | puts "Abandoning todo [#{todo_id}]: #{payload}" 121 | Request.call(:post, "/todo/#{todo_id}/abandon", payload) 122 | end 123 | end 124 | 125 | command :complete do |c| 126 | c.syntax = "#{script_name} complete [options]" 127 | c.summary = 'Complete a Todo item via the Todo web API' 128 | c.example 'Complete a Todo', %Q{#{script_name} complete -i 0b341422-c516-4ee4-8f3e-ef1992dfff32 -D 2017-01-01} 129 | c.option '-i ID', '--id ID', 'Existing Todo ID' 130 | c.option '-D COMPLETED_ON', '--completed_on COMPLETED_ON', 'Completed on' 131 | c.action do |args, options| 132 | todo_id = options.id 133 | payload = slice_and_compact(options.default, :completed_on) 134 | puts "Completing todo [#{todo_id}]: #{payload}" 135 | Request.call(:post, "/todo/#{todo_id}/complete", payload) 136 | end 137 | end 138 | 139 | command :list do |c| 140 | LISTS = [ 141 | 'outstanding', 142 | 'scheduled', 143 | 'completed', 144 | ] 145 | 146 | c.syntax = "#{script_name} list [options]" 147 | c.summary = 'List Todos via the Todo web API' 148 | c.example 'List outstanding Todos', %Q{#{script_name} list -l outstanding} 149 | c.option '-l LIST', '--list', "List to display: #{LISTS.join(', ')}" 150 | c.action do |args, options| 151 | list = options.list.to_s.downcase 152 | unless LISTS.include?(list) 153 | $stderr.puts "Error: you must specify which Todos to list: #{LISTS.join(', ')}" 154 | exit 1 155 | end 156 | 157 | puts "#{list.capitalize} todos" 158 | Request.call(:get, "/todos/#{list}") 159 | end 160 | end 161 | 162 | command :uuid do |c| 163 | c.syntax = "#{script_name} uuid" 164 | c.summary = 'Generate a UUID' 165 | c.example 'Generate a UUID', "#{script_name} uuid" 166 | c.action do |args, options| 167 | puts SecureRandom.uuid 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Event Sourcery Todo Example App 2 | 3 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 4 | 5 | An example event sourced/CQRS web application built using [EventSourcery](https://github.com/envato/event_sourcery) and its [Postgres event store implementation](https://github.com/envato/event_sourcery-postgres). 6 | 7 | This application is intended to illustrate concepts in EventSourcery, how they relate to each other, and how to use them in practice. 8 | 9 | ## Get started 10 | 11 | Ensure you have Postgres and Ruby 3.1 or higher installed. 12 | 13 | First you need to install the correct ruby version to work with: 14 | 15 | ```sh 16 | $ rbenv install 17 | ``` 18 | 19 | Then make sure you have postgresql running in the background: 20 | 21 | ```sh 22 | $ brew services restart postgresql 23 | ``` 24 | 25 | Then run the setup script. 26 | 27 | ```sh 28 | $ ./scripts/setup 29 | ``` 30 | 31 | Run the tests. 32 | 33 | ```sh 34 | $ bundle exec rake 35 | ``` 36 | 37 | ## Using the Application 38 | 39 | Start the web app and event stream processors via Foreman. 40 | 41 | ```sh 42 | $ foreman start 43 | ``` 44 | 45 | Then you can manage your Todos using the `request` CLI script. 46 | 47 | ```sh 48 | # Add a todo 49 | $ ./scripts/request add -i aac35923-39b4-4c39-ad5d-f79d67bb2fb2 -t "Get to the chopper" -d "It's in the trees" -s dillon@cia.gov -D 2017-01-01 50 | 51 | # Amend 52 | $ ./scripts/request amend -i aac35923-39b4-4c39-ad5d-f79d67bb2fb2 -t "Get to the chopper, NOW" 53 | 54 | # Complete 55 | $ ./scripts/request complete -i aac35923-39b4-4c39-ad5d-f79d67bb2fb2 -D 2017-01-01 56 | 57 | # Abandon 58 | $ ./scripts/request abandon -i aac35923-39b4-4c39-ad5d-f79d67bb2fb2 -D 2017-01-01 59 | 60 | # List 61 | $ ./scripts/request list -l outstanding 62 | $ ./scripts/request list -l scheduled 63 | $ ./scripts/request list -l completed 64 | ``` 65 | 66 | ## Application Structure 67 | 68 | ``` 69 | ├── app 70 | │   ├── aggregates 71 | │   │   └── todo.rb 72 | │   ├── commands 73 | │   │   └── todo 74 | │   │   ├── abandon.rb 75 | │   │   ├── add.rb 76 | │   │   ├── amend.rb 77 | │   │   └── complete.rb 78 | │   ├── errors.rb 79 | │   ├── events 80 | │   │   ├── stakeholder_notified_of_todo_completion.rb 81 | │   │   ├── todo_abandoned.rb 82 | │   │   ├── todo_added.rb 83 | │   │   ├── todo_amended.rb 84 | │   │   └── todo_completed.rb 85 | │   ├── projections 86 | │   │   ├── completed_todos 87 | │   │   │   ├── projector.rb 88 | │   │   │   └── query.rb 89 | │   │   ├── outstanding_todos 90 | │   │   │   ├── projector.rb 91 | │   │   │   └── query.rb 92 | │   │   └── scheduled_todos 93 | │   │   ├── projector.rb 94 | │   │   └── query.rb 95 | │   ├── reactors 96 | │   │   └── todo_completed_notifier.rb 97 | │   ├── utils.rb 98 | │   └── web 99 | │   └── server.rb 100 | ├── config 101 | │   └── environment.rb 102 | ``` 103 | 104 | ### Events 105 | 106 | These are our domain events. They are stored in our event store as a list of immutable facts over time. Together they form the source of truth for our application's state. 107 | 108 | - `TodoAdded` 109 | - `TodoCompleted` 110 | - `TodoAbandoned` 111 | - `TodoAmended` 112 | - `StakeholderNotifiedOfTodoCompletion` 113 | 114 | A `Todo` can have the following attributes: 115 | 116 | - title 117 | - description 118 | - due_date 119 | - stakeholder_email 120 | 121 | ### Commands 122 | 123 | The set of command handlers and commands that can be issued against the system. These form an interface between the web API and the domain model in the aggregate. 124 | 125 | ### Aggregates 126 | 127 | The domain is modeled via aggregates. In this application we only have one aggregate root: `Todo`. It loads its state from the event store (via the `repository`), executes commands, and raises new events which are saved back to the store (again via the `repository`). 128 | 129 | ### Projections 130 | 131 | You can think of projections as read-only models. They are created and updated by projectors and in this case show different current state views over the events that are the source of truth for our application state. 132 | 133 | - `OutstandingTodos` 134 | - `CompletedTodos` 135 | - `ScheduledTodos` (has due date) 136 | 137 | ### Reactors 138 | 139 | Reactors listen for events and take some action. Often these actions will involve emitting other events into the store. Sometimes it may involve triggering side effects in external systems. 140 | 141 | Reactors can be used to build [process managers or sagas](https://msdn.microsoft.com/en-us/library/jj591569.aspx). 142 | 143 | - `TodoCompletedNotifier` 144 | - "sends" an email notifying stakeholders of todo completion. 145 | - Emits `StakeholderNotifiedOfTodoCompletion` event to record this fact. 146 | 147 | ## Data flow of an "Add Todo" Request 148 | 149 | Below we see the flow of data of an "add todo" request. Note that arrows indicate data flow. 150 | 151 | Note that stage 1 and 2 are not synchronous. This means EventSourcery applications need to embrace [eventual consistency](https://en.wikipedia.org/wiki/Eventual_consistency). 152 | 153 | Also note that we are only showing one projection below. The other projectors and reactors will also update their projections based on the TodoAdded event. 154 | 155 | ``` 156 | 157 | 1. Add Todo │ 2. Update Outstanding │ 3. Issue Outstanding 158 | Todos Projection Todos Query 159 | │ │ │ 160 | ▼ ▲ 161 | ┌───────────────┐ │ │ │ 162 | │Command Handler│ │ 163 | └───────────────┘ │ │ F. Handle 164 | │ Query 165 | B. Call add todo on │ │ │ 166 | aggregate │ 167 | │ │ │ │ 168 | ▼ ┌─────────────┐ │ 169 | ┌─────────────┐ │ │ Outstanding │ │ ┌─────────────┐ 170 | │ │ ┌───────▶│ Todos │ │ │ 171 | ┌─▶│ Aggregate │ │ │ │ Projector │ │ │Query Handler│ 172 | │ │ │ │ └─────────────┘ │ │ 173 | │ └─────────────┘ │ D. Read │ │ └─────────────┘ 174 | │ │ event E. Update ▲ 175 | A. Load C. Save new │ │ Projection │ │ 176 | state from event │ │ G. Read 177 | events │ │ │ │ │ Outstanding Todos 178 | │ ▼ │ ▼ Projection 179 | │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ 180 | │ │ │ │ │ Outstanding │ │ 181 | └──│ Event Store │───┼───┘ │ Todos │ │ │ 182 | │ │ │ Database │────────────────────┘ 183 | └─────────────┘ │ │ Table │ │ 184 | └─────────────┘ 185 | │ │ 186 | 187 | ``` 188 | 189 | ## Routes 190 | 191 | The application exposes a web UI with the following API. 192 | 193 | ``` 194 | GET /todos/outstanding 195 | GET /todos/completed 196 | GET /todos/scheduled 197 | POST /todo/:id (add) 198 | PUT /todo/:id (amend) 199 | POST /todo/:id/complete 200 | POST /todo/:id/abandon 201 | ``` 202 | --------------------------------------------------------------------------------