• Skip to secondary menu
  • Skip to main content
  • Skip to primary sidebar
  • Home
  • Projects
  • Products
  • Themes
  • Tools
  • Request for Quote

Vengala Vinay

Having 9+ Years of Experience in Software Development

  • Home
  • WordPress
  • PHP
    • Codeigniter
  • Django
  • Magento
  • Selenium
  • Server
Home » Refactoring Monolithic Legacy Legacy Ruby on Rails 4.x Into Modern Rails 7.x (Modernized) Microservices

Refactoring Monolithic Legacy Legacy Ruby on Rails 4.x Into Modern Rails 7.x (Modernized) Microservices

Strategic Decomposition: Identifying Bounded Contexts in Rails 4.x

The initial, and arguably most critical, step in refactoring a monolithic Rails 4.x application into microservices is the precise identification of Bounded Contexts. This isn’t merely about extracting loosely coupled modules; it’s about defining clear boundaries where a specific domain model is consistent and authoritative. For a legacy Rails 4.x monolith, this often involves a deep dive into the existing codebase, tracing data flows, and understanding business capabilities. We’ll use a hypothetical e-commerce monolith as our example, focusing on the ‘Order Management’ and ‘Product Catalog’ domains.

In Rails 4.x, these domains are likely intertwined within models, controllers, and services. A common pattern is to analyze the `app/models` directory, looking for clusters of models that heavily interact with each other and represent a cohesive business concept. For ‘Order Management’, we’d expect to see `Order`, `OrderItem`, `Shipment`, `Payment`, and potentially `User` (though `User` might be a separate, shared context).

Consider the `Order` model. In a monolith, it might have associations and logic that touch `Product` models, `User` models, `PaymentGateway` integrations, and `ShippingProvider` APIs. The goal of decomposition is to isolate this logic. The ‘Order Management’ context should own the lifecycle of an order, from creation to fulfillment, including its associated payments and shipments. The ‘Product Catalog’ context, conversely, should be responsible for product data, pricing, inventory (if separate), and categories.

Extracting the ‘Product Catalog’ Microservice

Let’s begin by extracting the ‘Product Catalog’ domain. This service will be responsible for managing products, their attributes, categories, and potentially pricing. We’ll aim for a new Rails 7.x application, leveraging modern conventions.

First, set up the new Rails 7.x microservice. We’ll use a minimal setup, perhaps without Action Mailer or Active Job initially, as these might be handled by a separate ‘Notification’ or ‘Background Jobs’ service later.

New Rails 7.x Application Setup

Create the new Rails application:

rails new product_catalog_service --api --skip-active-storage --skip-action-mailer --skip-action-mailbox --skip-action-text --skip-jbuilder
cd product_catalog_service
bundle install

Database Schema for ‘Product Catalog’

Define the schema for the ‘Product Catalog’ service. This will likely mirror the relevant parts of the monolith’s `products` and `categories` tables.

Create a migration for the `products` table:

# db/migrate/YYYYMMDDHHMMSS_create_products.rb
class CreateProducts << ActiveRecord::Migration[7.0]
  def change
    create_table :products do |t|
      t.string :name, null: false
      t.text :description
      t.decimal :price, precision: 10, scale: 2, null: false
      t.integer :stock_quantity, default: 0
      t.boolean :published, default: false
      t.references :category, foreign_key: true

      t.timestamps
    end
  end
end

And for the `categories` table:

# db/migrate/YYYYMMDDHHMMSS_create_categories.rb
class CreateCategories << ActiveRecord::Migration[7.0]
  def change
    create_table :categories do |t|
      t.string :name, null: false
      t.string :slug, null: false, index: { unique: true }
      t.references :parent, foreign_key: { to_table: :categories }

      t.timestamps
    end
  end
end

Run the migrations:

rails db:create db:migrate

Models and Associations

Define the corresponding ActiveRecord models. Note the absence of `User` associations directly within `Product` or `Category` models, as `User` would be managed by a separate ‘User Management’ microservice.

# app/models/category.rb
class Category < ApplicationRecord
  has_many :products, dependent: :restrict_with_error
  has_many :subcategories, class_name: 'Category', foreign_key: 'parent_id'
  belongs_to :parent, class_name: 'Category', optional: true

  validates :name, presence: true, uniqueness: { case_sensitive: false }
  validates :slug, presence: true, uniqueness: true

  before_validation :generate_slug, on: :create

  private

  def generate_slug
    self.slug ||= name.parameterize
  end
end
# app/models/product.rb
class Product < ApplicationRecord
  belongs_to :category, optional: true # Allow products without a category for now

  validates :name, presence: true
  validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 }
  validates :stock_quantity, numericality: { greater_than_or_equal_to: 0 }

  scope :published, -> { where(published: true) }
  scope :in_stock, -> { where('stock_quantity > 0') }
end

API Endpoints (Controllers)

Expose the product catalog functionality via a JSON API. We’ll use Rails API controllers.

# app/controllers/api/v1/products_controller.rb
module Api
  module V1
    class ProductsController < ApplicationController
      before_action :set_product, only: [:show, :update, :destroy]

      def index
        @products = Product.all.includes(:category)
        # Add filtering/pagination here in a real-world scenario
        render json: @products, each_serializer: ProductSerializer, status: :ok
      end

      def show
        render json: @product, serializer: ProductSerializer, status: :ok
      end

      def create
        @product = Product.new(product_params)
        if @product.save
          render json: @product, serializer: ProductSerializer, status: :created
        else
          render json: { errors: @product.errors }, status: :unprocessable_entity
        end
      end

      def update
        if @product.update(product_params)
          render json: @product, serializer: ProductSerializer, status: :ok
        else
          render json: { errors: @product.errors }, status: :unprocessable_entity
        end
      end

      def destroy
        @product.destroy
        head :no_content
      end

      private

      def set_product
        @product = Product.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: 'Product not found' }, status: :not_found
      end

      def product_params
        params.require(:product).permit(:name, :description, :price, :stock_quantity, :published, :category_id)
      end
    end
  end
end

Create a simple serializer for the product data:

# app/serializers/product_serializer.rb
class ProductSerializer < ActiveModel::Serializer
  attributes :id, :name, :description, :price, :stock_quantity, :published, :category_id, :created_at, :updated_at

  belongs_to :category
end

Define the API routes:

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :products, except: [:new, :edit]
      resources :categories, except: [:new, :edit]
    end
  end
end

Configuration for Inter-Service Communication

The ‘Product Catalog’ service will need to be discoverable and accessible by other services, particularly the ‘Order Management’ service. This typically involves:

  • Service Discovery: In a Kubernetes environment, this is handled by DNS. In a simpler setup, a configuration file or environment variables might specify the service’s URL.
  • API Gateway: An API Gateway (e.g., Kong, Apigee, or a custom Nginx/Envoy setup) will route requests to the appropriate microservice.
  • Authentication/Authorization: This is crucial. The ‘Product Catalog’ service might rely on an external ‘Auth Service’ or expect JWTs validated by an API Gateway. For simplicity here, we’ll assume basic authentication is handled at the gateway level.

For inter-service communication, we’ll use HTTP. The ‘Order Management’ service will make HTTP requests to the ‘Product Catalog’ service’s API endpoints.

Migrating Data for ‘Product Catalog’

Once the new service is functional, data migration is required. This is a phased approach:

  • One-time Data Dump: Extract product and category data from the monolith’s database.
  • Data Transformation: Cleanse and transform the data to fit the new schema.
  • Bulk Import: Load the transformed data into the ‘Product Catalog’ service’s database.
  • Ongoing Synchronization: For a period, changes in the monolith’s product data need to be synchronized to the new service. This can be achieved via database triggers, change data capture (CDC) tools, or periodic batch jobs.

A simple script to dump data from the monolith (assuming it’s also Rails 4.x):

# In the monolith application's Rakefile or a dedicated script
namespace :data_export do
  desc "Export products and categories to CSV"
  task export_products_and_categories: :environment do
    require 'csv'

    # Export Categories
    categories_file = 'categories_export.csv'
    CSV.open(categories_file, 'wb') do |csv|
      csv << ['id', 'name', 'slug', 'parent_id', 'created_at', 'updated_at']
      Category.find_each do |category|
        csv << [category.id, category.name, category.slug, category.parent_id, category.created_at, category.updated_at]
      end
    end
    puts "Categories exported to #{categories_file}"

    # Export Products
    products_file = 'products_export.csv'
    CSV.open(products_file, 'wb') do |csv|
      csv << ['id', 'name', 'description', 'price', 'stock_quantity', 'published', 'category_id', 'created_at', 'updated_at']
      Product.find_each do |product|
        csv << [product.id, product.name, product.description, product.price, product.stock_quantity, product.published, product.category_id, product.created_at, product.updated_at]
      end
    end
    puts "Products exported to #{products_file}"
  end
end

And a script to import into the new service (run from the new service’s directory):

# import_products.rb (a standalone Ruby script)
require 'csv'
require 'active_record'

# Configure ActiveRecord connection for the new service's database
ActiveRecord::Base.establish_connection(YAML.load_file('config/database.yml')['development']) # Adjust environment as needed

# Load models
Dir.glob('./app/models/*.rb').each { |file| require file }

# Import Categories
categories_file = '../monolith/categories_export.csv' # Path relative to this script
CSV.foreach(categories_file, headers: true) do |row|
  Category.find_or_create_by!(id: row['id'].to_i) do |category|
    category.name = row['name']
    category.slug = row['slug']
    category.parent_id = row['parent_id'].present? ? row['parent_id'].to_i : nil
    category.created_at = Time.parse(row['created_at'])
    category.updated_at = Time.parse(row['updated_at'])
  end
  puts "Imported/Found Category: #{row['name']}"
end

# Import Products
products_file = '../monolith/products_export.csv' # Path relative to this script
CSV.foreach(products_file, headers: true) do |row|
  Product.find_or_create_by!(id: row['id'].to_i) do |product|
    product.name = row['name']
    product.description = row['description']
    product.price = row['price']
    product.stock_quantity = row['stock_quantity'].to_i
    product.published = row['published'] == 'true'
    product.category_id = row['category_id'].present? ? row['category_id'].to_i : nil
    product.created_at = Time.parse(row['created_at'])
    product.updated_at = Time.parse(row['updated_at'])
  end
  puts "Imported/Found Product: #{row['name']}"
end

puts "Import complete."

Refactoring ‘Order Management’ to Consume the Microservice

Now, we need to modify the monolith (or a new ‘Order Management’ microservice) to interact with the ‘Product Catalog’ service instead of its local product data.

Introducing an HTTP Client

In the existing Rails 4.x monolith, we’ll introduce an HTTP client. The `httparty` gem is a good choice for its simplicity.

# Gemfile (in the monolith)
gem 'httparty'

Run `bundle install` in the monolith.

Service Client Class

Create a client class to abstract the communication with the ‘Product Catalog’ service.

# app/services/product_catalog_client.rb (in the monolith)
class ProductCatalogClient
  include HTTParty
  base_uri ENV.fetch('PRODUCT_CATALOG_SERVICE_URL', 'http://localhost:3001/api/v1') # Use environment variable for URL

  # Add authentication headers if required (e.g., API keys, JWTs)
  # headers 'Authorization' => "Bearer #{ENV['PRODUCT_API_KEY']}"

  def self.get_product(product_id)
    response = get("/products/#{product_id}")
    JSON.parse(response.body) if response.success?
  rescue HTTParty::Error => e
    Rails.logger.error "ProductCatalogClient Error: #{e.message}"
    nil
  end

  def self.get_products(product_ids = [])
    # In a real scenario, you'd want to batch this or use a specific endpoint for multiple IDs
    # For simplicity, we'll fetch them individually or assume a bulk endpoint exists
    # If product_ids is empty, fetch all published products
    if product_ids.empty?
      response = get('/products?published=true') # Assuming a filter exists
    else
      # This is inefficient for many IDs. A POST request with IDs in the body is better.
      # Or a dedicated /products/batch endpoint.
      products_data = product_ids.map do |id|
        get_product(id)
      end.compact
      return products_data
    end
    JSON.parse(response.body) if response.success?
  rescue HTTParty::Error => e
    Rails.logger.error "ProductCatalogClient Error: #{e.message}"
    []
  end

  # Add methods for categories if needed by order management
  def self.get_category(category_id)
    response = get("/categories/#{category_id}")
    JSON.parse(response.body) if response.success?
  rescue HTTParty::Error => e
    Rails.logger.error "ProductCatalogClient Error: #{e.message}"
    nil
  end
end

Modifying the ‘Order’ Model

Now, modify the `Order` model and related logic in the monolith to use the `ProductCatalogClient`.

# app/models/order.rb (in the monolith)
class Order < ApplicationRecord
  has_many :order_items, dependent: :destroy
  # ... other associations and logic

  # Before, this might have done:
  # belongs_to :product
  # Or had order_items directly referencing product_id

  # After refactoring, we fetch product details on demand or store a denormalized snapshot
  # Option 1: Fetch product details when needed (e.g., for display)
  def product_details(product_id)
    @product_details ||= {}
    @product_details[product_id] ||= ProductCatalogClient.get_product(product_id)
  end

  # Option 2: Denormalize essential product data into OrderItem upon creation/update
  # This reduces read-time dependency on the Product Catalog service but requires
  # careful synchronization if product details change.

  # Example of modifying order creation to use the client
  def self.create_with_items(user_id:, items_attributes:)
    order = new(user_id: user_id)
    items_attributes.each do |item_attr|
      product_id = item_attr[:product_id]
      quantity = item_attr[:quantity]

      # Fetch product details from the microservice
      product_data = ProductCatalogClient.get_product(product_id)

      if product_data.nil?
        raise "Product with ID #{product_id} not found or service unavailable."
      end

      # Optional: Check stock availability (if the service exposes this)
      # if product_data['stock_quantity'].to_i < quantity.to_i
      #   raise "Insufficient stock for product #{product_data['name']}."
      # end

      # Calculate price based on fetched data
      price_per_unit = BigDecimal(product_data['price'])
      line_item_total = price_per_unit * quantity.to_i

      # Create OrderItem, potentially storing denormalized data
      order.order_items.build(
        product_id: product_id,
        product_name: product_data['name'], # Denormalized
        quantity: quantity,
        price_per_unit: price_per_unit,     # Denormalized
        total_price: line_item_total        # Denormalized
      )
    end

    # Save the order and its items
    order.save!
    order
  end

  # Method to calculate total order price using denormalized data
  def total_price
    order_items.sum(:total_price)
  end

  # ... rest of the Order model
end

And modify `OrderItem` if denormalization is used:

# app/models/order_item.rb (in the monolith)
class OrderItem < ApplicationRecord
  belongs_to :order
  # belongs_to :product # This association might be removed or changed to reference the external product ID

  # If denormalizing:
  # attribute :product_id, :integer # Still store the ID for reference
  # attribute :product_name, :string
  # attribute :price_per_unit, :decimal, precision: 10, scale: 2
  # attribute :quantity, :integer
  # attribute :total_price, :decimal, precision: 10, scale: 2

  # If not denormalizing, you'd fetch product details here when needed
  # def product_name
  #   ProductCatalogClient.get_product(product_id)&#39;name&#39;
  # end
end

Configuration for the Monolith

Ensure the monolith’s environment is configured with the URL of the ‘Product Catalog’ service.

# config/application.yml or .env file in the monolith
PRODUCT_CATALOG_SERVICE_URL=http://product-catalog-service.internal:3001/api/v1
# Or for local development:
# PRODUCT_CATALOG_SERVICE_URL=http://localhost:3001/api/v1

Handling Shared Concerns: Users and Authentication

User management and authentication are classic examples of shared concerns that should ideally be extracted into their own dedicated microservice. The monolith and the new ‘Product Catalog’ service would then delegate user-related operations and authentication checks to this ‘User Service’.

User Service (Conceptual)

A ‘User Service’ would typically expose endpoints for:

  • User registration and login.
  • User profile management.
  • Token generation (e.g., JWT).
  • Token validation.
  • Authorization checks (e.g., role-based access control).

Authentication Flow with JWT

A common pattern is using JSON Web Tokens (JWTs):

  • The client (e.g., a frontend application) authenticates with the ‘User Service’, receiving a JWT.
  • The client includes this JWT in the `Authorization: Bearer <token>` header for all subsequent requests to any microservice.
  • An API Gateway or a shared middleware in each microservice validates the JWT. This validation typically involves checking the signature against the ‘User Service’s’ public key and verifying claims (e.g., expiration, user ID, roles).
  • If the token is valid, the user’s identity and permissions are established for the request.

The ‘Product Catalog’ service and the ‘Order Management’ service (whether it’s the refactored monolith or a new service) would both rely on this JWT validation mechanism, likely implemented at the API Gateway level or via a shared library/gem.

Iterative Refinement and Future Steps

This process is iterative. After successfully extracting ‘Product Catalog’, the next step would be to extract ‘Order Management’ into its own service, further breaking down the monolith. Other domains like ‘Payments’, ‘Shipping’, or ‘Inventory’ would follow similar patterns.

Key considerations for ongoing refinement:

  • Event-Driven Architecture: For better decoupling, consider using message queues (e.g., RabbitMQ, Kafka) for inter-service communication, especially for asynchronous tasks like order fulfillment notifications.
  • Database per Service: Each microservice should ideally manage its own database to maintain true autonomy. Data synchronization strategies become even more critical here.
  • Observability: Implement robust logging, monitoring, and tracing across all services to diagnose issues effectively in a distributed system. Tools like Prometheus, Grafana, ELK stack, and Jaeger are invaluable.
  • Testing: Develop comprehensive integration tests that verify interactions between services, alongside unit tests for individual service logic.

Migrating a Rails 4.x monolith to microservices is a significant undertaking. By strategically identifying Bounded Contexts, incrementally extracting services, and carefully managing inter-service communication and data, you can successfully modernize your application architecture.

Primary Sidebar

A little about the Author

Having 9+ Years of Experience in Software Development.
Expertised in Php Development, WordPress Custom Theme Development (From scratch using underscores or Genesis Framework or using any blank theme or Premium Theme), Custom Plugin Development. Hands on Experience on 3rd Party Php Extension like Chilkat, nSoftware.

Recent Posts

  • Disaster Recovery 101: Architecting Auto-Failovers for Redis and PHP Deployments on OVH
  • How We Audited a High-Traffic WooCommerce Enterprise Stack on Google Cloud and Mitigated Race conditions during high-concurrency payment processing
  • Disaster Recovery 101: Architecting Auto-Failovers for Elasticsearch and Magento 2 Deployments on DigitalOcean
  • An Auditor’s Checklist for Securing WordPress Backends on OVH
  • Step-by-Step: Diagnosing Perl script high CPU throttling due to unoptimized regular expressions on AWS Servers

Copyright © 2026 · Vinay Vengala