When I built DocPDF, I wanted it to support multiple PDF libraries and image processors without forcing users to install all of them. If you only need to convert Word docs, you shouldn't have to pull in ImageMagick bindings. If you prefer HexaPDF over Prawn, that should just work.

The pattern I landed on is a resolver registry with lazy loading. Extensibility was a key concern too. Users should be able to register their own adapters without forking the gem. Here's how it works.

The problem

A document converter needs different tools for different formats. Word docs go through LibreOffice, images go through ImageMagick, plain text needs a PDF generation library. Each tool has its own Ruby gem with its own dependencies (some with native extensions).

The typical approach is to list everything in the gemspec and let users deal with the install. But that means anyone who wants to convert a Word doc is also compiling RMagick, even if they never touch an image.

The registry

Instead, I created a resolver class that maintains an ordered list of adapters. Each adapter declares which MIME types it handles, which gem it needs, and a loader proc that returns the adapter class:

class ConverterResolver
  @adapters = []

  class << self
    def register(name, require_name: nil, mime_types:, loader:)
      @adapters << {
        name: name.to_sym,
        require_name: require_name,
        mime_types: mime_types,
        loader: loader,
      }
    end

    def resolve(mime_type)
      missing_gems = []

      @adapters.each do |entry|
        next unless entry[:mime_types].include?(mime_type)
        require entry[:require_name] if entry[:require_name]
        return entry[:loader].call
      rescue LoadError
        missing_gems << entry[:require_name]
        next
      end

      if missing_gems.any?
        gem_list = missing_gems.map { |g| "'#{g}'" }.join(" or ")
        raise "No converter found for #{mime_type}. Install #{gem_list} and add it to your Gemfile."
      end
    end
  end
end

The key is the rescue LoadError. When an adapter's gem isn't installed, it silently moves on to the next one. This means multiple adapters can handle the same format, and the first available one wins.

Registering adapters

Adapters register themselves at load time, and the order determines priority:

ConverterResolver.register :prawn,
  require_name: "prawn",
  mime_types: %w[text/plain],
  loader: -> { require "adapters/prawn_converter"; PrawnConverter }

ConverterResolver.register :hexapdf,
  require_name: "hexapdf",
  mime_types: %w[text/plain],
  loader: -> { require "adapters/hexapdf_converter"; HexapdfConverter }

Both handle text/plain, but Prawn is checked first. If the user has Prawn installed, it's used. If not, HexaPDF is tried. If neither is installed, you get a clear error.

The adapter contract

Every adapter implements the same interface. For converters, that's a single class method:

class PrawnConverter
  def self.call(data:, mime_type:, filename:, config:)
    # Convert input to PDF binary string
    # Return a Result with data and filename
  end
end

No inheritance, no abstract base class, no module inclusion. Just duck typing. If it responds to .call with the right arguments, it works. This makes it trivial to write a custom adapter.

Why lazy loading matters

The loader: proc is important. The adapter class files are only required when an adapter is actually selected. This means:

  1. No eager loading of gems the user doesn't have
  2. No conditional requires scattered throughout the codebase
  3. The require happens once, at first use, and Ruby caches it

Without this, you'd need a bunch of if defined?(Prawn) checks or rescue blocks in your main code. The registry centralizes all of that.

User-extensible

Because registration is just a method call, users can add their own adapters:

ConverterResolver.register :my_converter,
  require_name: "my_gem",
  mime_types: %w[application/x-custom],
  loader: -> { require "my_converter"; MyConverter }

No monkey-patching, no subclassing. They register it and it participates in resolution like any built-in adapter.

Clear error messages

One thing that makes this pattern user-friendly: when no adapter is found, the error message tells you exactly which gem to install. This is the difference between "undefined method 'convert' for nil:NilClass" and "No converter found for text/plain. Install 'prawn' or 'hexapdf' and add it to your Gemfile."

That small detail saves a lot of GitHub issues.

When to use this pattern

This works well when:

You'll see variations of this pattern in database adapters, cloud storage gems, and file upload libraries. The specific implementation varies, but the core idea is the same: a registry, lazy loading, and a shared interface.