In the world of Ruby development, creating robust command-line interfaces (CLIs) is a common challenge. Having built numerous CLI applications throughout my career, I’ve discovered that applying established design patterns can transform a chaotic CLI into a maintainable, extensible system. The following seven design patterns have proven invaluable in my projects, providing structure and flexibility while keeping code clean and organized.
The Command Pattern
The Command pattern is fundamental for CLI applications. It encapsulates requests as objects, allowing parameterization of clients with different requests and support for undoable operations.
# Basic Command Pattern implementation
class Command
def execute
raise NotImplementedError, "Commands must implement execute method"
end
end
class ListCommand < Command
def execute(args)
items = fetch_items(args)
items.each { |item| puts item }
end
private
def fetch_items(args)
# Logic to retrieve items
["item1", "item2", "item3"]
end
end
class CreateCommand < Command
def execute(args)
name = args.first
create_item(name)
puts "Created item: #{name}"
end
private
def create_item(name)
# Logic to create an item
puts "Creating #{name}..."
end
end
This approach lets you add new commands without modifying existing code, adhering to the Open/Closed Principle. I find this particularly useful when managing numerous commands across different feature domains.
Plugin Architecture for Extensibility
Plugins dramatically enhance CLI flexibility, allowing third-party developers to extend functionality without modifying core code. I’ve implemented this pattern in several large-scale CLI applications with great success.
# Plugin System Implementation
class PluginManager
def initialize(cli)
@cli = cli
@plugins = {}
end
def register(plugin_name, plugin)
@plugins[plugin_name] = plugin
plugin.register(@cli)
end
def load_all
Dir["./plugins/*.rb"].each do |file|
require file
plugin_name = File.basename(file, ".rb")
plugin_class = Object.const_get("#{plugin_name.capitalize}Plugin")
register(plugin_name, plugin_class.new)
end
end
end
# Example Plugin
class GitPlugin
def register(cli)
cli.register_command("git-status", GitStatusCommand)
cli.register_command("git-commit", GitCommitCommand)
end
end
class GitStatusCommand < Command
def execute(args)
puts `git status`
end
end
When I implemented this pattern in a deployment tool, teams quickly created plugins for their specific infrastructure needs, greatly expanding the tool’s utility without requiring changes to the core codebase.
Decorator Pattern for Output Formatting
Output formatting in CLIs can become complex with multiple format options. The Decorator pattern provides a clean solution.
# Decorator Pattern for Output
class OutputFormatter
def format(output)
output
end
end
class ColorDecorator < OutputFormatter
def initialize(formatter)
@formatter = formatter
end
def format(output)
formatted = @formatter.format(output)
colorize(formatted)
end
private
def colorize(text)
# Add color codes
"\e[32m#{text}\e[0m"
end
end
class JsonDecorator < OutputFormatter
def initialize(formatter)
@formatter = formatter
end
def format(output)
formatted = @formatter.format(output)
format_as_json(formatted)
end
private
def format_as_json(text)
require 'json'
JSON.pretty_generate({output: text})
end
end
I’ve used this pattern to support multiple output formats (plain text, JSON, YAML, colored text) in a monitoring CLI tool, allowing users to pipe output to other programs or display it for human consumption.
Strategy Pattern for Parameter Parsing
Different commands often require different parameter parsing strategies. The Strategy pattern makes this clean and maintainable.
# Strategy Pattern for Parameter Parsing
class ParameterParser
def parse(args)
raise NotImplementedError
end
end
class SimpleParser < ParameterParser
def parse(args)
{value: args.join(' ')}
end
end
class KeyValueParser < ParameterParser
def parse(args)
result = {}
args.each do |arg|
key, value = arg.split('=')
result[key.to_sym] = value if key && value
end
result
end
end
class OptionParser < ParameterParser
def parse(args)
options = {}
parser = OptParse.new do |opts|
opts.on("-n NAME", "--name=NAME", "Specify name") do |name|
options[:name] = name
end
# More options...
end
parser.parse!(args)
options
end
end
This pattern allowed me to reuse parsing logic across similar commands while customizing it for specific command needs, significantly reducing code duplication in a complex database migration tool.
Observer Pattern for Event Logging
The Observer pattern is excellent for implementing cross-cutting concerns like logging, auditing, and progress reporting.
# Observer Pattern for Logging and Events
class CommandObservable
def initialize
@observers = []
end
def add_observer(observer)
@observers << observer
end
def notify_observers(event, data = {})
@observers.each { |observer| observer.update(event, data) }
end
end
class LogObserver
def update(event, data)
case event
when :command_start
puts "Starting command: #{data[:command]}"
when :command_complete
puts "Completed command: #{data[:command]}"
when :command_error
puts "Error in command #{data[:command]}: #{data[:error]}"
end
end
end
class MetricsObserver
def update(event, data)
if event == :command_complete
duration = data[:end_time] - data[:start_time]
puts "Command #{data[:command]} took #{duration}s to execute"
# Send to metrics system
end
end
end
By implementing this pattern in a batch processing tool, I gained comprehensive logging and performance metrics without cluttering the command implementations with cross-cutting concerns.
Factory Method for Command Instantiation
The Factory Method pattern centralizes command creation logic, making it easier to add pre/post processing or dependency injection.
# Factory Method Pattern
class CommandFactory
def self.create(command_name, context = {})
command_class = find_command_class(command_name)
return nil unless command_class
command = command_class.new
command.context = context
command
end
def self.find_command_class(command_name)
# Convert kebab-case to CamelCase and append Command
class_name = command_name.split('-').map(&:capitalize).join + 'Command'
# Try to find the constant
Object.const_get(class_name)
rescue NameError
nil
end
end
# Usage
command = CommandFactory.create('list-users', {db: database})
if command
command.execute(ARGV)
else
puts "Unknown command: #{ARGV[0]}"
end
I found this pattern particularly useful in a multi-tenant CLI tool where commands needed different configurations based on the user’s organization. The factory method provided a central place to inject tenant-specific dependencies.
DSL Creation for Intuitive Command Definition
Creating a Domain-Specific Language (DSL) for command definition can greatly improve the developer experience for extending your CLI.
# Command Definition DSL
class CLI
def self.command(name, description = nil, &block)
command_class = Class.new(Command)
command_class.class_eval do
define_method(:description) { description }
define_method(:execute, &block)
end
register_command(name, command_class)
end
def self.register_command(name, command_class)
@commands ||= {}
@commands[name] = command_class
end
def self.commands
@commands || {}
end
end
# Using the DSL
CLI.command "hello", "Greets the user" do |args|
name = args.first || "World"
puts "Hello, #{name}!"
end
CLI.command "add", "Adds numbers together" do |args|
sum = args.map(&:to_i).reduce(0, :+)
puts "Sum: #{sum}"
end
This approach dramatically simplified command contribution in an open-source CLI project I maintained. New contributors could add commands without understanding the entire architecture, accelerating feature development.
Bringing It All Together
To demonstrate how these patterns work together, here’s a more complete example that integrates several of them:
# Integrated CLI example
class AdvancedCLI
include CommandObservable
def initialize
super()
@commands = {}
@plugin_manager = PluginManager.new(self)
@formatter = OutputFormatter.new
# Add default observers
add_observer(LogObserver.new)
add_observer(MetricsObserver.new)
end
def register_command(name, command_class)
@commands[name] = command_class
end
def load_plugins
@plugin_manager.load_all
end
def set_formatter(formatter)
@formatter = formatter
end
def execute(args)
command_name = args.shift || 'help'
command = CommandFactory.create(command_name, {cli: self})
if command
begin
start_time = Time.now
notify_observers(:command_start, {command: command_name})
result = command.execute(args)
formatted_result = @formatter.format(result)
puts formatted_result
notify_observers(:command_complete, {
command: command_name,
start_time: start_time,
end_time: Time.now
})
rescue => e
notify_observers(:command_error, {
command: command_name,
error: e.message
})
puts "Error: #{e.message}"
exit(1)
end
else
puts "Unknown command: #{command_name}"
puts "Run 'help' to see available commands"
exit(1)
end
end
end
# Using the advanced CLI
cli = AdvancedCLI.new
cli.load_plugins
# Apply decorators for rich output
if ARGV.include?('--json')
ARGV.delete('--json')
cli.set_formatter(JsonDecorator.new(cli.formatter))
end
if ARGV.include?('--color')
ARGV.delete('--color')
cli.set_formatter(ColorDecorator.new(cli.formatter))
end
cli.execute(ARGV)
This integrated approach has served me well when building complex CLI applications that need to evolve over time. The separation of concerns allows different team members to work on different aspects of the CLI without stepping on each other’s toes.
Practical Tips for Implementation
After implementing these patterns in multiple projects, I’ve learned some practical lessons:
-
Start simple and add complexity only when needed. Begin with the Command pattern, then introduce others as your application grows.
-
Make error handling central to your design. CLIs should provide clear, actionable error messages.
-
Consider using Thor, GLI, or Dry-CLI gems, which implement many of these patterns for you.
-
Test your commands in isolation using dependency injection for external services.
-
Document the extension points thoroughly if you expect others to build on your CLI.
These design patterns have transformed how I approach CLI development in Ruby. They’ve helped me create tools that are not only functional but also maintainable and extensible over long periods. By applying these patterns thoughtfully, you can create command-line interfaces that users and developers alike will appreciate.
Through consistent application of these seven design patterns, I’ve successfully built CLI applications that remained maintainable even after years of feature additions and changes in requirements. The initial investment in proper design pays dividends throughout the application lifecycle, especially when multiple developers contribute to the codebase.