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:
-
Browser → LiveView: The selection fires a
phx-changeevent over the WebSocket. -
LiveView → Pool:
handle_event/3callsCpq.Pool.configure/2with the updated selections. - Pool → Ruby Worker: Poolboy checks out an available worker. The GenServer sends the request via Erlport.
- 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.
- Ruby → LiveView: The result flows back through Erlport → GenServer → Poolboy → LiveView.
- 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.