From 50497037f48f8be4b772d051a524942c859bc295 Mon Sep 17 00:00:00 2001 From: Benjamin Roth Date: Mon, 16 Mar 2026 10:39:57 +0100 Subject: [PATCH 1/2] v 0.2.0 --- .github/workflows/lint.yml | 8 +- .github/workflows/rspec.yml | 4 +- .github/workflows/sorbet.yml | 4 +- Gemfile | 2 +- Gemfile.lock | 4 +- LICENSE | 21 -- README.md | 437 ++++++++++------------- changelog.md | 35 +- changeset.gemspec | 9 +- lib/changeset.rb | 42 ++- lib/changeset/async_changeset.rb | 28 -- lib/changeset/configuration.rb | 3 +- lib/changeset/db_operation_collection.rb | 18 +- lib/changeset/errors.rb | 6 + lib/changeset/event_collection.rb | 18 +- lib/changeset/null_event_catalog.rb | 2 + lib/changeset/version.rb | 2 +- rbi/changeset.rbi | 54 ++- spec/changeset/dispatch_spec.rb | 80 +++++ spec/changeset/error_handling_spec.rb | 77 ++++ spec/changeset/guards_spec.rb | 115 ++++++ spec/changeset/merge_child_async_spec.rb | 133 ------- 22 files changed, 575 insertions(+), 527 deletions(-) delete mode 100644 LICENSE delete mode 100644 lib/changeset/async_changeset.rb create mode 100644 spec/changeset/dispatch_spec.rb create mode 100644 spec/changeset/error_handling_spec.rb create mode 100644 spec/changeset/guards_spec.rb delete mode 100644 spec/changeset/merge_child_async_spec.rb diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7000046..5432f53 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,13 +6,13 @@ on: - '**' jobs: - tests: - name: Sorbet + lint: + name: StandardRB runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: - ruby-version: 3.1 + ruby-version: 3.2 - run: bundle install --jobs 4 --retry 3 - run: bundle exec standardrb diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index b5d60b3..a251b87 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -11,9 +11,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['3.2', '3.1', '3.0'] + ruby-version: ['3.3', '3.2', '3.1'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} diff --git a/.github/workflows/sorbet.yml b/.github/workflows/sorbet.yml index dfa911b..4bb0e43 100644 --- a/.github/workflows/sorbet.yml +++ b/.github/workflows/sorbet.yml @@ -10,9 +10,9 @@ jobs: name: Sorbet runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: - ruby-version: 3.1 + ruby-version: 3.2 - run: bundle install --jobs 4 --retry 3 - run: bundle exec srb tc diff --git a/Gemfile b/Gemfile index fc13f58..c643764 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } -# Declare your gem's dependencies in flow.gemspec. +# Declare your gem's dependencies in changeset.gemspec. # Bundler will treat runtime dependencies like base dependencies, and # development dependencies will be added by default to the :development group. gemspec diff --git a/Gemfile.lock b/Gemfile.lock index 86d2845..4a21277 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ PATH remote: . specs: - changeset (0.1.4) - zeitwerk + changeset (0.2.0) + zeitwerk (>= 2.5) GEM remote: https://rubygems.org/ diff --git a/LICENSE b/LICENSE deleted file mode 100644 index caa71ee..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Benjamin Roth - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index f431e77..b3ad53f 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,64 @@ [![Combo](./doc/combo.svg)](https://combohr.com) +# Changeset + +A unit-of-work primitive for Rails: collect DB operations, collect events, execute in one transaction, dispatch events after commit. + +> **Note on naming:** This is not related to Ecto changesets (Elixir). This gem implements a unit-of-work pattern with event dispatch — it collects persistence operations and side effects, then executes them in a controlled sequence. + +--- +
- Table of Contents - +Table of Contents +1. [The Problem](#the-problem) +1. [How It Works](#how-it-works) 1. [Installation](#installation) 1. [Configuration](#configuration) -1. [Events](#events) -1. [Database Operations](#database-operations) -1. [Merging Changesets](#merging-changesets) -1. [Push!](#push) -1. [Testing](#testing-) +1. [Usage](#usage) + - [Events](#events) + - [Database Operations](#database-operations) + - [Merging Changesets](#merging-changesets) + - [Push!](#push) +1. [Real-World Patterns](#real-world-patterns) +1. [Testing](#testing) +1. [Transaction Semantics](#transaction-semantics) 1. [Sorbet](#sorbet) -1. [Example](#example) -1. [But why all these classes?](#but-why-all-these-classes)
+## The Problem -# Changeset - -The changeset contains all database operations and events of a command. +Rails service objects tend to accumulate three issues over time: -The point of the Changeset is to delay the moment you persist until the end of a chain of method calls. +**Interminable transactions.** Service A opens a transaction, calls service B which opens a nested transaction, which calls service C. The transaction scope becomes unknowable, and you're holding database locks far longer than necessary. -The main reasons are: -- use the shortest database transactions possible (holding transactions leads to many errors, nested transactions as well) -- trigger necessary events once all data is persisted (jobs fail if started before transaction ends) +**Unpredictable callbacks.** `after_save` and `after_commit` callbacks scattered across models fire in hard-to-trace order. When workflows overlap, the same callback can trigger duplicate side effects. -Whatever the way you organize your code (plain methods, service objects...), you can leverage the changesets. +**Jobs that run too early.** A background job enqueued inside a transaction can start before the transaction commits — and fail because the records don't exist yet. ---- +The changeset solves all three by separating *what to persist* from *when to persist*, and *what side effects to trigger* from *when to trigger them*. -It helped us solve complex use cases at [Combo](https://combohr.com) where some workflows overlapped. +## How It Works -We had *long running transactions*, *duplicated workers* and needed a **simple**, **testable** yet **robust** way to write our persistence layer code. +``` +1. Collect DB operations → changeset.add_db_operation(...) +2. Collect events → changeset.add_event(...) +3. Compose from sub-services → changeset.merge_child(child_changeset) +4. Execute → changeset.push! + a. All DB operations run in a single transaction + b. All events dispatch after the transaction commits +``` ## Installation ```ruby -git_source(:github) { |project| File.join("https://github.com", "#{project}.git") } gem "changeset", github: "apneadiving/changeset" ``` ## Configuration -One configuration is needed to use the gem: tell it how to use database transactions: + +Tell the gem how to wrap database transactions: ```ruby Changeset.configure do |config| @@ -56,19 +70,31 @@ Changeset.configure do |config| end ``` -## Events +This is the only required configuration. The gem does not force `requires_new: true` or any other transaction option — that's your choice in the wrapper. -They are meant to trigger only async processes: -- background jobs -- AMQP -- KAFKA -- ... +Optionally, you can detect when `push!` is called inside an already-open transaction — which defeats the purpose of the gem: -Events have to be registered in a class to be used later: +```ruby +Changeset.configure do |config| + config.db_transaction_wrapper = ->(&block) { ApplicationRecord.transaction { block.call } } + config.already_in_transaction = -> { ActiveRecord::Base.connection.open_transactions > 0 } +end +``` + +When configured, `push!` raises `Changeset::Errors::AlreadyInTransactionError` if the check returns true. This is a no-cost check (in-memory counter, no DB call). When not configured, no check runs. + +## Usage + +### Events + +Events trigger async processes (background jobs, AMQP, Kafka, etc.) after the transaction commits. + +Events must be registered in an event catalog — any object that implements `dispatch(event)` and `known_event?(event_name)`: ```ruby class EventsCatalog KNOWN_EVENTS = [:planning_updated] + def dispatch(event) send(event.name, event) end @@ -80,298 +106,223 @@ class EventsCatalog private def planning_updated(event) - # Trigger workers or any async processes. - # One event can mean many workers etc, your call. - # From here you can use event.payload + PlanningUpdatedJob.perform_async(event.payload) end end ``` -There are two ways to add events to a changeset: +Add events with a static payload (when you know all params upfront): + ```ruby -changeset = Changeset.new(EventsCatalog) -# if you know all params at the time you add the event: -changeset.add_event( - :planning_udpated, - { week: "2022W47" } -) -# if you do not know all params at the time you add the event, -# but know it will be populated once database operations are committed -changeset.add_event( - :planning_udpated, - -> { { week: some_object.week_identifier } } -) +changeset = Changeset.new(EventsCatalog.new) +changeset.add_event(:planning_updated, { week: "2022W47" }) ``` -For now there is a dedup mechanism to avoid same events to be dispatched several times. Indeed some actions may add same events in their own context and afeter traversing them all we know it is not necessary. - -The unicity is based on: -- the event catalog class name -- the name of the event -- the payload of the event +Or with a proc payload (when the payload depends on data created during the transaction): -## Database Operations +```ruby +changeset.add_event(:planning_updated, -> { { week: some_object.week_identifier } }) +``` -They are meant to be objects containing the relevant logic to call the database and commit persistence operations. -These classes must match the PersistenceInterface: respond to `call`. +Proc payloads are evaluated after DB operations commit, so they can reference newly created records. -You can create any depending on your needs: create, update, delete, bulk upsert... +**Deduplication:** Events are deduplicated by `[event_catalog_class, event_name, payload]`. If multiple services add the same event with the same payload, it dispatches once. -A very basic example is: -```ruby -class BasicPersistenceHandler - def initialize(active_record_object) - @active_record_object = active_record_object - end +### Database Operations - def call - @active_record_object.save! - end -end -``` +Any object that responds to `call` works as a DB operation: -You can then add database operations to the changeset. ```ruby -changeset = Changeset.new # notice we didnt pass an event catalog because we wont use events - -user = User.new(params) - -changeset.add_db_operation( - BasicPersistenceHandler.new(user) -) +changeset.add_db_operation(-> { user.save! }) ``` -If you do not need them to be reused, just use a lambda: -``` -user = User.new(params) +Add multiple at once: -changeset.add_db_operation( - -> { user.save! } +```ruby +changeset.add_db_operations( + -> { invoice.save! }, + -> { charge.save! } ) ``` -Database operations will then be commited in the order they were added to the changeset. - -## Merging changesets +Operations execute in the order they were added, within a single transaction. -The very point of changesets is they can be merged. +### Merging Changesets -On merge: -- parent changeset concatenates all db operations of its child -- parent changeset merges all events from its child +Changesets compose. A parent can merge any number of children: ```ruby -parent_changeset = Changeset.new(EventsCatalog) -parent_changeset - .add_db_operations( - db_operation1, - db_operation2 - ) - .add_event(:planning_updated, { week: "2022W47" }) +parent_changeset = Changeset.new(EventsCatalog.new) +parent_changeset.add_db_operation(db_operation1) -child_changeset = Changeset.new(EventsCatalog) - .add_db_operations( - db_operation3, - db_operation4 - ) +child_changeset = Changeset.new(EventsCatalog.new) +child_changeset + .add_db_operation(db_operation2) .add_event(:planning_updated, { week: "2022W47" }) - .add_event(:planning_updated, { week: "2022W48" }) parent_changeset.merge_child(child_changeset) +parent_changeset.add_db_operation(db_operation3) -parent_changeset - .add_db_operation( - db_operation5 - ) - -# - db operations will be in order 1, 2, 3, 4, 5 -# - only one planning_updated event will be dispatched with param {week: "2022W47"} -# - only one planning_updated event will be dispatched with param {week: "2022W48"} +parent_changeset.push! +# DB operations execute in order: 1, 2, 3 +# Events deduplicate and dispatch after commit ``` -## Push! +This is the core value: each service builds its own changeset, and the caller merges them. No service needs to know whether it's running inside a transaction or not. -At the end of the calls chain, it is the appropriate time to persist data and trigger events: +### Push! ```ruby changeset.push! ``` -This will: -- persist all database operations in a single transaction -- then trigger all events (outside the transaction) +This does two things in sequence: +1. Runs all DB operations in a single transaction (`commit_db_operations`) +2. Dispatches all unique events outside the transaction (`dispatch_events`) -## Testing ⚡ +A changeset can only be pushed once. Calling `push!` a second time raises `Changeset::Errors::AlreadyPushedError`. -A very convenient aspect of using changesets in you can run multiple scenarios without touching the database. +Both `commit_db_operations` and `dispatch_events` are public if you need to call them separately. Note: these bypass the double-push guard — they're escape hatches, not the normal path. -In the end you can compare the actual changeset you get against your expected one. +## Real-World Patterns -This requires to use real classes for persistence and implement `==` in these. You cannot really get procs to compare for equality. +The gem is deliberately minimal — it doesn't enforce how you structure your code. Here are patterns that have emerged in production across hundreds of files. -## Sorbet +### Services return changesets, callers push -This gem is typed with Sorbet and contains rbi definitions. +The most common pattern: services build and return a changeset, the caller decides when to push. This keeps transaction boundaries at the edges. -## Example - -Completely inspired from a discussion on Twitter you can find here: https://twitter.com/davetron5000/status/1575512016504164352 +```ruby +class Location::CreationService + def initialize(account:, params:) + @account = account + @params = params + @changeset = Changeset.new(Location::EventsCatalog.new) + end -We need to be fault tolerant in cases like below: + def call + @location = Location.new(@params) + @changeset + .add_db_operation(-> { @location.save! }) + .add_event(:location_created, -> { { id: @location.id } }) + end -```ruby -def charge(customer, amount_cents) - # These two create! calls must - # either both succeed or both fail - invoice = Invoice.create!( - customer: customer, - amount_cents: amount_cents, - ) - charge = Charge.create!( - invoice: invoice, - amount_cents: amount_cents, - ) - ChargeJob.perform_async(charge.id) + # Convenience method when the caller doesn't need the changeset + def self.run!(account:, params:) + service = new(account: account, params: params) + service.call.push! + service.location + end end + +# Caller can push directly: +Location::CreationService.run!(account: account, params: params) + +# Or merge into a larger workflow: +changeset.merge_child(Location::CreationService.new(account: account, params: params).call) ``` -It generally goes down to adding a transaction: +### One event catalog per domain + +Each bounded context defines its own catalog. When changesets from different domains merge, each event dispatches through its own catalog: ```ruby -def charge(customer, amount_cents) - ActiveRecord::Base.transaction do - invoice = Invoice.create!( - customer: customer, - amount_cents: amount_cents, - ) - charge = Charge.create!( - invoice: invoice, - amount_cents: amount_cents, - ) - end - # we can argue whether or not this should go inside the transaction... - ChargeJob.perform_async(charge.id) +class Location::EventsCatalog + KNOWN_EVENTS = [:location_created, :location_updated, :location_deleted] + # ... end + +class Membership::EventsCatalog + KNOWN_EVENTS = [:membership_created, :membership_changed] + # ... +end + +# A user creation service might compose both: +changeset = Changeset.new(User::EventsCatalog.new) +changeset + .add_event(:user_created, -> { { id: user.id } }) + .merge_child(membership_service.call) # uses Membership::EventsCatalog + .merge_child(location_config_service.call) # uses Location::EventsCatalog + .push! +# Each event dispatches through its own catalog ``` -You soon need to reuse this method in a larger context, and you now need to nest transactions: +### Persistence classes that carry state + +For complex operations, a dedicated class beats a lambda. It can encapsulate multi-step logic and expose results: ```ruby -def appointment_attended(appointment) - ActiveRecord::Base.transaction(requires_new: true) do - copay_cents = appointment.service.copay_cents - charge = charge(appointment.customer, copay_cents) +class Shift::BulkCreate::Persistence + include Changeset::PersistenceInterface - # create_insurance_claim would create yet another nested transaction - insurance_claim = create_insurance_claim(appointment, copay: charge) + def initialize(shifts:, planning:) + @shifts = shifts + @planning = planning end - # again, triggering the job here is maybe not the best option - SubmitToInsuranceJob.perform_async(insurance_claim.id) -end -def charge(customer, amount_cents) - ActiveRecord::Base.transaction(requires_new: true) do - invoice = Invoice.create!( - customer: customer, - amount_cents: amount_cents, - ) - charge = Charge.create!( - invoice: invoice, - amount_cents: amount_cents, - ) + def call + Shift.import!(@shifts) + @planning.update!(shifts_count: @planning.shifts_count + @shifts.size) end - # we can argue whether or not this should go inside the transaction... - ChargeJob.perform_async(charge.id) end -``` -As you can tell, we are putting more and more weight on the transation. -Holding a transaction takes a huge toll on your database opening the door to multiple weird errors. -The most common ones being: -- timeouts -- locking errors -- background job failing because they are unable to find database records (they can actually be trigerred before the transaction ended) +changeset.add_db_operation( + Shift::BulkCreate::Persistence.new(shifts: shifts, planning: planning) +) +``` ---- +### Chaining merge_child across services -Now with the Changeset: +Complex workflows merge changesets from multiple services. Each service is unaware of the others: ```ruby -# we need a catalog -class EventsCatalog - KNOWN_EVENTS = [:customer_charged, :insurance_claim_created] - def dispatch(event) - send(event.name, event) - end - - def known_event?(event_name) - KNOWN_EVENTS.include?(event_name) - end - - private +def appointment_attended(appointment) + changeset = Changeset.new(Appointment::EventsCatalog.new) - def customer_charged(event) - ChargeJob.perform_async(event.payload[:id]) - end + # Each service returns its own changeset with its own events + changeset + .merge_child(charge_service.call) + .merge_child(insurance_claim_service.call) + .merge_child(notification_service.call) - def insurance_claim_created(event) - SubmitToInsuranceJob.perform_async(event.payload[:id]) - end + changeset end -def appointment_attended(appointment) - Changeset.new(EventsCatalog).yield_self do |changeset| - copay_cents = appointment.service.copay_cents +# One transaction for all three services, events dispatched after +appointment_attended(appointment).push! +``` - new_charge, charge_changeset = charge(appointment.customer, copay_cents) - changeset.merge_child(charge_changeset) +## Testing - insurance_claim, insurance_claim_changeset = create_insurance_claim(appointment, copay: new_charge) - changeset.merge_child(insurance_claim_changeset) +Changesets can be compared without touching the database: - changeset.add_event( - :insurance_claim_created, - -> { { id: insurance_claim.id } } - ) - end -end +```ruby +expected = Changeset.new(EventsCatalog.new) + .add_db_operation(CreateUser.new(user)) + .add_event(:user_created, { id: 1 }) -def charge(customer, amount_cents) - Changeset.new(EventsCatalog).yield_self do |changeset| - invoice = Invoice.new( - customer: customer, - amount_cents: amount_cents - ) - charge = Charge.new( - invoice: invoice, - amount_cents: amount_cents - ) - - changeset - .add_db_operations( - -> { invoice.save! }, - -> { charge.save! } - ) - .add_event( - :customer_charged, - -> { { id: charge.id } } - ) - - [charge, changeset] - end -end +actual = my_service.call -# usage -changeset = appointment_attended(appointment) -changeset.push! +expect(actual).to eq(expected) ``` -One database transaction, workers triggered at the appropriate time. +This requires your persistence classes to implement `==`. Lambdas can't be compared for equality, so use real classes in tests. + +## Transaction Semantics + +- The `db_transaction_wrapper` you configure receives a block. All DB operations run inside that block. You control the transaction options (isolation level, `requires_new`, etc.). +- Events dispatch **after** the wrapper block returns — outside the transaction. This guarantees that background jobs can find the records they need. +- If any DB operation raises, the transaction rolls back and no events dispatch. +- DB operations execute in insertion order. Events deduplicate, then dispatch in insertion order. +- A changeset can only be pushed once — the second `push!` raises `AlreadyPushedError`. +- If `already_in_transaction` is configured and returns true, `push!` raises `AlreadyInTransactionError` before executing anything. + +## Sorbet -## But why all these classes? +This gem is typed with Sorbet and ships with RBI definitions. -I realized this kind of structure was necessary through my job at combohr.com, where we heavily use Domain Driven Design. +## Why a gem? -Because we do not use ActiveRecord within the domain (no objects, no query, no nothing), we need a way to bridge back from our own Ruby object to the persistence layer. This is where Persistence classes came into play. +The core logic is ~100 lines — you could inline it. The value isn't the implementation, it's the shared primitive. A named abstraction that the whole team reaches for beats ten ad-hoc transaction wrappers scattered across a codebase. Without it, every developer invents their own "collect stuff, run in transaction, fire jobs after" pattern. Some use `after_commit`, some nest transactions, some enqueue jobs inside transactions. The codebase drifts. With a changeset, there's one answer: build it, push it. -Anyway it is a good habit to have a facade to decouple your intent and the actual implementation. +This gem is intentionally small and stable. Low commit frequency reflects maturity, not abandonment. It has been running in production across hundreds of files at [Combo](https://combohr.com) since 2022. diff --git a/changelog.md b/changelog.md index d4cf863..6ad7bf2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,15 +1,30 @@ -# 0.1.5 -- Make commit_db_operations and dispatch_events public +# Changelog -# 0.1.4 -- Make DbOperationCollection an enumerable +## 0.2.0 +- **Breaking:** Remove `merge_child_async` and `AsyncChangeset` from the gem. This was a legacy escape hatch — if you need it, monkeypatch it in your app. +- Add double-push protection: `push!` raises `AlreadyPushedError` if called twice on the same changeset +- Add merge guards: prevent pushing a merged child, merging an already-pushed or already-merged changeset +- Add optional `already_in_transaction` config: raises `AlreadyInTransactionError` if `push!` is called inside an open transaction +- Add `pushed?` and `merged?` query methods +- Simplify `EventCollection` and `DbOperationCollection` (no more async special-casing) +- `NullEventCatalog` now includes `EventCatalogInterface` +- Fix `EventCollection#uniq_events` mutating state on read +- Fix RBI return type for `Configuration#db_transaction_wrapper` +- Require Ruby >= 3.1, zeitwerk >= 2.5 +- CI: drop Ruby 3.0 (EOL), add 3.3, upgrade to actions/checkout@v4 -# 0.1.3 +## 0.1.5 +- Make `commit_db_operations` and `dispatch_events` public + +## 0.1.4 +- Make `DbOperationCollection` an `Enumerable` + +## 0.1.3 - Add `merge_child_async` for legacy code concerns -# 0.1.2 -- Breaking change: `add_event` signature without keyword args +## 0.1.2 +- Breaking: `add_event` signature without keyword args -# 0.1.1 -- Now Db operations have to respond to `call` vs `commit`, opening the doors to simple lambdas. -- Remove constraints on events payload types +## 0.1.1 +- DB operations respond to `call` instead of `commit`, opening the door to simple lambdas +- Remove constraints on event payload types diff --git a/changeset.gemspec b/changeset.gemspec index 1206b70..e3f530c 100644 --- a/changeset.gemspec +++ b/changeset.gemspec @@ -7,11 +7,12 @@ Gem::Specification.new do |spec| spec.version = Changeset::VERSION spec.authors = ["Benjamin Roth"] spec.email = ["benjamin@rubyist.fr"] - spec.summary = "Propagate persistence and events from actions" - spec.description = "Propagate persistence and events from actions" + spec.summary = "Unit-of-work with event dispatch for Rails" + spec.description = "Collect DB operations and events, execute in one transaction, dispatch events after commit." spec.license = "MIT" + spec.required_ruby_version = ">= 3.1" - spec.files = Dir["{lib,rbi}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] + spec.files = Dir["{lib,rbi}/**/*", "MIT-LICENSE", "README.md"] - spec.add_dependency "zeitwerk" + spec.add_dependency "zeitwerk", ">= 2.5" end diff --git a/lib/changeset.rb b/lib/changeset.rb index feb49c2..1b307f2 100644 --- a/lib/changeset.rb +++ b/lib/changeset.rb @@ -9,18 +9,17 @@ def initialize(events_catalog = Changeset::NullEventCatalog.new) @events_collection = Changeset::EventCollection.new @db_operations = ::Changeset::DbOperationCollection.new @events_catalog = events_catalog + @pushed = false + @merged = false end - def merge_child(change_set) - events_collection.merge_child(change_set.events_collection) - db_operations.merge_child(change_set.db_operations) - self - end + def merge_child(child_changeset) + raise Changeset::Errors::AlreadyPushedError, "cannot merge a changeset that has already been pushed" if child_changeset.pushed? + raise Changeset::Errors::AlreadyMergedError, "cannot merge a changeset that has already been merged" if child_changeset.merged? - def merge_child_async(&changeset_wrapped_in_proc) - async_change_set = ::Changeset::AsyncChangeset.new(changeset_wrapped_in_proc) - events_collection.merge_child_async(async_change_set) - db_operations.merge_child_async(async_change_set) + events_collection.merge_child(child_changeset.events_collection) + db_operations.merge_child(child_changeset.db_operations) + child_changeset.send(:merged!) self end @@ -41,7 +40,20 @@ def add_db_operation(persistence_handler) self end + def pushed? + @pushed + end + + def merged? + @merged + end + def push! + raise Changeset::Errors::AlreadyPushedError, "this changeset has already been pushed" if @pushed + raise Changeset::Errors::AlreadyMergedError, "cannot push a changeset that has been merged into a parent" if @merged + + check_not_already_in_transaction! + @pushed = true commit_db_operations dispatch_events self @@ -75,4 +87,16 @@ def self.configure(&block) protected attr_reader :events_collection, :db_operations, :events_catalog + + private + + def merged! + @merged = true + end + + def check_not_already_in_transaction! + checker = Changeset.configuration.already_in_transaction + return unless checker + raise Changeset::Errors::AlreadyInTransactionError, "push! called inside an open transaction" if checker.call + end end diff --git a/lib/changeset/async_changeset.rb b/lib/changeset/async_changeset.rb deleted file mode 100644 index 913a100..0000000 --- a/lib/changeset/async_changeset.rb +++ /dev/null @@ -1,28 +0,0 @@ -# typed: true - -class Changeset - class AsyncChangeset - class InconsistencyError < StandardError; end - - def initialize(changeset_wrapped_in_proc) - @changeset_wrapped_in_proc = changeset_wrapped_in_proc - @called = false - end - - def db_operations - changeset.send(:db_operations) - end - - def events_collection - changeset.send(:events_collection) - end - - private - - def changeset - @changeset ||= @changeset_wrapped_in_proc.call.tap do |result| - raise InconsistencyError unless result.is_a?(::Changeset) - end - end - end -end diff --git a/lib/changeset/configuration.rb b/lib/changeset/configuration.rb index 96e0511..d81a5b8 100644 --- a/lib/changeset/configuration.rb +++ b/lib/changeset/configuration.rb @@ -2,7 +2,8 @@ class Changeset class Configuration - attr_writer :db_transaction_wrapper + attr_writer :db_transaction_wrapper, :already_in_transaction + attr_reader :already_in_transaction def db_transaction_wrapper return @db_transaction_wrapper if @db_transaction_wrapper diff --git a/lib/changeset/db_operation_collection.rb b/lib/changeset/db_operation_collection.rb index 7c39ad0..ba86768 100644 --- a/lib/changeset/db_operation_collection.rb +++ b/lib/changeset/db_operation_collection.rb @@ -18,22 +18,8 @@ def merge_child(db_operations) end end - def merge_child_async(async_change_set) - add(async_change_set) - self - end - - def each(&block) - collection.each do |element| - case element - when Changeset::AsyncChangeset - element.db_operations.each do |operation| - yield(operation) - end - else - yield(element) - end - end + def each(&) + collection.each(&) end def ==(other) diff --git a/lib/changeset/errors.rb b/lib/changeset/errors.rb index fc418d2..11cf2a8 100644 --- a/lib/changeset/errors.rb +++ b/lib/changeset/errors.rb @@ -7,5 +7,11 @@ class BaseError < StandardError; end class UnknownEventError < BaseError; end class MissingConfigurationError < BaseError; end + + class AlreadyPushedError < BaseError; end + + class AlreadyMergedError < BaseError; end + + class AlreadyInTransactionError < BaseError; end end end diff --git a/lib/changeset/event_collection.rb b/lib/changeset/event_collection.rb index 04e3f7d..7dcb2dc 100644 --- a/lib/changeset/event_collection.rb +++ b/lib/changeset/event_collection.rb @@ -4,7 +4,6 @@ class Changeset class EventCollection def initialize @grouped_events = {} - @async_change_sets = [] end def add(name:, raw_payload:, events_catalog:) @@ -20,14 +19,6 @@ def merge_child(event_collection) event_collection.all_events.each do |event| add_event(event) end - event_collection.async_change_sets.each do |async_change_set| - async_change_sets.push(async_change_set) - end - end - - def merge_child_async(async_change_set) - async_change_sets.push(async_change_set) - self end # standard:disable Style/ArgumentsForwarding @@ -42,7 +33,7 @@ def ==(other) protected - attr_reader :grouped_events, :async_change_sets + attr_reader :grouped_events # only used for merge def all_events @@ -53,14 +44,7 @@ def all_events end end - # called after push through #each def uniq_events - async_change_sets.each do |async_change_set| - async_change_set.events_collection.each do |event| - add_event(event) - end - end - [].tap do |collection| grouped_events.each_value do |events| collection.concat(events.uniq { |event| event.unicity_key }) diff --git a/lib/changeset/null_event_catalog.rb b/lib/changeset/null_event_catalog.rb index ce47145..fdecbc0 100644 --- a/lib/changeset/null_event_catalog.rb +++ b/lib/changeset/null_event_catalog.rb @@ -2,6 +2,8 @@ class Changeset class NullEventCatalog + include Changeset::EventCatalogInterface + def dispatch(event) raise "No events in NullEventCatalog" end diff --git a/lib/changeset/version.rb b/lib/changeset/version.rb index 3370406..448e998 100644 --- a/lib/changeset/version.rb +++ b/lib/changeset/version.rb @@ -1,5 +1,5 @@ # typed: strict class Changeset - VERSION = "0.1.5" + VERSION = "0.2.0" end diff --git a/rbi/changeset.rbi b/rbi/changeset.rbi index 127f02b..fe7430a 100644 --- a/rbi/changeset.rbi +++ b/rbi/changeset.rbi @@ -9,12 +9,16 @@ class Changeset def initialize(events_catalog = Changeset::NullEventCatalog.new) end - sig { params(change_set: Changeset).returns(T.self_type) } - def merge_child(change_set) + sig { returns(T::Boolean) } + def pushed? end - sig { params(changeset_wrapped_in_proc: T.proc.returns(::Changeset)).returns(T.self_type) } - def merge_child_async(&changeset_wrapped_in_proc) + sig { returns(T::Boolean) } + def merged? + end + + sig { params(child_changeset: Changeset).returns(T.self_type) } + def merge_child(child_changeset) end sig { params(name: Symbol, raw_payload: Changeset::RawEventPayload).returns(T.self_type) } @@ -55,13 +59,21 @@ class Changeset class Configuration DbTransactionWrapper = T.type_alias { T.proc.params(block: T.proc.void).void } + TransactionChecker = T.type_alias { T.proc.returns(T::Boolean) } sig { params(db_transaction_wrapper: DbTransactionWrapper).returns(DbTransactionWrapper) } attr_writer :db_transaction_wrapper - sig { returns(T.proc.void) } + sig { params(already_in_transaction: TransactionChecker).returns(TransactionChecker) } + attr_writer :already_in_transaction + + sig { returns(DbTransactionWrapper) } def db_transaction_wrapper end + + sig { returns(T.nilable(TransactionChecker)) } + def already_in_transaction + end end module EventCatalogInterface @@ -81,28 +93,14 @@ class Changeset end end - class AsyncChangeset - sig { params(changeset_wrapped_in_proc: T.proc.returns(::Changeset)).void } - def initialize(changeset_wrapped_in_proc) - end - - sig { returns(DbOperationCollection) } - def db_operations - end - - sig { returns(EventCollection) } - def events_collection - end - end - class DbOperationCollection - CollectionElement = T.type_alias { T.any(Changeset::PersistenceInterface, T.proc.void, Changeset::AsyncChangeset) } + include Enumerable sig { void } def initialize end - sig { params(persistence_handler: CollectionElement).void } + sig { params(persistence_handler: Changeset::Callable).void } def add(persistence_handler) end @@ -110,10 +108,6 @@ class Changeset def merge_child(db_operations) end - sig { params(async_change_set: Changeset::AsyncChangeset).void } - def merge_child_async(async_change_set) - end - sig { params(block: T.proc.params(arg0: Changeset::Callable).returns(BasicObject)).void } def each(&block) end @@ -124,7 +118,7 @@ class Changeset protected - sig { returns(T::Array[CollectionElement]) } + sig { returns(T::Array[Changeset::Callable]) } attr_reader :collection end @@ -143,10 +137,6 @@ class Changeset def merge_child(event_collection) end - sig { params(async_change_set: Changeset::AsyncChangeset).void } - def merge_child_async(async_change_set) - end - sig { params(block: T.proc.params(arg0: Changeset::Event).returns(BasicObject)).void } def each(&block) end @@ -159,8 +149,6 @@ class Changeset sig { returns(GroupedEvent) } attr_reader :grouped_events - sig { returns(T::Array[::Changeset::AsyncChangeset]) } - attr_reader :async_change_sets # only used for merge sig { returns(T::Array[Changeset::Event]) } @@ -239,4 +227,4 @@ class Changeset attr_reader :events_collection sig { returns(::Changeset::EventCatalogInterface) } attr_reader :events_catalog -end \ No newline at end of file +end diff --git a/spec/changeset/dispatch_spec.rb b/spec/changeset/dispatch_spec.rb new file mode 100644 index 0000000..3b016b6 --- /dev/null +++ b/spec/changeset/dispatch_spec.rb @@ -0,0 +1,80 @@ +# typed: ignore + +RSpec.describe "Changeset Dispatch", with_sorbet: false do + let(:dispatch_log) { [] } + let(:event_catalog_klass) do + log = dispatch_log + Class.new do + define_method(:dispatch) { |event| log << [event.name, event.payload] } + define_method(:known_event?) { |name| %i[event_a event_b event_c].include?(name) } + end + end + + before do + Changeset.configure do |config| + config.db_transaction_wrapper = ->(&block) { block.call } + config.already_in_transaction = nil + end + end + + describe "event dispatch order" do + it "dispatches events in insertion order after dedup" do + changeset = Changeset.new(event_catalog_klass.new) + changeset.add_event(:event_a, {order: 1}) + changeset.add_event(:event_b, {order: 2}) + changeset.add_event(:event_a, {order: 1}) # duplicate + changeset.add_event(:event_c, {order: 3}) + + changeset.push! + + expect(dispatch_log).to eq([ + [:event_a, {order: 1}], + [:event_b, {order: 2}], + [:event_c, {order: 3}] + ]) + end + end + + describe "commit_db_operations and dispatch_events independently" do + it "commit_db_operations runs operations without dispatching events" do + op = spy("op") + changeset = Changeset.new(event_catalog_klass.new) + changeset.add_db_operation(op) + changeset.add_event(:event_a, {}) + + changeset.commit_db_operations + + expect(op).to have_received(:call) + expect(dispatch_log).to be_empty + end + + it "dispatch_events dispatches without running operations" do + op = spy("op") + changeset = Changeset.new(event_catalog_klass.new) + changeset.add_db_operation(op) + changeset.add_event(:event_a, {val: 1}) + + changeset.dispatch_events + + expect(op).not_to have_received(:call) + expect(dispatch_log).to eq([[:event_a, {val: 1}]]) + end + end + + describe "db operations with lambdas" do + it "executes lambda operations in order" do + call_log = [] + changeset = Changeset.new + changeset.add_db_operation(-> { call_log << :first }) + changeset.add_db_operation(-> { call_log << :second }) + changeset.add_db_operations( + -> { call_log << :third }, + -> { call_log << :fourth } + ) + + changeset.push! + + expect(call_log).to eq([:first, :second, :third, :fourth]) + end + end +end diff --git a/spec/changeset/error_handling_spec.rb b/spec/changeset/error_handling_spec.rb new file mode 100644 index 0000000..a5f44c2 --- /dev/null +++ b/spec/changeset/error_handling_spec.rb @@ -0,0 +1,77 @@ +# typed: ignore + +RSpec.describe "Changeset Error Handling", with_sorbet: false do + before do + Changeset.configure do |config| + config.db_transaction_wrapper = ->(&block) { block.call } + config.already_in_transaction = nil + end + end + + describe "UnknownEventError" do + let(:event_catalog_klass) do + Class.new do + def dispatch(event) + end + + def known_event?(event_name) + %i[known_event].include?(event_name) + end + end + end + + it "raises when adding an unknown event" do + changeset = Changeset.new(event_catalog_klass.new) + + expect { changeset.add_event(:unknown_event, {}) }.to raise_error( + Changeset::Errors::UnknownEventError, + "unknown unknown_event" + ) + end + + it "does not raise for a known event" do + changeset = Changeset.new(event_catalog_klass.new) + + expect { changeset.add_event(:known_event, {}) }.not_to raise_error + end + end + + describe "MissingConfigurationError" do + it "raises when db_transaction_wrapper is not configured" do + # Reset configuration + Changeset.instance_variable_set(:@configuration, Changeset::Configuration.new) + + changeset = Changeset.new + changeset.add_db_operation(-> {}) + + expect { changeset.push! }.to raise_error(Changeset::Errors::MissingConfigurationError) + end + end + + describe "NullEventCatalog" do + it "rejects all events" do + changeset = Changeset.new # uses NullEventCatalog by default + + expect { changeset.add_event(:anything, {}) }.to raise_error(Changeset::Errors::UnknownEventError) + end + end + + describe "rollback on failure" do + it "does not dispatch events if a db operation raises" do + mocked_worker = spy("mocked_worker") + event_catalog_klass = Class.new do + define_method(:initialize) { |worker| @worker = worker } + define_method(:dispatch) { |event| @worker.call } + define_method(:known_event?) { |name| name == :my_event } + end + + changeset = Changeset.new(event_catalog_klass.new(mocked_worker)) + changeset + .add_db_operation(-> { raise "boom" }) + .add_event(:my_event, {}) + + expect { changeset.push! }.to raise_error("boom") + expect(mocked_worker).not_to have_received(:call) + end + end +end diff --git a/spec/changeset/guards_spec.rb b/spec/changeset/guards_spec.rb new file mode 100644 index 0000000..d705486 --- /dev/null +++ b/spec/changeset/guards_spec.rb @@ -0,0 +1,115 @@ +# typed: ignore + +RSpec.describe "Changeset Guards", with_sorbet: false do + before do + Changeset.configure do |config| + config.db_transaction_wrapper = ->(&block) { block.call } + config.already_in_transaction = nil + end + end + + describe "double push protection" do + it "raises AlreadyPushedError on second push!" do + changeset = Changeset.new + changeset.push! + + expect { changeset.push! }.to raise_error( + Changeset::Errors::AlreadyPushedError, + "this changeset has already been pushed" + ) + end + + it "raises even if the changeset is empty" do + changeset = Changeset.new + changeset.push! + + expect { changeset.push! }.to raise_error(Changeset::Errors::AlreadyPushedError) + end + + it "does not prevent pushing different changesets" do + Changeset.new.push! + + expect { Changeset.new.push! }.not_to raise_error + end + end + + describe "merge guards" do + it "prevents pushing a child that has been merged" do + parent = Changeset.new + child = Changeset.new + + parent.merge_child(child) + + expect { child.push! }.to raise_error( + Changeset::Errors::AlreadyMergedError, + "cannot push a changeset that has been merged into a parent" + ) + end + + it "prevents merging a child that has already been pushed" do + parent = Changeset.new + child = Changeset.new + child.push! + + expect { parent.merge_child(child) }.to raise_error( + Changeset::Errors::AlreadyPushedError, + "cannot merge a changeset that has already been pushed" + ) + end + + it "prevents merging a child that has already been merged" do + parent1 = Changeset.new + parent2 = Changeset.new + child = Changeset.new + + parent1.merge_child(child) + + expect { parent2.merge_child(child) }.to raise_error( + Changeset::Errors::AlreadyMergedError, + "cannot merge a changeset that has already been merged" + ) + end + + it "allows pushing the parent after merging a child" do + parent = Changeset.new + child = Changeset.new + + parent.merge_child(child) + + expect { parent.push! }.not_to raise_error + end + end + + describe "already in transaction detection" do + it "raises AlreadyInTransactionError when checker returns true" do + Changeset.configure do |config| + config.db_transaction_wrapper = ->(&block) { block.call } + config.already_in_transaction = -> { true } + end + + changeset = Changeset.new + + expect { changeset.push! }.to raise_error( + Changeset::Errors::AlreadyInTransactionError, + "push! called inside an open transaction" + ) + end + + it "does not raise when checker returns false" do + Changeset.configure do |config| + config.db_transaction_wrapper = ->(&block) { block.call } + config.already_in_transaction = -> { false } + end + + changeset = Changeset.new + + expect { changeset.push! }.not_to raise_error + end + + it "does not raise when checker is not configured" do + changeset = Changeset.new + + expect { changeset.push! }.not_to raise_error + end + end +end diff --git a/spec/changeset/merge_child_async_spec.rb b/spec/changeset/merge_child_async_spec.rb deleted file mode 100644 index 7ae6829..0000000 --- a/spec/changeset/merge_child_async_spec.rb +++ /dev/null @@ -1,133 +0,0 @@ -# typed: ignore - -RSpec.describe "Changeset Merge Child Async", with_sorbet: false do - context "check calls" do - let(:mocked_worker) { spy("mocked_worker") } - let(:mocked_worker2) { spy("mocked_worker2") } - let(:mocked_worker3) { spy("mocked_worker3") } - let(:event_catalog_klass) do - Class.new do - def initialize(mocked_worker) - @mocked_worker = mocked_worker - end - - def dispatch(event) - send(event.name, event.payload) - end - - def known_event?(event_name) - %i[my_event].include?(event_name) - end - - private - - def my_event(payload) - @mocked_worker.call(payload) - end - end - end - - let(:db_operation1) { spy("db_operation1") } - let(:db_operation2) { spy("db_operation2") } - let(:db_operation3) { spy("db_operation3") } - let(:db_operation4) { spy("db_operation4") } - let(:changeset) { ::Changeset.new(event_catalog_klass.new(mocked_worker)) } - let(:child_changeset) { ::Changeset.new(event_catalog_klass.new(mocked_worker2)) } - let(:grand_child_changeset) { ::Changeset.new(event_catalog_klass.new(mocked_worker3)) } - - before do - Changeset.configure do |config| - config.db_transaction_wrapper = ->(&block) { block.call } - end - end - - it "triggers db_operations and events" do - changeset.add_db_operations( - db_operation1, - db_operation2 - ) - changeset.add_event(:my_event, {"foo" => 1}) - - changeset.merge_child_async do - child_changeset - .add_db_operation(db_operation3) - .add_event(:my_event, {"foo" => 2}) - .merge_child_async do - grand_child_changeset - .add_db_operations(db_operation4) - .add_event(:my_event, {"foo" => 3}) - end - end - - changeset.push! - - expect(db_operation1).to have_received(:call).ordered - expect(db_operation2).to have_received(:call).ordered - expect(db_operation3).to have_received(:call).ordered - expect(db_operation4).to have_received(:call).ordered - expect(mocked_worker).to have_received(:call).with({"foo" => 1}).once.ordered - expect(mocked_worker2).to have_received(:call).with({"foo" => 2}).once.ordered - expect(mocked_worker3).to have_received(:call).with({"foo" => 3}).once.ordered - end - end - - context "check instanciation order" do - it "triggers db_operations" do - child_changeset_instantiated = false - grand_child_changeset_instantiated = false - - db_operation1_called = false - db_operation2_called = false - db_operation3_called = false - db_operation4_called = false - db_operation5_called = false - - db_operation1 = -> { - db_operation1_called = true - expect(child_changeset_instantiated).to be false - } - - db_operation2 = -> { - db_operation2_called = true - expect(grand_child_changeset_instantiated).to be false - } - - db_operation3 = -> { - db_operation3_called = true - } - - db_operation4 = -> { - db_operation4_called = true - expect(grand_child_changeset_instantiated).to be true - } - - db_operation5 = -> { - db_operation5_called = true - expect(child_changeset_instantiated).to be true - expect(grand_child_changeset_instantiated).to be true - } - - Changeset.new.add_db_operations( - db_operation1 - ).merge_child_async do - child_changeset_instantiated = true - Changeset.new - .add_db_operation(db_operation2) - .merge_child_async do - grand_child_changeset_instantiated = true - Changeset.new - .add_db_operations(db_operation3) - end - .add_db_operation(db_operation4) - end - .add_db_operation(db_operation5) - .push! - - expect(db_operation1_called).to be true - expect(db_operation2_called).to be true - expect(db_operation3_called).to be true - expect(db_operation4_called).to be true - expect(db_operation5_called).to be true - end - end -end From d6abe81d6f4d729c0d1dbdd6b1858b5b60a755fe Mon Sep 17 00:00:00 2001 From: Benjamin Roth Date: Mon, 16 Mar 2026 10:51:27 +0100 Subject: [PATCH 2/2] polishing --- README.md | 44 ++++++++++++++++++++++-- lib/changeset/db_operation_collection.rb | 6 ++-- rbi/changeset.rbi | 2 +- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b3ad53f..81724ef 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ # Changeset -A unit-of-work primitive for Rails: collect DB operations, collect events, execute in one transaction, dispatch events after commit. +A unit-of-work primitive for Rails that separates domain logic from persistence. Your services decide *what* to persist and *which events* to fire — the changeset decides *when*, in a single transaction, with events dispatched after commit. -> **Note on naming:** This is not related to Ecto changesets (Elixir). This gem implements a unit-of-work pattern with event dispatch — it collects persistence operations and side effects, then executes them in a controlled sequence. +If you're drawn to hexagonal architecture (ports and adapters) but don't want a framework, this is the minimum viable boundary: domain logic in, side effects out, one seam between the two. + +> **Note on naming:** This is not related to Ecto changesets (Elixir). This gem implements a unit-of-work pattern with event dispatch. --- @@ -21,6 +23,7 @@ A unit-of-work primitive for Rails: collect DB operations, collect events, execu - [Merging Changesets](#merging-changesets) - [Push!](#push) 1. [Real-World Patterns](#real-world-patterns) + - [Separating Reads from Writes](#separating-reads-from-writes) 1. [Testing](#testing) 1. [Transaction Semantics](#transaction-semantics) 1. [Sorbet](#sorbet) @@ -292,6 +295,43 @@ end appointment_attended(appointment).push! ``` +### Separating reads from writes + +A changeset naturally pushes your services toward a clean structure: read first, build the changeset, push at the boundary. No reads happen inside the transaction, no writes happen outside it. + +```ruby +class Appointment::AttendService + def initialize(appointment:) + @appointment = appointment + @changeset = Changeset.new(Appointment::EventsCatalog.new) + end + + def call + # 1. Read phase — queries, validations, business logic (no transaction) + charge = Charge.build_for(@appointment) + next_slot = @appointment.location.next_available_slot + raise "no availability" unless next_slot + + # 2. Build phase — collect what needs to happen (still no transaction) + @changeset + .add_db_operations( + -> { charge.save! }, + -> { @appointment.update!(status: :attended, next_slot: next_slot) } + ) + .add_event(:appointment_attended, -> { { id: @appointment.id, charge_id: charge.id } }) + + @changeset + end +end + +# 3. Push phase — single transaction, events after commit +Appointment::AttendService.new(appointment: appointment).call.push! +``` + +The transaction only wraps the writes. Reads stay outside. This keeps locks short and makes the service easy to test — you can assert on the changeset without ever calling `push!`. + +If you're familiar with hexagonal architecture (ports and adapters), the changeset is the boundary between your domain logic and your persistence/infrastructure layer. The read and build phases are pure domain — no side effects. The push phase is the adapter. The gem doesn't enforce this, but it makes it the path of least resistance. + ## Testing Changesets can be compared without touching the database: diff --git a/lib/changeset/db_operation_collection.rb b/lib/changeset/db_operation_collection.rb index ba86768..f045402 100644 --- a/lib/changeset/db_operation_collection.rb +++ b/lib/changeset/db_operation_collection.rb @@ -18,9 +18,11 @@ def merge_child(db_operations) end end - def each(&) - collection.each(&) + # standard:disable Style/ArgumentsForwarding + def each(&block) + collection.each(&block) end + # standard:enable Style/ArgumentsForwarding def ==(other) collection == other.collection diff --git a/rbi/changeset.rbi b/rbi/changeset.rbi index fe7430a..c9589a7 100644 --- a/rbi/changeset.rbi +++ b/rbi/changeset.rbi @@ -58,7 +58,7 @@ class Changeset end class Configuration - DbTransactionWrapper = T.type_alias { T.proc.params(block: T.proc.void).void } + DbTransactionWrapper = T.type_alias { T.untyped } TransactionChecker = T.type_alias { T.proc.returns(T::Boolean) } sig { params(db_transaction_wrapper: DbTransactionWrapper).returns(DbTransactionWrapper) }