← Back to blog
March 14, 2026 11 min read

Bridging Ruby and Elixir: How We Built a Real-Time CPQ Engine with Erlport and Poolboy

A deep dive into the architecture behind our CPQ platform — how we connect a battle-tested Ruby rule engine to Phoenix LiveView using Erlport process pooling and BEAM message passing to deliver real-time product configuration for manufacturers.

elixir phoenix cpq architecture ruby engineering
001

Most companies building product configuration software face a brutal choice: rewrite a decade of battle-tested business logic from scratch, or stay locked into an aging runtime that can’t keep up with real-time demands.

We chose neither.

Our CPQ platform — Painless — runs a Ruby-based rule engine inside the BEAM virtual machine, managed by Elixir processes, pooled by Poolboy, and wired directly into Phoenix LiveView over WebSockets. The result is a configuration experience where a dealer changes a dropdown and sees price, options, and the full BOM update in under 80 milliseconds — powered by a rule engine that has already solved thousands of manufacturing edge cases.

This post is a look under the hood.

The Problem: Two Runtimes, One User Experience

Our CPQ rule engine is written in Ruby. It encodes the deep, gnarly logic of configurable product manufacturing — thousands of interdependent rules covering materials, dimensions, pricing tiers, regional codes, compatibility constraints, and bill of materials generation. This isn’t code you rewrite on a whim. It represents years of domain knowledge crystallized into software.

But Ruby’s concurrency model isn’t built for what we need at the web layer: hundreds of dealers simultaneously configuring products in real time, each expecting instant feedback as they change options.

Phoenix LiveView gives us that real-time layer. Every user gets a persistent WebSocket connection. Every configuration change triggers a server-side re-render. The UI updates without page reloads, API calls, or frontend state management.

The architecture challenge: connecting a Ruby computation engine to an Elixir web layer with the performance and reliability that manufacturing demands.

The Architecture: Erlport + Poolboy + Message Passing

Erlport: Ruby Inside the BEAM

Erlport spawns external language runtimes as OS processes and communicates with them using the Erlang External Term Format — the same binary protocol BEAM nodes use to talk to each other. No JSON serialization, no HTTP overhead, no request/response cycle. Direct binary-encoded message passing between Elixir and Ruby.

defmodule Painless.Cpq.RubyWorker do
  use GenServer

  def init(_opts) do
    ruby_path = Application.app_dir(:painless, "priv/ruby")

    {:ok, pid} =
      :erlport.open({:ruby, ~c"cpq_worker"},
        cd: ruby_path,
        compressed: 5
      )

    {:ok, %{ruby: pid}}
  end

  def handle_call({:configure, product_id, selections}, _from, %{ruby: pid} = state) do
    result = :erlport.call(pid, :CpqEngine, :configure, [product_id, selections])
    {:reply, {:ok, result}, state}
  end
end

Each RubyWorker is a GenServer that owns a persistent Ruby OS process — no cold starts, no interpreter boot penalty between calls.

On the Ruby side, cpq_worker.rb is the file Erlport loads. It’s the bridge between the BEAM and the CPQ rule engine:

require "erlport"
require_relative "cpq_engine"

module CpqEngine
  def self.configure(product_id, selections)
    product = ProductRegistry.find(product_id)
    context = ConfigurationContext.new(product, to_hash(selections))
    context.evaluate

    {
      price: context.total_price,
      available_options: context.available_options,
      warnings: context.validation_warnings,
      bom: context.bill_of_materials
    }
  end

  def self.to_hash(selections)
    selections.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
  end
end

The ConfigurationContext is where the DSL comes alive — it loads the product’s rule definitions, binds the user’s selections into an evaluation scope, and runs every field, constraint, and pricing rule in a single pass. Erlport handles the serialization transparently: Elixir maps become Ruby hashes, atoms become symbols, and the return value flows back as native Elixir terms.

Poolboy: Bounded Concurrency

A single Ruby process handles one computation at a time. Hundreds of concurrent users need something more. Poolboy manages a pool of Ruby workers with automatic checkout, checkin, overflow, and timeout handling.

defmodule Painless.Cpq.Pool do
  def child_spec(_opts) do
    :poolboy.child_spec(:cpq_pool,
      name: {:local, :cpq_pool},
      worker_module: Painless.Cpq.RubyWorker,
      size: 10,
      max_overflow: 5
    )
  end

  def configure(product_id, selections) do
    :poolboy.transaction(:cpq_pool, fn worker ->
      GenServer.call(worker, {:configure, product_id, selections}, 15_000)
    end)
  end
end

Ten warm Ruby processes, five overflow for spikes. Bounded resource usage with elastic capacity. The BEAM manages the pool, handles timeouts, and restarts crashed workers through its supervision tree.

The Data Flow

Here’s what happens when a dealer changes a retractable screen’s install type from Standard to Recessed:

  1. Browser → LiveView: The selection fires a phx-change event over the WebSocket.
  2. LiveView → Pool: handle_event/3 calls Cpq.Pool.configure/2 with the updated selections.
  3. Pool → Ruby Worker: Poolboy checks out an available worker. The GenServer sends the request via Erlport.
  4. Ruby CPQ Engine: The Ruby process evaluates the full rule chain — field visibility recalculates, dimensional constraints shift, the roller size options appear, pricing updates, the BOM regenerates.
  5. Ruby → LiveView: The result flows back through Erlport → GenServer → Poolboy → LiveView.
  6. LiveView → Browser: LiveView diffs the HTML and pushes only the changed DOM nodes.

Total round-trip: 30–80ms. The dealer sees updated prices, valid options, and validation warnings instantly.

┌─────────┐   WebSocket    ┌──────────┐   GenServer.call   ┌──────────────┐
│ Browser  │ ◄────────────► │ LiveView │ ◄────────────────► │   Poolboy     │
│ (HTML)   │   phx-change   │ Process  │   checkout/checkin  │   :cpq_pool   │
└─────────┘                 └──────────┘                    └──────┬───────┘
                                                                   │
                                                          Erlport (binary)
                                                                   │
                                                            ┌──────▼───────┐
                                                            │ Ruby Process │
                                                            │  CPQ Engine  │
                                                            └──────────────┘

Why Ruby Stays — And Why That’s a Feature

This isn’t legacy code we’re tolerating. Ruby is the right language for this layer.

Our CPQ engine is built on Ruby DSLs — domain-specific languages that let manufacturers express configuration logic in syntax that reads like the business rules themselves. Here’s the shape of a real product definition for a motorized retractable screen:

configurable do
  step "Configuration" do
    field :width do
      type Numeric
      required true
      input { label "Width"; component "fractional-dimension" }

      check do
        condition  -> { width.present? && housing_size == "5.5\"" }
        constraint -> { width <= 270 }
        message "Maximum width for 5.5\" housing is 270\"."
        impacts [:housing_size]
      end
    end

    field :roller_size do
      type String
      required -> { install_type == "Recessed" }
      visible  -> { install_type == "Recessed" }
      options  { choice "3\""; choice "4\""; choice "5\"" }
    end
  end

  step "Screen" do
    field :screen_option do
      option_set do
        active -> { screen_type == "Insect" }
        choice_set ["18/14", "Tuffscreen", "Custom"]
      end
      option_set do
        active -> { screen_type == "Sunscreen" }
        choice_set ["Sheerweave 90%", "Suntex 95", "Custom"]
      end
    end
  end
end

computed do
  field :base_price do
    PriceTable.lookup(
      ["Motorized", install_type, screen_type].join(" - "),
      x: width, y: height
    )
  end
end

accounting do
  bom_item "Motorized Screen" do
    price { (base_price * (1 + size_markup_percent)).round }
  end

  bom_item "Hand Crank Deduction" do
    condition -> { motor == "Hand Crank" }
    price -220.00
  end

  bom_item "Somfy Remote" do
    quantity  -> { remote_quantity.to_i }
    condition -> { somfy_motor? && remote_control == "16 Channel" }
    price 150.00
  end
end

A real product definition has dozens of steps — housing, tracks, bottom bar, vinyl layout, seam placement, motorization — each with their own fields, cross-field constraints, conditional visibility, and dynamic option sets. But even this excerpt shows what makes the DSL approach powerful.

Every rule is a runtime lambda. required -> { install_type == "Recessed" } is evaluated dynamically against the current configuration state. When the user changes install type, the engine re-evaluates every field’s visibility, requirement status, validation, and available options in a single pass. Ruby’s method_missing and instance_eval make field names like width and housing_size resolve directly against the configuration context — no boilerplate declarations, no mapping layers.

Options react to selections. The screen_option field swaps its entire choice set based on which screen type is selected. This pattern repeats everywhere: motor type constrains remote options, install type constrains track types, roller size constrains maximum dimensions.

The BOM assembles itself. Each bom_item in the accounting block has a condition lambda — it only materializes on the order when its condition is true. Quantities and prices can be lambdas referencing computed fields. The entire bill of materials is an emergent artifact of the user’s selections.

Execution is sandboxed. Each product’s rule set runs in an isolated evaluation context using Ruby’s BasicObject subclassing and instance_eval. One manufacturer’s pricing can’t leak into another’s. PriceTable.lookup resolves against tenant-scoped data. This is how a single pool of Ruby workers serves dozens of manufacturers safely.

You could try to replicate this in Elixir — but you’d end up building a DSL interpreter, at which point you’ve just recreated what Ruby gives you natively. Better to let each language do what it does best.

Production Guarantees

Fault Isolation

Each Ruby worker runs in its own OS process, supervised by a GenServer, managed by Poolboy, rooted in the application supervision tree. A crashed Ruby process — malformed input, unexpected nil, memory spike — gets detected, terminated, and replaced automatically. No other user’s session is affected.

Backpressure

Poolboy’s fixed pool size is natural backpressure. If all 15 workers are busy, the 16th request queues rather than spawning an uncontrolled process. The system degrades gracefully under load rather than collapsing. The 15-second timeout on :poolboy.transaction/3 means LiveView can show a retry prompt instead of a frozen screen.

Observability

Every Ruby call flows through a GenServer, which means every call is instrumentable:

def handle_call({:configure, product_id, selections}, _from, %{ruby: pid} = state) do
  {time_us, result} = :timer.tc(fn ->
    :erlport.call(pid, :CpqEngine, :configure, [product_id, selections])
  end)

  :telemetry.execute([:painless, :cpq, :configure], %{duration: time_us}, %{product_id: product_id})
  {:reply, {:ok, result}, state}
end

We track p50, p95, and p99 latencies per product type. When Ruby computation time spikes, we trace it back to specific rule chains. This telemetry feeds into LiveDashboard and our alerting pipeline — the same observability tools the rest of the Elixir ecosystem uses.

LiveView: Where It Comes Together

The LiveView layer is where the architecture pays its dividends:

defmodule PainlessWeb.ConfiguratorLive do
  use PainlessWeb, :live_view

  def handle_event("update_selection", %{"option" => key, "value" => val}, socket) do
    selections = Map.put(socket.assigns.selections, key, val)

    case Painless.Cpq.Pool.configure(socket.assigns.product_id, selections) do
      {:ok, result} ->
        {:noreply,
         assign(socket,
           selections: selections,
           price: result.price,
           available_options: result.available_options,
           warnings: result.warnings,
           bom: result.bom
         )}

      {:error, :timeout} ->
        {:noreply, put_flash(socket, :error, "Taking longer than expected. Please try again.")}
    end
  end
end

No API layer. No frontend state management. No loading spinners. The user changes an option, the server computes the full result, and the UI updates — all within a single WebSocket round-trip.

When a dealer is on the phone with a homeowner, walking through options for a 20-unit retractable screen order, this responsiveness is the difference between closing the sale and losing it. When a manufacturer’s inside sales team is processing 200 quotes before lunch, it’s the difference between keeping up and falling behind.

Why This Matters

Most CPQ platforms are built by software companies that know software but learn manufacturing along the way. We went the other direction. We spent a decade in the configurable products industry — understanding how dealers quote, how manufacturers price, how orders flow from configuration through production to installation — and then chose the technology stack that solves those specific problems.

The Ruby DSL engine exists because implementation engineers shouldn’t need to be programmers to define product rules. Erlport and Poolboy exist in our stack because real-time responsiveness during quoting directly impacts close rates. Phoenix LiveView exists because dealers shouldn’t need to install anything or wait for page loads when they’re configuring on-site with a customer.

Every architectural decision traces back to a business outcome.

If you’re a manufacturer whose CPQ system can’t keep up with your product complexity — or a dealer whose quoting tool feels like it was built for a different industry — we should talk. We’ve spent 10 years building exactly the software your business can’t find off the shelf.

002
Written by ParableSoft Team

Building software for manufacturers, distributors, and dealers of configurable products.

newsletter.subscribe
// STAY UPDATED

Get engineering insights delivered.

New posts on Elixir, Phoenix, manufacturing tech, and building software for complex industries.