Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Dynamic Consistency Boundary (on PostgreSQL)

Avatar for Paweł Pacana Paweł Pacana
June 02, 2026
3

Dynamic Consistency Boundary (on PostgreSQL)

Avatar for Paweł Pacana

Paweł Pacana

June 02, 2026

Transcript

  1. CreditToppedUp = Data.define(:amount, :account_id) CreditUsed = Data.define(:amount, :account_id) history =

    [ CreditToppedUp.new(amount: 100, account_id: 13), CreditToppedUp.new(amount: 20, account_id: 42), CreditUsed.new(amount: 10, account_id: 13), ]
  2. account_13 = history.select { |event| event.account_id == 13 } =>

    [#<data CreditToppedUp amount=100, account_id=13>, #<data CreditUsed amount=10, account_id=13>]
  3. class Account def initialize(id, balance) @id = id @balance =

    balance end def top_up_credits(amount) [CreditsToppedUp.new(amount: amount, account_id: @id)] end def use_credits(amount) return [] if amount > balance [CreditsUsed.new(amount: amount, account_id: @id)] end end
  4. account_id = 13 events, last_position = event_store.read.stream("account_#{account_id}") # => [[CreditsToppedUp.new(amount:

    100, account_id: 13)], 1] balance = project(events) # => 100 account_1 = Account.new(account_id, balance) account_2 = Account.new(account_id, balance) new_events = account_1.use_credits(100) # => [CreditsUsed.new(amount: 100, account_id: 13)] event_store.append(new_events, expected_version: last_position) event_store.read.stream("account_#{account_id}") # => [[CreditsToppedUp.new(amount: 100, account_id: 13), CreditsUsed.new(amount: 100, account_id: 13)], 2] event_store.append(account_2.use_credits(100), expected_version: last_position) # => WrongExpectedVersion
  5. CREATE TABLE events ( position bigint GENERATED ALWAYS AS IDENTITY

    PRIMARY KEY, type TEXT NOT NULL, data jsonb, id uuid NOT NULL UNIQUE );
  6. CREATE TABLE streams ( name text NOT NULL, event_id uuid

    NOT NULL REFERENCES events (id), position bigint NOT NULL, UNIQUE (name, position), UNIQUE (event_id, name) );
  7. CREATE FUNCTION append_events (p_events jsonb, p_stream_name text, p_expected_position bigint) RETURNS

    void LANGUAGE sql AS $$ -- ordered_input cut out, transforms p_events INSERT INTO events (type, data, id) SELECT type, data, id FROM ordered_input; INSERT INTO streams (name, event_id, position) SELECT p_stream_name, id, p_expected_position + ord FROM ordered_input; $$;
  8. class Account # sourced from CreditsToppedUp, CreditsUsed def initialize(id, balance)

    @id = id @balance = balance end def top_up_credits(amount) [CreditsToppedUp.new(amount: amount, account_id: @id)] end end
  9. class Account # sourced from CreditsToppedUp, CreditsUsed def initialize(id, balance)

    @id = id @balance = balance end def use_credits(amount) return [] if amount > balance [CreditsUsed.new(amount: amount, account_id: @id)] end end
  10. A course cannot accept more than 20 students 1. The

    student cannot join more than 5 courses 2.
  11. class Course def initialize(id, students) @students = students @course_id =

    id end def subscribe(student_id) if students.size < 20 [StudentSubscribedToCourse(student_id: student_id, course_id: @course_id)] else [] end end end class Student def initialize(id, courses) @courses = courses @student_id = id end def join(course_id) if courses.size < 5 [StudentSubscribedToCourse(student_id: @student_id, course_id: course_id)] else [] end end end
  12. class Subscription def initialize(subscriptions) @by_students, @by_courses = subscriptions end def

    subscribe(student_id, course_id) if @by_courses[course_id].size < 20 && @by_students[student_id].size < 5 [StudentSubscribedToCourse(student_id: student_id, course_id: course_id)] else [] end end end
  13. handle UseCredits do |cmd| scope = event_store .read .with_tag("account:#{cmd.account_id}") events,

    position = scope .of_type(%w[CreditsToppedUp CreditsUsed]) .each_with_position .reduce { |acc, (ev, pos)| [[*acc.first, ev], pos] } balance = evolve(events, initial_balance = 0) new_events = decide(cmd, balance) event_store.append( new_events, fail_if: scope.of_type(%w[CreditsUsed]).after(position) ) end
  14. 0.1 0.2 0.5 1 2 5 10 20 50 100

    200 500 1000 input size mean latency (ms) 1 10 100 1 000 10 000 100 000 stream tags error bars = ±1 stddev
  15. 0 5 10 15 20 25 30 35 40 45

    mean latency (ms) 16.57 ms stream-append 34.23 ms dcb-append 17.13 ms dcb-locked error bars = ±1 stddev | 10×100 events
  16. 0 10 20 30 40 50 60 70 80 90

    100 110 mean latency (ms) 48.29 ms stream-append 74.50 ms dcb-append 80.26 ms dcb-locked error bars = ±1 stddev | conflict scenario
  17. Read https://dcb.events https://sara.event-thinking.io/2023/04/kill- aggregate-chapter-1-I-am-here-to-kill-the- aggregate.html https://nakodach.pl/dynamic-consistency- boundary-sourcing-criteria-vs-append- criteria/ https://bettersoftwaredesign.pl/podcast/o- agregatach-eventach-i-dynamic-

    consistency-boundary-z-pawlem-pacana/ https://mostlyobvio.us/2026/04/dynamic- consistency-boundary-in-rails-event-store/ Write Ruby — https://github.com/mostlyobvious/en57 Ruby — https://github.com/ortegacmanuel/kroniko GRPC — https://umadb.io TS — https://www.boundlessdb.dev JVM — https://www.axoniq.io/server Elixir — https://www.evntd.com/fact.html GRPC — https://github.com/oexza/Orisun
  18. ?