August 15, 2019·6 min read·engineeringrubyrailsdatabases

Rails 6 makes database roles explicit in Active Record

Rails 6 adds multiple-database support with writing and reading roles. Its connection APIs organize access, while replica consistency and cross-database operations retain concrete limits.

A database replica can answer the same query as its primary while returning an older result. That makes read/write splitting an application behavior decision, not merely a connection-string change. Rails 6.0, released August 15, adds multiple-database support to Active Record. The new APIs give that decision a place in the framework's connection model.

The release also brings Action Mailbox and Action Text, Zeitwerk autoloading, and parallel testing. Its multiple-database work is particularly useful for understanding the boundary between a framework convention and infrastructure behavior. Rails can select a connection and organize its migrations. It does not make separate databases behave like one transactional system.

I find Ruby's object-oriented model approachable when a class has a clear responsibility. The same preference applies to persistence. A model hierarchy that declares which database owns its records is easier to understand than repeated connection changes scattered through individual methods.

Separate databases and replicas represent different relationships

The multiple-database guide shipped with Rails 6.0 describes multiple primary databases, each with a replica. These arrangements serve different purposes. Putting a set of records in another primary separates where those records live. Adding a replica supplies another copy of the records belonging to its primary.

A database configuration therefore needs to distinguish both identity and role. The guide uses a nested configuration with named entries under each environment. A replica entry has replica: true, while a separate primary can declare its own migrations_paths. These settings let Rails interpret the connection's intended purpose.

The guide also recommends a read-only database user for the replica. That is a database permission boundary. Calling a connection the reading role does not itself create such a user or configure its server permissions. A complete configuration needs both the application's routing policy and the database account appropriate to that policy.

This division is helpful when diagnosing an operation that reaches the wrong destination. The framework chooses a configured connection. The selected account and database determine what that connection can actually do. A correct application role name cannot compensate for a replica account with inappropriate privileges, just as correct privileges cannot repair selection of the wrong database.

A model hierarchy can own its connections

A separate abstract model class can declare the connections for a group of records. This example follows the release guide's structure for an animals database.

class AnimalsBase < ApplicationRecord
  self.abstract_class = true

  connects_to database: {
    writing: :animals,
    reading: :animals_replica
  }
end

class Animal < AnimalsBase
end

The configured names refer to entries in database.yml. The class states that writing and reading for this hierarchy use those respective entries. Other model hierarchies can retain the primary database's own pair of connections.

The guide recommends establishing those connections in one model and inheriting from it. Connecting each individual model separately can multiply connection pools because Rails uses the model class name as the connection specification name. A database server still has a finite connection capacity, so how the hierarchy establishes connections affects the resources the application consumes.

This is a useful example of a convention with an operational consequence. Two classes can point at the same database without sharing the same connection specification. Repeating an apparently harmless declaration can therefore increase the number of connections. The abstract base class communicates ownership and provides a common place for that declaration.

The hierarchy should follow actual persistence ownership. A class's business relationship to another class does not guarantee that their rows live in the same database. Making those boundaries explicit helps reveal operations that may need separate queries or coordination across destinations.

Automatic switching uses request history

Rails supplies optional middleware that selects the primary or replica using the HTTP verb and recent writes. The release guide shows the resolver and its session-based context configured alongside a delay.

config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver =
  ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context =
  ActiveRecord::Middleware::DatabaseSelector::Resolver::Session

The middleware sends write-oriented requests to the primary. GET and HEAD requests can use the replica, except during the configured interval after a recent write for the tracked context. The guide describes this as read-your-own-write behavior and distinguishes it from seeing another user's recent write.

The policy addresses a common interaction. A user changes a record and immediately returns to a page displaying it. Sending that read to a lagging replica could show the old value and make the successful write appear lost. Keeping the read on the primary for an interval avoids that particular routing choice during the window.

The window is still a policy based on time and context. It is not a measurement proving that every replica has replayed a particular transaction. The configured delay needs to correspond to the infrastructure's behavior. A request outside that context, or a read concerning another user's update, does not acquire the same routing decision automatically.

I like that the guide states the narrower guarantee. A framework can supply useful default routing while preserving a clear place to replace the resolver. Describing the policy precisely makes it possible to judge whether it matches an application's consistency requirement.

A transaction-status screen, for example, may require the latest authoritative state regardless of whether the current session performed the write. That requirement differs from the default session-based heuristic. It is a reason for an explicit connection choice or a resolver appropriate to the operation, rather than an assumption that every GET is interchangeable.

Manual switching expresses a stronger local choice

The connection API also allows code to select a role for a block.

ActiveRecord::Base.connected_to(role: :reading) do
  Animal.limit(10).to_a
end

The block communicates that its Active Record work should use the reading role. An unknown role raises an error rather than silently inventing a pool. The role must already have meaning through the application's connection configuration.

This is useful outside ordinary request routing, including work whose destination should be explicit in the code. A job or a specialized operation may have a requirement that does not follow the HTTP method. Naming the role at that boundary tells a reviewer which consistency and permission context the operation expects.

The call should also surround the actual database work. Constructing a lazy relation and executing it elsewhere makes the execution context less obvious. The release’s relation implementation loads records when the relation is converted to an array. Materializing the result within the block, as above, keeps the query in the declared role scope. That makes the example's intent inspectable without depending on when a later caller consumes the relation.

Schema changes need the same ownership boundary

Multiple writable databases need distinct migration histories. The guide places migrations for a separate primary under its configured migration directory and supplies database-specific tasks. A general database creation task can create the configured primaries, while a named task can target one database.

This organization prevents a migration's destination from being implicit in whichever connection happens to be active. The directory and task describe the database the migration belongs to. It also makes a review more concrete, because adding a table to a second database includes the configuration and migration location needed to identify its owner.

Creating database users remains separate work. The Rails task that creates a database does not create the read-only account recommended for its replica. That separation prevents the application's schema lifecycle from being confused with the server's access-control lifecycle.

A deployment with several databases also has several schema states to account for. A migration completing on one does not imply that every other destination has completed its own migrations. The release gives each destination an explicit task and configuration, while deployment code still needs to express the order and failure behavior appropriate to the application.

Rails 6 retains limits around distributed data

The 6.0 guide explicitly excludes sharding, replica load balancing, and joins across database clusters. These exclusions are material to the design. Selecting among configured databases does not decide how to distribute rows by a shard key or how to balance traffic among replicas.

A relationship spanning separate databases similarly cannot rely on an ordinary cross-database join through this support. The application has to obtain the required records through separate queries and combine them appropriately. That can change the number of round trips and the point at which concurrent updates affect the result.

Multiple-database support is therefore useful for organizing known persistence boundaries inside one Rails application. Its roles and model hierarchies make those boundaries visible. Operations needing a single authoritative read can select the appropriate connection, while operations spanning destinations still need a consistency model that accounts for the separate databases involved.