Skip to content

Rails Makes LLMs Boring (And That's Good): A Service Layer Implementation

Posted on:February 26, 2025 at 04:44 PM

In our previous article on Rails and AI, we explored how Rails’ conventions create an ideal foundation for AI-powered applications. Now let’s dive deeper into the LLM service layer that powers our D&D character generator, showcasing how Rails’ pragmatic approach handles complex AI integrations without the overhead of microservices or intricate event buses.

Many teams assume that integrating AI requires complex architectural changes—splitting applications into microservices, implementing event-driven messaging patterns, or adopting specialized frameworks. This perceived complexity often leads to overengineered solutions. Rails, with its strong service object patterns and modular design principles, provides everything needed to architect maintainable, well-structured LLM integrations while keeping the development model simple and familiar.

Service Layer Foundation

Following the pragmatic development approach we discussed earlier, we begin with a mock service that establishes our interface and enables rapid development. This approach lets us develop and test our character generator’s AI features without waiting for external API integration.

# app/services/llm/providers/mock.rb
module Llm
  module Providers
    class Mock < Base
      # Main chat interface that processes incoming messages and returns appropriate mock responses
      # @param messages [Array<Hash>] Array of message objects with 'role' and 'content' keys
      # @param system_prompt [String, nil] Optional system prompt to guide response generation
      # @return [Hash] Mock response matching the expected schema
      def chat(messages:, system_prompt: nil)
        # Log incoming request for debugging and monitoring
        log_request(:chat, messages: messages, system_prompt: system_prompt)
        
        # Extract the last user message which contains our request
        # We only care about the most recent message for determining response type
        last_message = messages.last
        return {} unless last_message['role'] == 'user'
        
        # Parse the request type from the message content
        # This determines which mock response or generator to use
        request = last_message['content']
        
        # Pattern match against request content to determine response type
        # Each type maps to a specific mock response or generator method
        response = case request
                  when /background/i
                    load_mock_response('character_background.json')
                  when /traits/i
                    load_mock_response('character_traits.json')
                  when /equipment/i
                    suggest_equipment
                  when /spells/i
                    suggest_spells
                  else
                    { error: 'Unknown request type' }
                  end
        
        # Log the response for debugging and monitoring
        log_response(:chat, response)
        response
      end
      
      private
      
      # Loads mock response data from JSON fixtures
      # @param filename [String] Name of the JSON fixture file to load
      # @raise [ProviderError] If file cannot be loaded or parsed
      def load_mock_response(filename)
        # Construct path to fixture file using Rails conventions
        path = Rails.root.join('test', 'fixtures', 'files', 'mock_responses', filename)
        JSON.parse(File.read(path))
      rescue StandardError => e
        # Log error and raise with helpful message
        log_error(:load_mock_response, e)
        raise Llm::Service::ProviderError, "Failed to load mock response: #{e.message}"
      end
    end
  end
end

This mock implementation offers several advantages:

  1. Fast Development: It starts simple yet offers space for evolution.
  2. Clear Interface: The service establishes the contract that real providers will follow.
  3. Test Reliability: Our tests can use known responses rather than unpredictable LLM outputs.
  4. Cost Saving: Developers can work without API keys or usage costs.

Prompt Service and Templates

Before our service can chat with LLMs, it needs well-crafted prompts. Our PromptService manages the templates and contextual data that shape our AI conversations. This is a critical abstraction in our architecture—prompt engineering is often one of the most challenging and iterative aspects of working with LLMs, requiring frequent refinement as we learn what produces the best results.

By extracting prompt management into a dedicated service, we gain multiple benefits: we can separately version and test prompts, enable non-developers to contribute to prompt crafting, cache frequently used templates for performance, and maintain provider-specific variations when different LLMs require different instructions. This separation of concerns follows Rails’ convention over configuration philosophy while providing flexibility where it matters most.


# app/services/llm/prompt_service.rb
module Llm
  class PromptService
    class << self
      # Factory method for generating prompts with context
      # @param request_type [Symbol] Type of prompt to generate
      # @param provider [Symbol] LLM provider to generate for
      # @param context [Hash] Additional context for template rendering
      def generate(request_type:, provider:, **context)
        new.generate(request_type: request_type, provider: provider, **context)
      end
    end

    # Main prompt generation method that loads, validates, and renders templates
    # @param request_type [Symbol] Type of prompt to generate
    # @param provider [Symbol] LLM provider to generate for
    # @param context [Hash] Additional context for template rendering
    # @return [Hash] Rendered prompt with system and user messages
    def generate(request_type:, provider:, **context)
      # Load and validate the appropriate template
      template = load_template(request_type, provider)
      validate_template!(template)
      # Render the template with provided context
      render_template(template, provider, context)
    end

    private

    # Loads template from cache or disk with fallback to default
    # @param request_type [Symbol] Type of prompt template to load
    # @param provider [Symbol] Provider-specific template to look for
    def load_template(request_type, provider)
      # Try to load from cache first to avoid disk reads
      Rails.cache.fetch(cache_key(request_type, provider)) do
        load_template_from_disk(request_type, provider)
      end
    rescue TemplateNotFoundError => e
      # Fallback to default template if provider-specific one isn't found
      Rails.logger.warn "[PromptService] #{e.message}, using default"
      load_default_template(request_type)
    end

    # Validates template structure and required fields
    # @param template [Hash] Template to validate
    # @raise [ValidationError] If template is invalid
    def validate_template!(template)
      raise ValidationError, "Template must be a hash" unless template.is_a?(Hash)
      raise ValidationError, "Missing system_prompt" unless template['system_prompt'].is_a?(String)
      raise ValidationError, "Missing user_prompt" unless template['user_prompt'].is_a?(String)

      # Skip schema validation for string responses
      return if template['response_format'] == 'string'

      # Validate schema structure if present
      if template['schema']
        validate_schema!(template['schema'])
      end
    end

    # Renders template with provided context using Mustache
    # @param template [Hash] Template to render
    # @param provider [Symbol] Provider context for rendering
    # @param context [Hash] Variables for template interpolation
    def render_template(template, provider, context)
      # Create a copy to avoid modifying the cached version
      rendered = template.deep_dup

      # Render both prompts using Mustache templating
      rendered['system_prompt'] = Mustache.render(template['system_prompt'], context)
      rendered['user_prompt'] = Mustache.render(template['user_prompt'], context)

      rendered
    end
  end
end

We load YAML templates, cache them for speed, and validate their structure—ensuring our AI dungeon master has a solid script to work from. YAML keeps templates human-readable and editable outside code—perfect for tweaking prompts without redeploying the application. This approach also makes it easy to maintain multiple prompt variations for different providers or use cases.

Externalizing prompts into YAML templates provides several significant advantages over hardcoding them:

  1. Non-technical Collaboration: Product managers, subject matter experts, and content designers can directly contribute to and refine prompts without developer intervention.
  2. Rapid Iteration: Prompt engineering requires experimentation and fine-tuning. Separating templates from code allows for quick adjustments without deployment cycles.
  3. Environment Customization: Different environments (development, staging, production) can use different prompt strategies with the same codebase.
  4. Provider-Specific Optimizations: We can maintain optimized versions for each LLM provider, leveraging their unique capabilities or working around limitations.
  5. Version Control: Changes to prompts are tracked in git separately from code changes, making reviews and rollbacks more manageable.

This thoughtful separation of concerns exemplifies Rails’ philosophy—make the common case easy and provide flexibility where it matters most.

A typical YAML template looks like this:

# config/prompts/default/character_background.yml
system_prompt: |
  You are a D&D character background generator. Create a compelling backstory
  for a {{level}}-level {{class_type}} that fits with their {{alignment}} alignment.
  The story should explain how they acquired their skills and abilities.

user_prompt: |
  Generate a background story for {{name}}, a {{race}} {{class_type}} with 
  the following abilities:
  {{#abilities}}
  - {{name}}: {{score}}
  {{/abilities}}

schema:
  type: object
  required:
    - background
    - personality_traits
  properties:
    background:
      type: string
      description: A detailed background story for the character
    personality_traits:
      type: array
      items:
        type: string
      minItems: 2
      maxItems: 4
      description: List of personality traits that define the character

The Service Layer

With our mock foundation and prompt service in place, let’s look at how the main service layer orchestrates LLM interactions. This service abstracts the communication with LLM providers, handling retry logic, error management, and providing a consistent interface for our application.

First, let’s establish our error hierarchy and constants for resilient interactions:

# app/services/llm/service.rb
module Llm
  class Service
    # Define error hierarchy for specific error handling
    class Error < StandardError; end
    class ConfigurationError < Error; end
    class ProviderError < Error; end
    class RateLimitError < ProviderError; end
    
    # Constants for retry behavior
    MAX_RETRIES = 3 # Maximum number of retry attempts
    RETRY_DELAY = 1 # Base delay in seconds between retries

This error hierarchy enables targeted exception handling. Rather than catching all errors together, we can respond differently to configuration issues versus provider-specific problems. The retry constants balance persistence with respect for API limits.

Next, we implement convenient class methods that provide a clean interface to the service:

    class << self
      # Basic chat interface for simple requests
      # @param messages [Array<Hash>] Array of conversation messages
      # @param system_prompt [String, nil] Optional system context
      def chat(messages:, system_prompt: nil)
        new.chat(messages: messages, system_prompt: system_prompt)
      end
      
      # Schema-validated chat for structured responses
      # @param messages [Array<Hash>] Array of conversation messages
      # @param system_prompt [String, nil] Optional system context
      # @param schema [Hash] JSON schema for response validation
      def chat_with_schema(messages:, system_prompt: nil, schema:)
        new.chat_with_schema(messages: messages, system_prompt: system_prompt, schema: schema)
      end
      
      # Connection test method for health checks
      # @return [Boolean] True if connection successful
      def test_connection
        new.test_connection
      end
    end
    
    def initialize
      # Initialize with appropriate provider from factory
      @provider = Llm::Factory.create_provider
    end

These class methods follow the same pattern we saw in the PromptService—they provide a clean, stateless interface to our service while delegating to instance methods for the actual work. The initialize method leverages our factory pattern, allowing us to dynamically select the appropriate provider based on configuration.

Our service offers two primary interaction methods—chat and chat_with_schema—which reflect two distinct needs when working with LLMs:

  1. Basic text interactions: Sometimes we simply need free-form text responses, like generating creative content or conversational replies. The chat method handles these scenarios with minimal overhead.

  2. Structured data extraction: For cases where we need predictable, structured outputs that our application can reliably process (like generating D&D character traits as JSON), the chat_with_schema method enforces response validation against a JSON schema. This is crucial for integrating AI outputs into database models and ensuring type safety.

This dual approach provides flexibility without sacrificing reliability—we can use the simpler method when appropriate, but have structured validation when our application requires consistent data formats.

    # Instance method implementation of basic chat
    def chat(messages:, system_prompt: nil)
      Rails.logger.info "[Llm::Service] Sending chat request with #{messages.length} messages"
      
      # Use retry mechanism for resilience
      with_retries do
        @provider.chat(messages: messages, system_prompt: system_prompt)
      end
    rescue StandardError => e
      # Log error and wrap in our error type
      Rails.logger.error "[Llm::Service] Error in chat: #{e.class} - #{e.message}"
      raise ProviderError, "Failed to process chat request: #{e.message}"
    end
    
    def chat_with_schema(messages:, system_prompt: nil, schema:)
      Rails.logger.info "[Llm::Service] Sending schema-validated chat request"
      Rails.logger.debug "[Llm::Service] Using schema: #{schema.inspect}"
      
      with_retries do
        @provider.chat_with_schema(
          messages: messages,
          system_prompt: system_prompt,
          schema: schema
        )
      end
    rescue StandardError => e
      Rails.logger.error "[Llm::Service] Error in schema request: #{e.message}"
      raise ProviderError, "Failed to process structured chat: #{e.message}"
    end
    
    def test_connection
      Rails.logger.info "[Llm::Service] Testing connection to provider"
      
      with_retries do
        chat(messages: [{ role: 'user', content: 'test' }])
        true
      end
    rescue StandardError => e
      Rails.logger.error "[Llm::Service] Connection test failed: #{e.message}"
      false
    end

Each method wraps provider interactions with our with_retries mechanism and comprehensive logging. The test_connection method might appear simple, but it serves multiple purposes in our development lifecycle: validating API credentials during setup, verifying connectivity in CI/CD pipelines, and serving as a health check endpoint in production.

Finally, the retry mechanism that makes our service resilient:

    private
    
    # Implements retry logic with exponential backoff
    # @yield Block to retry on failure
    def with_retries
      retries = 0
      begin
        yield
      rescue RateLimitError => e
        # Special handling for rate limits with exponential backoff
        retries += 1
        if retries <= MAX_RETRIES
          sleep_time = RETRY_DELAY * (2 ** (retries - 1)) # Exponential backoff
          Rails.logger.warn "[Llm::Service] Rate limited, retry #{retries}/#{MAX_RETRIES} in #{sleep_time}s"
          sleep sleep_time
          retry
        else
          Rails.logger.error "[Llm::Service] Max retries exceeded"
          raise
        end
      rescue StandardError => e
        # General error retry with fixed delay
        retries += 1
        if retries <= MAX_RETRIES
          Rails.logger.warn "[Llm::Service] Error occurred, retrying"
          sleep RETRY_DELAY
          retry
        else
          Rails.logger.error "[Llm::Service] Max retries exceeded"
          raise
        end
      end
    end
  end
end

The with_retries method employs two different strategies: exponential backoff for rate limits (increasing the delay between retries) and fixed delay for general errors. This distinction is important—rate limits often require progressively longer waits to allow provider resources to recover, while transient network issues might resolve quickly.

We set MAX_RETRIES to 3, striking a balance between persistence and pragmatism. Too few retries and we might abandon salvageable requests; too many and we could delay user feedback or waste resources on unsalvageable requests.

This service layer abstracts all the complexity of LLM interactions, providing our application with a reliable, consistent interface regardless of which provider we’re using. The combination of factory pattern, retry logic, and error handling creates a robust foundation that can weather the unpredictable nature of external API services.

Provider Interface

Like a character class that establishes core abilities, our base provider defines the interface that all LLM implementations must follow:

# app/services/llm/providers/base.rb
module Llm
 module Providers
   class Base
     attr_reader :config
     
     def initialize(config)
       @config = config
     end
     
     # Core interface methods that all providers must implement
     def chat(messages:, system_prompt: nil)
       raise NotImplementedError, "#{self.class} must implement #chat"
     end
     
     def chat_with_schema(messages:, system_prompt: nil, schema:, provider_config: nil)
       raise NotImplementedError, "#{self.class} must implement #chat_with_schema"
     end
     
     def test_connection
       raise NotImplementedError, "#{self.class} must implement #test_connection"
     end
     
     protected
     
     # Helper for validating required configuration keys
     def validate_config!(*required_keys)
       missing_keys = required_keys.select { |key| config[key].nil? }
       return if missing_keys.empty?
       
       raise Llm::Service::ConfigurationError,
             "Missing required configuration keys: #{missing_keys.join(', ')}"
     end
   end
 end
end

This base class sets clear expectations for all provider implementations. The validate_config! method ensures that providers fail fast if they’re missing required configuration, while the interface methods establish a contract that all providers must fulfill.

Factory Pattern and Configuration

The factory pattern allows us to switch LLM providers at runtime without changing our application code:

# app/services/llm/factory.rb
module Llm
 class Factory
   class << self
     def create_provider
       provider_name = Rails.configuration.llm.provider
       config = Rails.configuration.llm.providers[provider_name]
       
       provider_class = case provider_name.to_sym
                       when :anthropic
                         Llm::Providers::Anthropic
                       when :openai
                         Llm::Providers::Openai
                       when :mock
                         Llm::Providers::Mock
                       else
                         raise Llm::Service::ConfigurationError, "Unknown provider: #{provider_name}"
                       end
       
       provider_class.new(config)
     rescue StandardError => e
       Rails.logger.error "[Llm::Factory] Provider creation failed: #{e.message}"
       raise Llm::Service::ConfigurationError, "Provider initialization failed: #{e.message}"
     end
   end
 end
end

Our configuration system pairs with the factory to provide environment-specific defaults:

# config/initializers/llm.rb
Rails.application.configure do
 config.llm = ActiveSupport::OrderedOptions.new
 
 # Environment-aware provider selection - like choosing the right tool for the job
 default_provider = case Rails.env
                   when 'test'
                     :mock
                   else
                     :anthropic
                   end
 
 # Allow override through environment variables
 config.llm.provider = (ENV['LLM_PROVIDER'] || default_provider).to_sym
 
 # Configuration for each supported provider
 config.llm.providers = {
   anthropic: {
     api_key: ENV['ANTHROPIC_API_KEY'],
     model: ENV['ANTHROPIC_MODEL'] || 'claude-3-5-sonnet-20241022',
     max_tokens: (ENV['ANTHROPIC_MAX_TOKENS'] || 4096).to_i,
     temperature: (ENV['ANTHROPIC_TEMPERATURE'] || 0.7).to_f
   },
   openai: {
     api_key: ENV['OPENAI_API_KEY'],
     model: ENV['OPENAI_MODEL'] || 'gpt-4-turbo-preview',
     max_tokens: (ENV['OPENAI_MAX_TOKENS'] || 4096).to_i,
     temperature: (ENV['OPENAI_TEMPERATURE'] || 0.7).to_f
   },
   mock: {
     # Mock provider doesn't need configuration
   }
 }
end

This configuration approach offers several benefits:

  1. Environment-Based Defaults: Different environments use appropriate providers.
  2. Environment Variable Overrides: Easy switching without code changes—a key advantage of the factory pattern.
  3. Sensible Defaults: Each configuration has reasonable defaults that can be overridden when needed.

Testing Strategy

Our testing approach ensures the reliability of our LLM integration through both integration and unit tests:

# test/integration/llm_service_test.rb
class LlmServiceIntegrationTest < ActionDispatch::IntegrationTest
 setup do
   @character = characters(:warrior)  # Using a fixture for consistency
 end
 
 test "generates character traits using LLM service" do
   # Mock response that the service should return
   mock_response = {
     'traits' => [
       { 'trait' => 'Brave', 'description' => 'Always ready to face danger' },
       { 'trait' => 'Loyal', 'description' => 'Stands by their companions' }
     ]
   }
   
   # Mock the prompt service for consistent testing
   prompt = { 'user_prompt' => 'Generate traits', 'system_prompt' => nil }
   Llm::PromptService.expects(:generate)
                    .with(
                      request_type: 'character_traits',
                      provider: Rails.configuration.llm.provider,
                      name: @character.name,
                      race: @character.race,
                      class_type: @character.class_type,
                      level: @character.level,
                      alignment: @character.alignment,
                      background: @character.background
                    )
                    .returns(prompt)
   
   # Mock the LLM service to return our predefined response
   Llm::Service.any_instance.expects(:chat_with_schema)
               .returns(mock_response)
   
   # Verify traits were updated
   assert_changes -> { @character.personality_traits } do
     @character.generate_traits
   end
 end
end

Our unit tests verify specific service behaviors in isolation:

# test/services/llm/service_test.rb
module Llm
 class ServiceTest < ActiveSupport::TestCase
   setup do
     @mock_provider = mock('provider')
     Llm::Factory.stubs(:create_provider).returns(@mock_provider)
     @service = Llm::Service.new
     @messages = [{ 'role' => 'user', 'content' => 'Test' }]
   end
   
   test "retries on rate limit errors" do
     mock_response = { 'background' => 'Success after retry' }
     
     sequence = sequence('retry_sequence')
     
     @mock_provider.expects(:chat)
                  .raises(Llm::Service::RateLimitError.new("Rate limited"))
                  .in_sequence(sequence)
     
     @mock_provider.expects(:chat)
                  .returns(mock_response)
                  .in_sequence(sequence)
     
     response = @service.chat(messages: @messages)
     assert_equal mock_response, response
   end
   
   test "validates schema in chat_with_schema" do
     schema = {
       'type' => 'object',
       'required' => ['background'],
       'properties' => {
         'background' => { 'type' => 'string' }
       }
     }
     
     @mock_provider.expects(:chat_with_schema)
                  .with(messages: @messages, system_prompt: nil, schema: schema)
                  .returns({ 'background' => 'Test background' })
     
     response = @service.chat_with_schema(messages: @messages, schema: schema)
     assert_equal 'Test background', response['background']
   end
 end
end

This ensures our schema enforces structure—vital for consistent AI outputs and error handling. Our tests cover both the happy path (successful LLM calls) and error conditions (rate limits, validation failures), helping us maintain reliability as the codebase evolves.

Image Generation Integration

Our LLM service architecture extends to image generation for character portraits. This service follows the same patterns but with specialized abilities tailored for visual content creation.

Image generation presents unique challenges compared to text generation. While the overall service structure feels familiar, we need additional features like domain-specific prompt enhancement, external API integration, and file handling for the generated images. Let’s see how we address these challenges:

# app/services/image_generation/service.rb
module ImageGeneration
  class Service
    # Class-specific visual elements to enhance image generation prompts
    # @type [Hash<String, Array<String>>] Mapping of class names to visual elements
    CLASS_DETAILS = {
      'Wizard' => ['magical energy surrounding their hands', 'arcane symbols floating nearby'],
      'Fighter' => ['battle-worn armor details', 'warrior\'s confident stance'],
      'Cleric' => ['holy symbols', 'divine light effects', 'religious vestments'],
      # Other classes omitted for brevity
    }.freeze
    
    attr_reader :character, :prompt_type
    
    # Factory method for generating character portraits
    # @param character [Character] The character to generate a portrait for
    # @return [CharacterPortrait] The generated portrait record
    def self.generate(character:)
      new(character: character).generate
    end

Our implementation starts with two key architectural decisions:

  1. We maintain a dictionary of class-specific visual elements (CLASS_DETAILS) to enhance prompts based on character class. This domain knowledge dramatically improves image quality by giving the AI model specific elements to include that match D&D conventions.

  2. We implement the same factory method pattern seen in our LLM service, providing a clean, simple interface for callers.

The main generation method handles the complete lifecycle of image creation, from prompt construction to saving the final image:

    # Main portrait generation method
    # @return [CharacterPortrait] The generated and saved portrait
    def generate
      logger.info "[ImageGeneration::Service] Generating portrait for character #{character.id}"
      
      # Validate character has all required attributes before proceeding
      validate_character!
      
      # Get the prompt from PromptService - reusing our LLM prompt infrastructure
      # This ensures consistent prompt structure and caching
      prompt = Llm::PromptService.generate(
        request_type: 'character_image',
        provider: provider_name,
        **character_details
      )
      
      logger.debug "[ImageGeneration::Service] Using prompt: #{prompt}"
      
      # Call Fal.ai's Recraft model for portrait generation
      # We chose fal-ai for its realistic portraits and simple API
      result = HTTP.auth("Key #{ENV['FAL_API_KEY']}")
                     .post("https://fal.run/fal-ai/recraft-v3",
                       json: {
                         prompt: prompt,
                         image_size: "portrait_4_3",
                         style: "realistic_image"
                       })
      
      response = JSON.parse(result.body.to_s)
      
      # Handle API errors with clear error messages
      if !result.status.success?
        raise Error, "Failed to generate image: #{response['error'] || 'Unknown error'}"
      end

Notice how we reuse our PromptService for generating image prompts. This demonstrates a key benefit of our architecture—components designed for one AI modality (text) can be repurposed for another (images) with minimal changes.

We’re using Fal.ai’s Recraft model instead of better-known services like DALL-E or Midjourney. This deliberate choice leverages Recraft’s strengths in realistic character portraits and simple API, showing how our factory pattern enables easy provider swapping based on specific needs.

The second half of the method handles the result processing and persistence:

      # Create and attach the image to our character's portrait collection
      # New portraits are automatically selected, unselecting previous ones
      portrait = character.character_portraits.new(
        selected: true,
        generation_prompt: prompt
      )
      
      # Download and attach the image using ActiveStorage
      # This handles file storage and variant generation
      downloaded_image = URI.open(response['images'].first['url'])
      portrait.image.attach(
        io: downloaded_image,
        filename: "portrait_#{Time.current.to_i}.png",
        content_type: 'image/png'
      )
      
      portrait.save!
      portrait
    end

Here we see Rails’ ActiveStorage seamlessly integrating with our AI service, illustrating how Rails’ ecosystem handles the full pipeline from generation to storage. We’re creating a complete CharacterPortrait record that stores the image itself, preserves the generation prompt for future use, and handles portrait selection automatically—not just retrieving a URL

Finally, our validation and helper methods ensure data integrity:

    private
    
    # Validates that character has all required attributes for portrait generation
    # @raise [Error] If any required attributes are missing
    def validate_character!
      missing_attributes = []
      missing_attributes << "race" if character.race.blank?
      missing_attributes << "class_type" if character.class_type.blank?
      missing_attributes << "level" if character.level.blank?
      
      if missing_attributes.any?
        raise Error, "Cannot generate portrait: Missing #{missing_attributes.join(', ')}"
      end
    end
    
    # Builds rich prompt context from character attributes
    # @return [Hash] Character details for prompt generation
    def character_details
      {
        name: character.name,
        race: character.race,
        class_type: character.class_type,
        level: character.level,
        alignment: character.alignment,
        class_type_details: CLASS_DETAILS[character.class_type]&.join(", ")
      }
    end
  end
end

The validate_character! method provides early validation, preventing API calls with insufficient data that would likely result in poor images. This fail-fast approach improves the user experience by providing immediate, specific error messages rather than generic API failures.

The character_details method constructs a rich context object for prompt generation, enhancing basic attributes with class-specific visual details. This exemplifies how domain knowledge can significantly improve AI-generated content through thoughtful prompt engineering.

This image generation service demonstrates how our service architecture can flex to handle different AI modalities while maintaining consistent patterns. By reusing components where appropriate (PromptService) and adding specialized functionality where needed (ActiveStorage integration), we create a cohesive system that’s both powerful and maintainable.

Connecting to Our Character Model: Where the Magic Happens

Now that we’ve established our service layer architecture, let’s bring everything together by seeing how these services integrate with our domain models. The real test of any infrastructure is how cleanly it can be used in practice—and this is where Rails’ conventions truly shine.

Here’s how our Character model leverages the service layer to generate AI content:

# app/models/character.rb
class Character < ApplicationRecord
  # Associations for managing character portraits
  has_many :character_portraits, dependent: :destroy
  has_one :selected_portrait, -> { where(selected: true) },
          class_name: 'CharacterPortrait'
  
  # Generates a background story using our LLM service
  # @return [Boolean] True if background was generated and saved successfully
  def generate_background
    # Get the prompt from our PromptService
    prompt = Llm::PromptService.generate(
      request_type: 'character_background',
      provider: Rails.configuration.llm.provider,
      name: name,
      race: race,
      class_type: class_type,
      level: level,
      alignment: alignment
    )
    
    # Send the request to our LLM service with schema validation
    response = Llm::Service.chat_with_schema(
      messages: [{ 'role' => 'user', 'content' => prompt['user_prompt'] }],
      system_prompt: prompt['system_prompt'],
      schema: prompt['schema']
    )
    
    # Update the character with the generated content
    update!(
      background: response['background'],
      personality_traits: response['personality_traits']
    )
  end
  
  # Generates a portrait using the ImageGeneration service
  # @return [CharacterPortrait] The generated portrait
  def generate_portrait
    ImageGeneration::Service.generate(character: self)
  end
end

Notice how clean and domain-focused these methods are. The generate_background method knows nothing about API keys, rate limiting, or retry strategies. It simply requests a character background and updates the model with the results. Each line tells a clear story:

  1. First, we get a prompt tailored to this specific character by passing its attributes to our PromptService
  2. Next, we send that prompt to our LLM service using schema validation to ensure structured results
  3. Finally, we update the character record with the generated content

This approach keeps our model focused on its domain responsibilities rather than AI implementation details. The generate_portrait method is even simpler—a single line that delegates to our image generation service. This clean separation of concerns exemplifies how well-designed service layers can simplify domain models.

For production applications, handling AI generation synchronously isn’t ideal, especially for longer outputs like character backgrounds. We’re planning to enhance this with background job processing:

# Future implementation - app/models/character.rb
def generate_background_async
  # Enqueue background job and update status
  GenerateBackgroundJob.perform_later(id)
  update!(generation_status: :pending)
end

# app/jobs/generate_background_job.rb
class GenerateBackgroundJob < ApplicationJob
  # @param character_id [Integer] ID of the character to generate background for
  def perform(character_id)
    character = Character.find(character_id)
    character.update!(generation_status: :generating)
    
    begin
      # Generate the background using our existing service
      prompt = Llm::PromptService.generate(
        request_type: 'character_background',
        provider: Rails.configuration.llm.provider,
        name: character.name,
        # Other character attributes...
      )
      
      response = Llm::Service.chat_with_schema(
        messages: [{ 'role' => 'user', 'content' => prompt['user_prompt'] }],
        system_prompt: prompt['system_prompt'],
        schema: prompt['schema']
      )
      
      # Update character with generated content
      character.update!(
        background: response['background'],
        personality_traits: response['personality_traits'],
        generation_status: :completed
      )
      
      # Broadcast update to UI using Turbo Streams
      broadcast_replace_to character,
        target: "character_#{character.id}_background",
        partial: "characters/background"
    rescue => e
      # Handle errors by updating status and showing error in UI
      character.update!(generation_status: :failed)
      Rails.logger.error("Background generation failed: #{e.message}")
      
      broadcast_replace_to character,
        target: "character_#{character.id}_background",
        partial: "characters/background_error"
    end
  end
end

This asynchronous approach adds three important capabilities:

  1. Status tracking: The character model tracks generation status (pending, generating, completed, failed), providing clear visibility into the process
  2. Background processing: Using ActiveJob moves generation out of the request cycle, preventing timeouts on slower LLM responses
  3. Real-time updates: Turbo Streams broadcasts updates directly to the UI when generation completes or fails

This implementation maintains all the benefits of our service architecture while adding production-ready features for a better user experience. The job still uses our existing service layer, demonstrating how well our design accommodates different execution contexts.

What’s particularly elegant about this approach is how it leverages Rails’ ecosystem. ActiveJob handles the background processing, ActiveRecord manages the character state, and Turbo Streams delivers real-time updates—all working together with our custom service layer in a cohesive system.

Conclusion

Our implementation of the LLM service layer demonstrates how Rails’ conventions naturally support complex AI integrations.

Key wins include a modular provider system, robust retry logic, and cached prompts—all built on Rails’ predictable patterns. This service layer’s modularity, clear interfaces, and Rails-aligned conventions make it both powerful and easy to maintain—proof that “boring” can be brilliant.

By following established Rails patterns like service objects, factories, and configuration management, we’ve created a system that can handle AI interactions reliably while remaining flexible for future development.

In our next article, we’ll delve into specific LLM provider implementations, focusing on how we handle structured responses, rate limiting, and token streaming with providers like Claude and GPT.

If you’re eager to see these concepts in action, the complete source code for our D&D character generator is available on GitHub. The repository includes all the components we’ve discussed, along with comprehensive documentation and setup instructions.