Skip to content

Graphiti 2.0 🚀 - #539

Merged
jkeen merged 172 commits into
mainfrom
beta
Sep 1, 2026
Merged

Graphiti 2.0 🚀#539
jkeen merged 172 commits into
mainfrom
beta

Conversation

@jkeen

@jkeen jkeen commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Graphiti 2.0

Graphiti 2.0 collapses the satellite-gem constellation into one gem, makes persistence behave the way you always assumed it did, and ships with a rebuilt documentation site. There are some important breaking changes that improve ergonomics, but nearly everything from 1.x still runs under 2.0 with deprecation warnings, so most apps can upgrade incrementally. Upgrade path here: https://graphiti.dev/upgrading/

One gem instead of four

graphiti-rails, graphiti_spec_helpers, and graphiti_errors are now part of graphiti itself. Remove them from your Gemfile after upgrading graphiti and everything keeps working. Rails integration is now opt-in per controller with include Graphiti::Rails::Controller, instead of every controller silently receiving it (#535). Exception handling is built on rescue_registry (#526), and spec helpers live at Graphiti::SpecHelpers with a new set of RSpec matchers for asserting resource attributes and relationships (graphiti-api/graphiti_spec_helpers#14).

This collapse was primarily motivated by the fact that 80-90% of graphiti users are using it with rails, and collapsing the surface area makes maintenance and understanding easier.

Persistence that matches your mental model

The model you inspect is the model that saves (#465, docs). Hooks and overrides see the same assigned model instance that gets persisted, instead of a parallel attributes hash that could drift from reality. around_persistence hooks receive that model, writable guards now receive the model and attribute name so authorization can consider the actual record (#511), and the resource carries its assigned model rather than threading it through override signatures.

Writable guards evaluate under the action being performed on every path, so writable: proc { current_action == :create } answers the same whether or not you inspected the model first.

Serialization wins

  • belongs_to renders relationship resource ids by default, solving an issue that had been open since 2019 (Always include id and type for belongs_to relationships #167), with belongs_to_resource_ids_by_default and a per-relationship resource_ids option to control it. The default comes without a performance cost, since linkage renders from foreign keys already on the record.
  • Resource.wrap serializes models you fetched yourself, so you can use graphiti's serialization without going through its finders (feat: add Resource.wrap as an easy way to use graphiti serialization on models fetched via other means #513).
  • A subclass redeclaring a relationship now reaches its own serializer.
  • Sideloaded entities are deduplicated across include paths, so a record reached through two different associations renders once, complete. Previously only the first copy to serialize got its relationships, leaving "data": null holes in included (Smarter response resource squashing #476).
  • A relationship linkage no longer disappears when an unrelated include happens to share its name. ?include=positions.classification used to blank the employee's own classification.
  • Deduplication no longer widens a deep filtered association. ?include=positions.employee.positions&filter[positions.rank]=1 returned every position where the shallow path correctly returned one.

Concurrency

Concurrent sideload resolution is more reliable, with fixes for deadlocks and swallowed errors and new stress specs covering behavior under load. Request state now lives in fiber storage rather than thread locals, so context, transaction hooks and debug chunks survive into fibers a request spawns. That makes graphiti work under fiber based servers such as falcon (#540), and closes a case where a sideload resolving inside a fiber would wait on the bounded pool from a pool thread.

ActiveSupport::CurrentAttributes are carried onto pool threads, so a sideload resolving concurrently still sees Current.user and anything else your app sets per request. Previously that state was left behind on the request thread, and a resource reading it during a concurrent sideload saw nothing.

Performance

Serialization is faster and allocates less, with the biggest gains on simple requests. Every release records its object allocations so changes are visible over time, and rake performance:current reports them locally.

Guardrails and tooling

  • rake graphiti:audit scans your resources for latent issues.
  • rails g graphiti:locale writes every error code graphiti renders into a locale file, ready to customize or translate.
  • New rake tasks for schema checks, so verifying a schema no longer means running the whole suite.
  • Rendering a relationship the model doesn't define now raises MissingRelationshipMethod instead of failing obscurely.
  • The resource generator takes a --controller name, so it no longer overwrites a controller you already have (ResourceGenerator overwrites existing controller #528).
  • context_namespace is now current_action, which says what it actually is.

A documentation site that lives with the code

The docs site was rebuilt, absorbed into this repository, and now publishes automatically as new versions are cut. Runtime error messages link to the new docs, and there's a dedicated 2.0 upgrade guide. Behavior and documentation updates can ship at the same time!

Modern platform

Requires Ruby 3.2+ and Rails 7.1+. Tested against Ruby 3.2 through 4.0 and Rails 7.1 through 8.1, on a rebuilt CI matrix and release pipeline. Apps on older Rubies and Rails stay supported on the 1.x maintenance branch.

Breaking changes

The upgrade guide walks through each of these with migration steps. The short version:

  • Ruby 3.2+ and Rails 7.1+ are required.
  • Controllers serving graphiti resources must include Graphiti::Rails::Controller. Under graphiti-rails, every controller received the integration automatically (Include Graphiti stuff only in api #535).
  • around_persistence hooks receive the assigned model instead of the attributes hash (feat!: the model you inspect is the model that saves #465). Move attribute-hash edits to before_attributes, or set values on the model. Custom create/update overrides keep their 1.x signatures and need no changes.
  • include GraphitiErrors raises instead of warning, because rescue_registry replaced it and there is nothing left to point it at. GraphitiErrors.enable!/disable! becomes handle_request_exceptions, and 409 responses now report code "conflict" and title "Conflict Error".
  • belongs_to relationships render resource linkage by default, so those relationship objects gain a data key in payloads (Always include id and type for belongs_to relationships #167). Set belongs_to_resource_ids_by_default = false for 1.x output.
  • Relationships that render neither ids nor a link are omitted rather than rendered empty.
  • allow_nil and deny_empty fold into one blanks: filter option, and the pagination settings are named after the page params they control.

Everything else from 1.x resolves with a deprecation warning and keeps working until 3.0.

Closes

Related work from the satellite gems

These PRs live in the now-absorbed repos and shaped what shipped here:

jkeen and others added 30 commits July 29, 2026 16:39
BREAKING CHANGE: Ruby >= 3.0 / Rails >= 6 are now required.
Rails 5.2 cannot run on Ruby 3, so these produced no CI jobs once Ruby 2.7 was dropped. Removes the two appraise blocks and their generated gemfiles.
Creates:
  resource = MyResource.build(params)
  resource.data          # unsaved model, attributes applied
  resource.data.valid?   # inspect before committing to anything
  resource.save          # persists that same instance

Updates:
  resource = MyResource.find(params)
  resource.assign_attributes(params)
  resource.data.changed  # dirty tracking works
  resource.update

  # or in one call
  resource.update(params)

Attributes are assigned once, before the persistence hooks fire. 

BREAKING CHANGE: around_persistence hooks receive the assigned model instead of the attributes hash. Move attribute-hash modifications to before_attributes, or set values on the model. Custom create/update overrides that should receive a pre-assigned model must accept an assigned_model: keyword. See UPGRADING.md
# [2.0.0-beta.1](v1.12.2...v2.0.0-beta.1) (2026-07-30)

### Features

* drop Ruby 2.7 and Rails 5.2 support ([e905ddb](e905ddb))
* the model you inspect is the model that saves ([#465](#465)) ([a905fff](a905fff))

### BREAKING CHANGES

* around_persistence hooks receive the assigned model instead of the attributes hash. Move attribute-hash modifications to before_attributes, or set values on the model. Custom create/update overrides that should receive a pre-assigned model must accept an assigned_model: keyword. See UPGRADING.md
* Ruby >= 3.0 / Rails >= 6 are now required.
# Conflicts:
#	CHANGELOG.md
#	lib/graphiti/resource/persistence.rb
#	lib/graphiti/version.rb
# [2.0.0-beta.2](v2.0.0-beta.1...v2.0.0-beta.2) (2026-07-30)

### Features

* add Resource.wrap as an easy way to use graphiti serialization on models fetched via other means (and not via graphiti's finders) ([#513](#513)) ([fcd19e2](fcd19e2))
* deprecate mutating attributes in around_persistence hooks ([#514](#514)) [skip ci] ([8410ab0](8410ab0))
…signatures

This way create and update overrides keep their 1.x signatures and we're not breaking existing setups. This was an oversight in the original commit.
# [2.0.0-beta.3](v2.0.0-beta.2...v2.0.0-beta.3) (2026-07-31)

### Features

* carry the assigned model on the resource, not through override signatures ([8ad848d](8ad848d))
graphiti_spec_helpers gem is retired. Graphiti is its only home now, at Graphiti::SpecHelpers.

BREAKING CHANGE: remove graphiti_spec_helpers from your Gemfile. Prefer Graphiti::SpecHelpers and "graphiti/spec_helpers/rspec"; the old namespace and require paths still resolve, warn, and are removed in 3.0.
Brings over Graphiti::Rails, the generators and the rake tasks. Rails is still detected at runtime, so railties does not become a dependency. Controllers now opt in with Graphiti::Rails::Controller rather than Graphiti attaching itself to every ActionController, which graphiti-rails#52 has been open about since 2020. Exception handling is left for the rescue_registry decision.

BREAKING CHANGE: remove graphiti-rails from your Gemfile. Controllers serving Graphiti resources must `include Graphiti::Rails::Controller` — previously every controller received it whether it wanted it or not. Graphiti::Responders is now Graphiti::Rails::Responders.
Loose requirements let a newer minor satisfy them, so rails-7-1 resolved to 7.2 and rails-8-0 to 8.1. sqlite3 pins now match what each Rails adapter demands at require time, and the CI matrix uses explicit rows — an exclude naming a gemfile that does not exist is silently ignored, which had left four dead rows.
Ruby 3.0 and 3.1 are both past end of life, and 3.2 is what Rails 8 requires. Rails 6.1 and 7.0 do not support Ruby 3.2. Apps that cannot move stay on the 1.x branch.

BREAKING CHANGE: Ruby >= 3.2 and Rails >= 7.1 are now required.
graphiti-rails had moved to rescue_registry while core stayed on graphiti_errors, so both loaded in every Rails app and each registered its own handlers. Only graphiti_errors' serializers survive, as Graphiti::ErrorSerializers. ConflictRequest gets a registered handler for the first time, and reports a conflict rather than code "bad_request" beside a 409.

BREAKING CHANGE: graphiti_errors is no longer a dependency and must be removed from the Gemfile, along with any `include GraphitiErrors`.

GraphitiErrors::Validation::Serializer is now Graphiti::ErrorSerializers::Validation, and GraphitiErrors.enable!/disable! becomes handle_request_exceptions. 409 responses now report code "conflict" and title "Conflict Error".
Adds shims for names that had no replacement. Requiring "graphiti-rails", "graphiti_errors" or "graphiti/responders" raised LoadError, and `include Graphiti::Rails` did nothing at all. Both work again and warn.

Also fixes deprecation messages that pointed at gems that no longer exist.

BREAKING CHANGE: nothing removed, everything warns and goes away in 3.0. Except `include GraphitiErrors`, which now raises, as rescue_registry replaced it, so there's nothing to point it at.
Its serializers and ExceptionHandler both worked without Rails, and the guide framed the whole change as a Rails concern. The serializers move and keep working; RescueRegistry::ExceptionHandler replaces the payload builder, with the note that it needs rack required first or it raises NameError.
It was the only mixin row missing its include.
Moves the docs to within this repository so docs + changes can ship together. New docs setup is versioned, and the old 1.x docs will be archived and available via the version menu.

New docs site is built with docusaurus, and the concepts and prose have been greatly simplified and reorganized.
The docs dropped the /guides prefix, so the URLs printed by Unlinkable, SideloadParamsError, SideloadAssignError and InvalidLink all 404'd. resource_spec asserts on one of those messages verbatim, so it moves too.
A belongs_to sent only a link in 1.x, and to learn the id took a second request. That id is the foreign key already on the parent, so rendering it loads nothing extra (which was not the case with #168 which caused #185 to revert it)

A belongs_to that cannot use its foreign key still loads the association, so it stays opt-in behind always_include_resource_ids, as does has_many. That covers a scope or params block, a base_scope, a polymorphic or remote target, and a custom primary_key.

always_include_resource_ids_by_default now sets this per resource, replacing the monkey patch #167 has been recommending since 2020.

Closes #167
Overriding an inherited belongs_to had no effect on the payload. The subclass gets its own serializer class, but it inherits the parent every relationship block, and each block closes over the sideload it was built for. Application skipped any name the serializer already answered to, so the override kept rendering through the parent sideload: the wrong resource, and with linkage now derived from the foreign key, the wrong type and id.

Serializers record which sideload each generated block was built for, so a redeclared relationship is told apart from one already applied. A block the application wrote by hand has no such record and is still left alone.
The public entry points (Scope#resolve, Scope#resolve_sideloads, Sideload#resolve, PolymorphicBelongsTo#resolve) now share a uniform shape: branch on concurrency, then delegate to the public future_* method or a private sync_* method
jkeen and others added 16 commits August 29, 2026 22:54
# [2.0.0-beta.12](v2.0.0-beta.11...v2.0.0-beta.12) (2026-08-30)

### Bug Fixes

* compare each concurrency path against itself in the table to better illustrate the performance wins ([d0f0e92](d0f0e92))

### Features

* add schema rake tasks, so checking a schema no longer means running the suite ([6f4783d](6f4783d))
* declare Graphiti's client errors in rescue_responses ([e369824](e369824))
* mark deprecated settings in the generated ApplicationResource ([52b0642](52b0642))
* take error text from locale keys ([d01fd06](d01fd06)), closes [#216](#216)
…le file

The file now lists each code with the title it renders, the exceptions that raise it, and a detail for the 500 fallback, which had none.
Graphiti.context[:namespace] was technically private API, but it was given publicly as a solution, which makes it practically public, so the old key has a deprecation around it now.
…bers

A thread local is fiber scoped in MRI, so context, transaction hooks and debug chunks were lost in any fiber a request spawned, and a sideload resolving inside one would wait on the bounded pool from a pool thread.

Point rescue registry at develop branch with fiber fix until 1.1 is shipped

Closes #540.
?include=positions.employee.positions&filter[positions.rank]=1 returned positions 1 and 2 where ?include=positions returned 1.
?include=positions.classification rendered the employee's own classification as data: nil.
semantic-release-bot and others added 9 commits August 31, 2026 23:51
# [2.0.0-beta.13](v2.0.0-beta.12...v2.0.0-beta.13) (2026-08-31)

### Bug Fixes

* compare the endpoint id to the payload id as strings ([62051d7](62051d7))
* evaluate writable guards under the action being performed ([d56b86a](d56b86a))
* **generators:** stop injecting a routes host into config/application.rb ([db92d06](db92d06))
* keep a belongs_to linkage when another include shares its name ([2c9c7c7](2c9c7c7))
* keep deep filters narrow when an include path repeats ([833a10b](833a10b))
* keep request state in fiber storage so it survives into child fibers ([6569935](6569935))
* only register the jsonapi mime type when it is missing ([88401ab](88401ab))
* with_options accepts the predicate backed relationship options ([71ab441](71ab441))

### Features

* write every error code Graphiti renders into the generated locale file ([15f19c1](15f19c1))

### Performance Improvements

* record latest performance stats ([7380ef9](7380ef9))
# Conflicts:
#	CHANGELOG.md
#	lib/graphiti/version.rb
Graphiti stopped calling them in 2018 and reaches the adapter through #save.
@jkeen
jkeen merged commit 5d2dbd1 into main Sep 1, 2026
48 checks passed
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@sandstrom

Copy link
Copy Markdown

Awesome! 💯

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment