John Tornow
Customer Facing Identifiers

Customer Facing Identifiers

Posted on 9 min read

One of the aspects of Rails and ActiveRecord that I never have liked is using integer-based identifiers in public-facing routes. We’ve all seen something like this a million times in URLs:

GET https://example.com/projects/12345

Is there something wrong with everyone in the world knowing this is project #12345? No, not really. But I don’t like it. For an internal facing application, it doesn’t matter. But once I start to get into a multi-tenant application with many customers I prefer to keep my identifiers more obfuscated.

My primary goal here is to obscure the ability for customers of my apps to see how many records are in my database. Nobody needs to know if they are the first account in our system, or the millionth.

One solution to this problem is to use UUIDs for primary keys. Something like this, in a table creation migration file is all that’s needed to set up UUID:

create_table :projects, id: :uuid do |t|
  t.string :name
  t.timestamps
end

An example of a Rails migration using a UUID as the primary key.

But here’s the thing: UUIDs are ugly, long and annoying to work with. This isn't much better for me:

GET https://example.com/projects/ff39caa6-3dc1-4d9a-9f67-46362a48dbc8

I also don’t want to change all of my primary keys from integers to UUIDs! I like the integers internally, but I just don’t want them exposed publicly.

What I’m looking towards is something similar to the gold standard of APIs and identifiers: Stripe. Stripe prefixes its identifiers with a prefix related to the underlying model, which is really nice. So Project with an ID of 12345, becomes something like proj_73OofhdO.[1]

This is where a typical developers would reach for a gem. There’s plenty of them, FriendlyID looks really nice. Prefixed IDs is another great one, and has a very similar implementation to mine.

But there’s something you should know about my style: I do not like dependencies. I do not like them at all. I do not like having to maintain a list of hundreds of gems in my projects. I’m a software developer, not a collector of other people’s code. Dependencies are fine if you have a good reason, but this is not one of them.

My solution is simple and is only two files. I copy these between projects when I kick things off and I never have to think about it again. Simple, portable, and maintainable.

Enter the Cfid

Naming things is hard, as you may know. I call my identifier a “Cfid”, short for Customer Facing Identifier. Or maybe even Customer Friendly Identifier, whatever. It’s just something that I use when the customer is presented with a URL with an identifier, so the name fits.

To do the heavy lifting, I have a model class named Cfid. Here are the basics of creating a Cfid:

class Cfid

  attr_reader :prefix
  attr_reader :id

  def initialize(prefix:, id:)
    @prefix = prefix
    @id     = id
  end

  def encoded_id
    sqids.encode([id])
  end

  def to_s
    "#{ prefix }_#{ encoded_id }"
  end

  def self.alphabet
    # Rails.application.credentials.dig(:cfid, :alphabet)
    # - or -
    # ENV["CFID_ALPHABET"]
    #
    # This is a hard-coded example for this explanation:

    "GWkjSVLyXKsoJ1nziMNue8Rafqlgxb4IhQCBtr03HUYAd9v7mP2D6FE5wOpTcZ"
  end

  def self.sqids
    @sqids ||= Sqids.new(alphabet:, min_length: 8)
  end

  private

    def sqids
      self.class.sqids
    end

end

A basic Cfid class for encoding a record

Let’s dig into what’s going on here a bit with an example of usage. This basic implementation gives us the ability to encode the record id. In this case I'm just using Project #1. Here's how to turn Project #1 into a prefix and encoded Cfid:

cfid = Cfid.new(prefix: "proj", id: 1)
cfid.to_s # => "proj_73OofhdO"

Using the new Cfid class to generate an encoded id.

Nice and handy! We have created an encoded id, with a per-model prefix. In this case I’m using proj for Project, but you can use anything you like.

The magic under the hood here is the Sqids gem (formerly known as Hashids), which is implemented in a ton of languages and is a very simple utility for encoding and decoding integer values into an obfuscated string.[2]

One important piece to mention here is the alphabet parameter for the Sqids setup. An "alphabet" lets us choose a specific set of letters and numbers to use for the encoding of the ids. You could certainly just use the standard alphabet, but that would be very easily reversible. I like to generate a random alphabet, just to obfuscate things a bit using a snippet like this:

puts ((0..9).map(&:to_s) + ('A'..'Z').to_a + ('a'..'z').to_a).shuffle.join("")

A script to output a randomized alphabet

Decoding Cfids

I've demonstrated a simple way here to encode your primary key, but how do we look up the model from the id? Here's the Cfid class again, but with a way to find a specific record for a given model:

class Cfid

  # ...

  def self.find(cfid)
    prefix, encoded_id = cfid.to_s.strip.split("_")
    return nil unless prefix.present? && encoded_id.present?

    ids = sqids.decode(encoded_id)
    return nil unless ids.present?

    new(prefix:, id: ids.last)
  end

  # ...
  
end

A new class method for finding a record by model and Cfid string.

With this new find method, we can load the original record back from the Cfid:

cfid = Cfid.find("proj_73OofhdO")
cfid.id # => 1
cfid.prefix # => "proj"

Decoding a Cfid string with a valid encoded id.

This is all working nicely enough, but it's very cumbersome. I want to deal with models, not ids and remembering prefixes. Let's integrate the Cfid a bit more into our application context.

The Concern

I love concerns in Rails. Technically this is just a syntax improvement over Ruby's Module implementation, but I'm calling them concerns for this type of use.

In order to make Cfid a bit more usable and friendly inside of our application, I'm going to mix in the following concern into all models where I'd like to use customer facing ids:

module CustomerFacingIdentifiable

  extend ActiveSupport::Concern

  def cfid
    if persisted?
      Cfid.new(prefix: cfid_prefix, id:)
    end
  end

  def cfid_prefix
    self.class.cfid_prefix
  end

  def to_cfid
    cfid&.to_s
  end

  class_methods do

    def cfid_prefix=(prefix)
      @cfid_prefix = prefix.to_s
    end

    def cfid_prefix
      @cfid_prefix || default_prefix_for_cfid
    end

    # Fallback to a calculated prefix, if none is provided for this model.
    #
    # Project => "pr"
    # ProjectTask => "prta"
    def default_prefix_for_cfid
      self.model_name.name.underscore.split("_").map { |s| s[0..1] }.join("")
    end

    def customer_facing_prefix(value)
      self.cfid_prefix = value
    end

  end
end

A Rails concern to mix in Cfid capability to a model.

Then, to use the concern and its capabilities, we need to include the concern in our model. In this case, the Project model. The concern does provide a primitive default prefix for us, but let's use a specific prefix:

class Project < ApplicationRecord

  include CustomerFacingIdentifiable

  customer_facing_prefix :proj

end

Adding Cfid capability to a model with the concern

Now we have the ability to generate a Cfid easily from a model:

project = Project.find(1)

# Get a Cfid instance
project.cfid # => Cfid.new(prefix: "proj", id: 1)

# Or more likely, just convert it to a string id:
project.to_cfid # => "proj_73OofhdO"

Creating a new Cfid from a model.

This is much cleaner than remembering ids and prefixes! Just like earlier, we now can generate a Cfid string from a model, but we still need to find a model based on an existing id. For that, I'll add another method to the Cfid model itself so we can find a particular model by its prefix:

class Cfid

  # A memoized list of models and prefix for easy lookup later
  #
  # If any prefixes are changed, make sure you restart your server or console
  # to reload this value.
  def self.models
    return @models if defined?(@models)

    @models = {}

    Dir.glob(Rails.root.join("app/models/**/*.rb")).each do |path|
      class_name = path.gsub(Rails.root.join("app/models/").to_s, "").gsub(/\.rb$/, "").gsub("concerns/", "").classify
      klass = class_name.safe_constantize
      next unless klass&.respond_to?(:cfid_prefix)

      clean_prefix = klass.cfid_prefix.to_s.gsub(/_$/, "").to_sym
      next unless clean_prefix.present?

      @models[clean_prefix] = klass
    end

    @models
  end

end

A models method to keep track of prefixes and models.

This is a bit of a gnarly method. All we are doing here is looping through all Rails model files (less concerns) and checking for any of those models that have a cfid_prefix. We won't use this method directly in our application, but it gives the Cfid the ability to find a specific model class by prefix. Here's what the models method generates:

Cfid.models # => { "proj" => Project }

The output of Cfid.models method

The .models method allows us to add another method to the Cfid class that returns the model record for the given id:

class Cfid

  def model
    self.class.models[prefix]
  end

  def record
    if model.present? && id.present?
      model.find_by(id:)
    end
  end
  
end

Find a model and record instance for a given prefix and id

Finally, we're able to generate and look up a Cfid string for a given model:

project = Project.find(1)
project.to_cfid # => "proj_73OofhdO"

Cfid.find("proj_73OofhdO").record # => Project(1)

Generating and finding a Cfid

I still don't love having to ever refer to the Cfid class in my application code. I'd prefer to have a finder method that looks up a Project by its customer id value. Let's hop back into our CustomerFacingIdentifiable concern and add that:

module CustomerFacingIdentifiable
  # ...

  class_methods do
    def find_by_cfid(cfid_str)
      cfid = Cfid.find(cfid_str)
      return nil unless cfid.present?
      return nil unless cfid.prefix == cfid_prefix

      cfid.record
    end

    def find_by_cfid!(cfid)
      if record = find_by_cfid(cfid)
        record
      else
        raise ActiveRecord::RecordNotFound
      end
    end
  end
  
  # ...
end

Adding finder methods to CustomerFacingIdentifiable concern

Continuing along with our sample, we can now simply use the following code to generate a cfid and lookup the model:

project = Project.find(1)
project.to_cfid # => "proj_73OofhdO"

Project.find_by_cfid("proj_73OofhdO") # => Project(1)
Project.find_by_cfid("proj_xyz") # => nil

Project.find_by_cfid!("proj_73OofhdO") # => Project(1)
Project.find_by_cfid!("proj_xyz") # => raises ActiveRecord::RecordNotFound

Generating a Cfid and retrieving a record from Cfid

Bringing it all together

Bringing us back to the top, our main goal here was to create obfuscated routes for our models. Rails kindly offers a method (to_param) that determines how ActiveRecord instances are turned into routes, so we'll hook into that for our Cfid-enabled models. Once again, let’s modify our CustomerFacingIdentifiable concern to override the to_key and to_param methods that Rails relies upon to build routes:

module CustomerFacingIdentifiable
  # ...

  def key
    [ to_param ]
  end

  def to_param
    to_cfid
  end
  
  # ...
end

Modifying our concern to allow Rails to generate routes using Cfids

With these additions, a routing helper for our original project above might now look like this:

GET https://example.com/projects/proj_tvhCdE5D

This route is generated using normal Rails routing helpers:

project_path(@project)

Last but not least, in our controller where we look up the project we'll change the find method to use find_by_cfid!:

class ProjectsController < ApplicationController
  def show
    @project = Project.find_by_cfid!(params[:id])
  end
end

And there we have it! Two files (the Cfid model, and our CustomerFacingIdentifiable concern) and we have a basic working version of customer facing identifiers.

Here are the full final files that are ready to use: [3]

# app/models/cfid.rb

class Cfid

  attr_reader :prefix
  attr_reader :id

  def initialize(prefix:, id:)
    @prefix = prefix
    @id     = id
  end

  def encoded_id
    sqids.encode([id])
  end

  def model
    self.class.models[prefix]
  end

  def record
    if model.present? && id.present?
      model.find_by(id:)
    end
  end

  def to_s
    "#{ prefix }_#{ encoded_id }"
  end

  def self.alphabet
    # Rails.application.credentials.dig(:cfid, :alphabet)
    # - or -
    # ENV["CFID_ALPHABET"]
    #
    # This is a hard-coded example for this explanation:

    "GWkjSVLyXKsoJ1nziMNue8Rafqlgxb4IhQCBtr03HUYAd9v7mP2D6FE5wOpTcZ"
  end

  def self.find(cfid)
    prefix, encoded_id = cfid.to_s.strip.split("_")
    return nil unless prefix.present? && encoded_id.present?

    ids = sqids.decode(encoded_id)
    return nil unless ids.present?

    new(prefix:, id: ids.last)
  end

  # A memoized list of models and prefix for easy lookup later
  #
  # If any prefixes are changed, make sure you restart your server or console
  # to reload this value.
  def self.models
    return @models if defined?(@models)

    @models = {}

    Dir.glob(Rails.root.join("app/models/**/*.rb")).each do |path|
      class_name = path.gsub(Rails.root.join("app/models/").to_s, "").gsub(/\.rb$/, "").gsub("concerns/", "").classify
      klass = class_name.safe_constantize
      next unless klass&.respond_to?(:cfid_prefix)

      clean_prefix = klass.cfid_prefix.to_s.gsub(/_$/, "").to_s
      next unless clean_prefix.present?

      @models[clean_prefix] = klass
    end

    @models
  end

  def self.sqids
    @sqids ||= Sqids.new(alphabet:, min_length: 8)
  end

  private

    def sqids
      self.class.sqids
    end

end
# app/concerns/customer_facing_identifiable.rb

module CustomerFacingIdentifiable

  extend ActiveSupport::Concern

  def cfid
    if persisted?
      Cfid.new(prefix: cfid_prefix, id:)
    end
  end

  def cfid_prefix
    self.class.cfid_prefix
  end

  def key
    [ to_param ]
  end

  def to_cfid
    cfid&.to_s
  end

  def to_param
    to_cfid
  end

  class_methods do

    def cfid_prefix=(prefix)
      @cfid_prefix = prefix.to_s
    end

    def cfid_prefix
      @cfid_prefix || default_prefix_for_cfid
    end

    def customer_facing_prefix(value)
      self.cfid_prefix = value
    end

    # Fallback to a calculated prefix, if none is provided for this model.
    #
    # Project => "pr"
    # ProjectTask => "prta"
    def default_prefix_for_cfid
      self.model_name.name.underscore.split("_").map { |s| s[0..1] }.join("")
    end

    def find_by_cfid(cfid_str)
      cfid = Cfid.find(cfid_str)
      return nil unless cfid.present?
      return nil unless cfid.prefix == cfid_prefix

      cfid.record
    end

    def find_by_cfid!(cfid)
      if record = find_by_cfid(cfid)
        record
      else
        raise ActiveRecord::RecordNotFound
      end
    end

  end
end

Also available in a Gist.

👋
Thanks for reading. Liked this post or found it helpful? Want to tell me why everything is wrong and your way is better? I’m here for it.
Drop me a line any time.

  1. One side benefit of prefixing the identifiers with the name of the model is they become very easily findable in application logs. It’s difficult to search log files for identifiers some times, especially when an app has a lot of models with similar record counts. Searching for proj_abc123 is very clear and only returns the specific record I need ↩︎

  2. Yes, I know I said I don’t like external dependencies. That’s true, but I don’t mind utilities like Sqids at all. These are easily upgradeable, do one thing well, and don’t prescribe how to use it in your app. ↩︎

  3. We haven’t taken care of much error checking or handling of exceptions, but that's beyond the scope of this simple demo. ↩︎

Posted by John Tornow in: Utilities