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

Dynamic Consistency Boundary (on PostgreSQL)

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for Paweł Pacana Paweł Pacana
June 02, 2026
10

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. ?