If you running a Rails application, there is a very good chance that you are storing information needed by your application in a database, be that customer information of orders and invoices. While storing the data is one part, retrieval and display of the data are the other two. In this article, we will focus on the retrieval-side of your database and application: record identifiers, or IDs are the keys that uniquely identify a row in a database table.

Built-In Record IDs

By default, Rails uses incrementing numbers, starting a 1 as the primary identifiers for ActiveRecord objects. This is a simplistic approach, and makes it easy to discuss and reason about the system during prototyping and development ("Order 56 has an issue with the input form ..."), and it comes pre-configured out-of-the-box. In practice however, this system is not only a security and business intelligence issue ("aha, this company issued only 17 invoices this quarter!"), it also leaves the system open to enumeration attacks, where attackers can simply guess the IDs of records they should not be able to see.

You can opt-in and configure your Rails apps to use UUIDs as your primary keys - they have really good support out of the box, are almost impossible to guess and collisions are exceedingly rare. However, they are clunky, use a lot of space, and with some mode complex applications and APIs that have nested paths you are likely to hit the 255 character limit for your URLs when each ID already takes up 32 characters.

Human-Readable IDs

That leaves us wondering - can't we find a middle ground between random / non-guessable and still short and human readable, while at the same time keeping the chance for collisions low? One company that has solved this problem really well is payments processor Stripe - all data objects in Stripe have a very neat human readable ID that allows users to immediately identify the type of object being referenced. For instance, the identifier for a PaymentIntent object may look like this:

pi_aSjLS0bas8ISmxPAJsKska21
└─┘└──────────────────────┘
 └─ Prefix    └─ Random Alpha-Numeric String 

It consists of a prefix (pi_) and a short (20 characters) alpha-numeric string. This practice keeps identifiers random, and unguessable, while helping Stripe internally just as much as users when accessing data and debugging problems. As an additional benefit, app developers can use a mapping to automatically infer the object type and perform a lookup based on this ID format, enabling amazing UX such as automatically opening up relevant pages by clicking on IDs.

There are other less obvious benefits in using prefix IDs such as Stripe does, for example you can differentiate between data stored in live vs test environments (_live_ prefixes), censoring sensitive data in logs and APM, or enabling efficient polymorphic tables and lookups.

Generating Stripe-Like IDs in your own Rails app

So, prefix IDs are amazing - but how do you implement them in your own app, in a way that scales? When it comes to scalability there are two primary concerns around IDs: the time to lookup, i.e., the amount of time it takes to find a row in a database table given the identifier, and the rate of collisions, i.e., primary identifiers need to be unique and in the case the same identifier already exists in the database the app needs to keep re-generating a random IDs until one is found that doesn't collide with the existing data.

We can leverage two Ruby gems to build a system that generates Stripe-like IDs for all our Rails models: the first being the amazing friendly_id gem that is commonly used for generating slugs, and the nanoid gem, a tiny, secure URL-friendly unique string ID generator.

The first step is to define an "extension" to the FriendlyId module for generating prefixed slugs based on nano ids:

# frozen_string_literal: true

module FriendlyId
  module Nano
    # Sets up behavior and configuration options for FriendlyId's nano_id
    # slugging feature.
    def self.included(model_class)
      model_class.friendly_id_config.instance_eval do
        # Include the FriendlyId::NanoId::Configuration module
        self.class.send(:include, Configuration)

        # Set configuration defaults
        defaults[:nano_id_prefix] ||= model_class.name.underscore
        defaults[:nano_id_length] ||= 16
        defaults[:nano_id_alphabet] ||= '1234567890abcdedfghjkmnopqrstuvwxzABCDEFGHJKMNOPQRSTUVWXYZ'
      end
    end

    def normalize_friendly_id(value)
      value.to_s.parameterize(preserve_case: true)
    end

    def nano_id
      id = Nanoid.generate(
        size: friendly_id_config.nano_id_length,
        alphabet: friendly_id_config.nano_id_alphabet
      )

      # Return final value with prefix and id separated by underscore
      # if the prefix is set (not blank?)
      if friendly_id_config.nano_id_prefix.present?
        "#{friendly_id_config.nano_id_prefix}_#{id}"
      else
        id
      end
    end

    # This module adds the `:nano_id_` configuration options to
    # {FriendlyId::Configuration}
    module Configuration
      attr_writer :nano_id_prefix, :nano_id_length, :nano_id_alphabet

      # The prefix that will be used in front of the nano id
      def nano_id_prefix
        @nano_id_prefix ||= defaults[:nano_id_prefix]
      end

      # The length of the desired nano id, longer IDs are more collision resistant
      def nano_id_length
        @nano_id_length ||= defaults[:nano_id_length]
      end

      # The alphabet that will be used to generate the nano id
      def nano_id_alphabet
        @nano_id_alphabet ||= defaults[:nano_id_alphabet]
      end
    end
  end
end

The second step is to add a slugged (or a name of your own choice, just make sure to change the configuration) column and index to your model, for example for a User model this migration would add the column and index:

# frozen_string_literal: true

class AddNanoIdToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :slug, :string
    add_index  :users, :slug
  end
end

The last step is to include the FriendlyId behaviour to your model and configure it to use the Nano extension created earlier:

class User < ApplicationRecord
  include FriendlyId
  friendly_id :nano_id, use: [:slugged, :nano], nano_id_prefix: 'usr', nano_id_length: 20
  # ...
end

And that's everything needed to have your records store nice, human-readable, Stripe-like identifiers in addition to their auto-incrementing integer ID. To access a record, pass the prefix-ID to the friendly finder:

user1 = User.friendly.find('usr_8pGQV9zu0gQwtvebELJtH')

and modify your Controllers to use friendly finders on the given :id params. Everything else will work out of the box. The index on the slug column makes lookups fast and the 20 character nano ID has a 1%-chance of collision after about 6 billion years when storing 1,000 records per hour. Of course, if you are creating and storing objects at a lower rate, or are more tolerant to collisions, a 12-character nano id can be an even nicer alternative when it comes to readability.

Polymorphic lookups

To facilitate polymorphic lookups, where we can find any object in the database given the object's prefix ID, we can add a new module PrefixedIds as follows:

module PrefixedIds
  # Register a prefix and the corresponding model
  # @param [String] prefix the prefix to register
  # @param [Class] klass the model class
  # @example
  #   PrefixedIds.register('ws', Workspace)
  #   PrefixedIds.register('it', Item)
  def self.register(prefix, klass)
    self.map.merge!(prefix => klass)
  end

  # Perform a polymorphic lookup a record by its prefixed id
  # @param [String] id the prefixed id
  # @return [Object] the record or nil if not found
  # @example
  #   PrefixedIds.lookup('ws_ER1Qhf9vkkA9PhATcE7Kt')
  #   # => #<Workspace id: 123>
  def self.lookup(id)
    return nil if id.blank?

    prefix, uid = id.split('_')
    return nil if uid.blank?

    model = map.fetch(prefix, prefix.classify.safe_constantize)
    return nil if model.nil?

    model.friendly.find_by(id: id)
  end

  # Lookup the model class for a prefixed id
  # @param [String] id the prefixed id
  # @return [Class] the model class or nil if not found
  # @example
  #   PrefixedIds.model_for('ws_ER1Qhf9vkkA9PhATcE7Kt')
  #   # => Workspace
  def self.model_for(id)
    return nil if id.blank?
    prefix, _uid = id.split('_')
    model = map.fetch(prefix, prefix.classify.safe_constantize)

    model
  end

  # Make singleton method `map` available globally
  singleton_class.send(:attr_accessor, :map)
  self.map = {}
end

and then map all our application models to their configured prefixes in an initializer:

Rails.application.config.after_initialize do
  ApplicationRecord.descendants.each do |model|
    if model.respond_to?(:friendly_id_config)
      PrefixedIds.register(model.friendly_id_config.nano_id_prefix, model)
    end
  end
end

Note that this requires the config.eager_load = true setting set for your environment (it's on by default for production but turned off in development) to correctly registers all models when the application boots.

You can then perform a polymorphic lookup by calling into PrefixedIds:

PrefixedIds.lookup('usr_8pGQV9zu0gQwtvebELJtH')

Afterword

Computers generally don't care about what an ID looks like: as long as it's unique and URL-safe we are good to go. Humans that work with data care very much though which puts prefixed IDs at the sweet spot between both worlds.

Post-Scriptum

Chris Oliver built a different implementation published as a gem that uses hashids for generating prefix IDs. Check it out on his GitHub account!