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
endThe 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
endNo 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:
- No eager loading of gems the user doesn't have
- No conditional requires scattered throughout the codebase
- 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:
- Your gem needs to support multiple backends for the same operation
- Those backends have heavy or platform-specific dependencies
- Users want to choose which backend to use
- You want to allow third-party extensions
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.