Using sequential, numerical IDs for your record is the typical way to go, but what if for complicated and plot-related reasons you need something else? Say, you’re working with public-facing data and you’d like to obfuscate the total number of records or make it hard for users to guess another resource. There’s a couple approaches to do this easily in Ruby on Rails.

Using UUIDs

UUIDs are the simplest, easiest way to go in this scenario. Even more if you’re using Postgres as a database. Just enable the pgcrypto extension inside a migration and change your migration afterwards.

class EnablePgCrypto < ActiveRecord::Migration
  def change
    enable_extension 'pgcrypto'
  end
end

Then, for your migrations just add the id: :uuid type in the create_table definition.

class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users, id: :uuid do |t|
      t.string :name
      t.timestamps
    end
  end
end

If you want to enable uuids for every record created in the future, overwrite your generators’ default within an initializer.

# config/initializers/generators.rb
Rails.application.config.generators do |g|
  g.orm :active_record, primary_key_type: :uuid
end

…voilá. Now every model created will use :uuid as their primary_key_type by default.

Using custom strings

Imagine you have three models: Organization, Project and Task. For product-related reasons, they each need to expose an id with the resource’s initial and a number; for example, ORG-123, PRO-092 and TSK-19202.

We could add these fields to our models either via a migration with a computed value, a method in the class definition or the third, forbidden way.

Stored values

Let the database handle the ID generation for you. (Available from Rails 7.1 and onwards!)

class CreateOrganizations < ActiveRecord::Migration
  def change
    create_table :organizations do |t|
      t.string :name
      t.virtual :internal_id, type: :string, as: "'O-' || id", stored: true

      t.timestamps
  end
end

Presto! Now every Organization record will be created with an internal_id attribute set to O-#{their_id}.

The same could be achieved with a simple method in the Organization class, but it wouldn’t allow us to query records by their internal_id attribute.

class Organization < ApplicationRecord
  ...
  def internal_id
    "O-#{id}"
  end
end

But wait! What if Product want the urls to look something like app.com/organizations/O-1928?

No problem, we can rewrite our routes and controller to query through them… Right?

# config/routes.rb
get "organizations/:internal_id", to: "organizations#show", as: :organization

# app/controllers/organizations.rb
...
private
 def set_organization
   # Get the numerical id from the param
   id = params[:internal_id][/\d+/].to_i
   @organization = Organization.find(id)
 end

It will work, but now we’re way one minor misstep ahead from getting into a real ugly technical debt.

The forbidden way

Rails can handle all of this for you, no strings attached and without ever worrying about messing with your routes, controllers or migrations again.

For the following example I’m gonna assume that from now on, every model in your application will require unique ID definitions stored as strings.

# config/initializers/generators.rb
Rails.application.config.generators do |g|
  g.orm :active_record, primary_key_type: :string
end

And now we’re gonna specify what’s the initial for each of our models

class Organization < ApplicationRecord
  ID_INITIALS = "ORG".freeze
end

class Project < ApplicationRecord
  ID_INITIALS = "PRO".freeze
end

class Task < ApplicationRecord
  ID_INITIALS = "TSK".freeze
end

And finally, making sure every new record complies with this new format upon creation

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  before_create :set_id

  private

  def set_id
    # Ignore if the current model does not use :string
    #   as the primary_key_type
    return if self.class.attribute_types['id'] != :string
    # Not having said constant will raise an error 
    return unless self.class.const_defined?(:ID_INITIALS)

    self.id ||= "#{self.class::ID_INITIALS}-#{SecureRandom.hex(3).upcase}"
  end
end

Now, a couple interesting things are happening here:

  • Since we’re walking away from the incremental ID generation, we can’t safely assume the next record from ORG-98 will be ORG-99. We could look for the last generated record and increment from there, but then if other records are already pointing to it because it was deleted, we’re setting ourselves up for a relationship nightmare.
    • Imagine for a second that the generated id turns out to be the same one as a previously deleted record, for example.
  • So we’re trusting SecureRandom.hex(3) to generate a 3-bytes, 6-characters long string. If you wanna be extra safe about collisions and don’t mind making a couple database queries while you’re on it, you could rewrite the set_id method like:
def set_id
  # clause guards
  self.id ||= loop do
    generated_id = "#{self.class::ID_INITIALS}-#{SecureRandom.hex(3).upcase}"
    break generated_id unless self.class.exists?(id: generated_id)
  end
end

This will trigger at least one database query to verify that our ID is unique. If we’re unlucky, it might trigger multiple. This is not the best approach for a rapidly growing table.

Conclusion

There you got it! Now we can (un)safely use any kind of ID format within our models. Is this the best approach? Of course it isn’t, but it’s nice to have options. Overwriting the id generation portion of ActiveRecord is not always a bad thing, though: the same approach can be applied to use UUIDv7 as primary keys.