<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Seryl Lns</title>
    <description>The latest articles on DEV Community by Seryl Lns (@seryllns_).</description>
    <link>https://dev.to/seryllns_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2014485%2Fe26696c0-ca8c-4e48-abbe-6757b2a55109.png</url>
      <title>DEV Community: Seryl Lns</title>
      <link>https://dev.to/seryllns_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/seryllns_"/>
    <language>en</language>
    <item>
      <title>Building a Rails Engine #16 --Publishing to RubyGems &amp; Retrospective</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Thu, 09 Apr 2026 12:06:10 +0000</pubDate>
      <link>https://dev.to/seryllns_/building-a-rails-engine-16-publishing-to-rubygems-retrospective-3l0p</link>
      <guid>https://dev.to/seryllns_/building-a-rails-engine-16-publishing-to-rubygems-retrospective-3l0p</guid>
      <description>&lt;h1&gt;
  
  
  Publishing to RubyGems &amp;amp; Retrospective
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;From &lt;code&gt;bundle gem&lt;/code&gt; to &lt;code&gt;gem push&lt;/code&gt;: looking back at 14 articles, 20 components, and the lessons learned building a Rails engine from scratch with TDD.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;This is the final article in the series where we build &lt;strong&gt;DataPorter&lt;/strong&gt;, a mountable Rails engine for data import workflows. In &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-14-dry-run-validate-before-you-import-1gl5"&gt;part 14&lt;/a&gt;, we added Dry Run mode -- the last safety net before data touches the database.&lt;/p&gt;

&lt;p&gt;We started this series with a question: why do we keep rebuilding the same import workflow in every Rails app? Fourteen articles later, we have a published gem that answers it. This article covers the last mile -- publishing to RubyGems -- then steps back to look at what we built, what we learned, and what we would do differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing the gem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The gemspec
&lt;/h3&gt;

&lt;p&gt;The interesting parts of the gemspec are not the metadata -- they are the constraints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# data_porter.gemspec&lt;/span&gt;
&lt;span class="no"&gt;Gem&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Specification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"data_porter"&lt;/span&gt;
  &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VERSION&lt;/span&gt;
  &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;required_ruby_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 3.2.0"&lt;/span&gt;

  &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"rubygems_mfa_required"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt;

  &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_dependency&lt;/span&gt; &lt;span class="s2"&gt;"csv"&lt;/span&gt;
  &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_dependency&lt;/span&gt; &lt;span class="s2"&gt;"phlex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.0"&lt;/span&gt;
  &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_dependency&lt;/span&gt; &lt;span class="s2"&gt;"rails"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 7.0"&lt;/span&gt;
  &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_dependency&lt;/span&gt; &lt;span class="s2"&gt;"store_model"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 2.0"&lt;/span&gt;
  &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_dependency&lt;/span&gt; &lt;span class="s2"&gt;"turbo-rails"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.0"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rubygems_mfa_required&lt;/code&gt; enforces multi-factor authentication for publishing -- a standard for any serious open-source gem. &lt;code&gt;required_ruby_version&lt;/code&gt; at &lt;code&gt;&amp;gt;= 3.2.0&lt;/code&gt; excludes unmaintained Ruby versions. Runtime dependencies are intentionally wide (&lt;code&gt;&amp;gt;= 1.0&lt;/code&gt;, &lt;code&gt;&amp;gt;= 7.0&lt;/code&gt;) to avoid locking host apps to specific versions.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;spec.files&lt;/code&gt; filter excludes dev files (&lt;code&gt;spec/&lt;/code&gt;, &lt;code&gt;bin/&lt;/code&gt;, &lt;code&gt;.github/&lt;/code&gt;) so the published gem only contains production code. Nobody wants to download 2 MB of specs when installing a gem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Versioning
&lt;/h3&gt;

&lt;p&gt;DataPorter follows semantic versioning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0.1.0&lt;/strong&gt;: first release. The &lt;code&gt;0.x&lt;/code&gt; signals that the API may still evolve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0.x.y&lt;/strong&gt;: each new feature increments minor, each bugfix increments patch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1.0.0&lt;/strong&gt;: comes when the API is stabilized and battle-tested in production across multiple apps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The version number lives in a single file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/version.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="no"&gt;VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.1.0"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One place to update. The gemspec reads it via &lt;code&gt;require_relative&lt;/code&gt;. The CHANGELOG references it. The Git tag matches it. No duplication.&lt;/p&gt;

&lt;h3&gt;
  
  
  The release workflow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Update version&lt;/span&gt;
&lt;span class="c"&gt;# lib/data_porter/version.rb -&amp;gt; VERSION = "0.1.0"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Update CHANGELOG&lt;/span&gt;
&lt;span class="c"&gt;# CHANGELOG.md -&amp;gt; ## [0.1.0] - 2026-02-06&lt;/span&gt;

&lt;span class="c"&gt;# 3. Commit, tag, push&lt;/span&gt;
git add &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Release v0.1.0"&lt;/span&gt;
git tag v0.1.0
git push origin master &lt;span class="nt"&gt;--tags&lt;/span&gt;

&lt;span class="c"&gt;# 4. Build and push&lt;/span&gt;
gem build data_porter.gemspec
gem push data_porter-0.1.0.gem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, if the Rakefile includes &lt;code&gt;bundler/gem_tasks&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle &lt;span class="nb"&gt;exec &lt;/span&gt;rake release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single command builds, tags, pushes to Git, and pushes to RubyGems -- guaranteeing the tag and the gem stay in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation
&lt;/h2&gt;

&lt;p&gt;A gem without documentation is a gem nobody will use. DataPorter relies on three layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The README&lt;/strong&gt;: entry point. Install in one command (&lt;code&gt;rails generate data_porter:install&lt;/code&gt;), a 15-line Target example, the three-step workflow diagram. A developer should understand what the gem does and install it in under 5 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The CHANGELOG&lt;/strong&gt;: every release documented with what changed, what was added, what broke. &lt;a href="https://keepachangelog.com/" rel="noopener noreferrer"&gt;Keep a Changelog&lt;/a&gt; format -- a standard the Ruby community knows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inline comments&lt;/strong&gt;: every public method documented with YARD. The DSL is the critical part -- &lt;code&gt;column&lt;/code&gt;, &lt;code&gt;sources&lt;/code&gt;, &lt;code&gt;csv_mapping&lt;/code&gt;, &lt;code&gt;persist&lt;/code&gt; need examples, because that is what users will read most.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;p&gt;Here is the complete list of components that make up DataPorter, in the order we built them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Engine + isolate_namespace&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gem structure, namespace isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Configuration DSL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DataPorter.configure&lt;/code&gt;, defaults, &lt;code&gt;context_builder&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;StoreModels (ImportRecord, Error, Report)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Typed JSONB structures without extra tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;TypeValidator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Type validation (email, phone, url) on columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Target DSL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;label&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;columns&lt;/code&gt;, &lt;code&gt;sources&lt;/code&gt;, &lt;code&gt;persist&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Registry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Auto-discovery and resolution of targets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Source::Base + Source::CSV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Source abstraction, CSV parsing with mapping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DataImport model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ActiveRecord, enum status, polymorphic user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Orchestrator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Coordinates parse/import, per-record error handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;RecordValidator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Generic validations (required, type)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ParseJob + ImportJob&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Background processing via ActiveJob&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Broadcaster + ImportChannel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Real-time progress via ActionCable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6 Phlex components&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;StatusBadge, SummaryCards, PreviewTable, ProgressBar, ResultsSummary, FailureAlert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Stimulus controller&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Client-side progress bar animation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ImportsController&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dynamic inheritance, 7 actions, Turbo integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Install generator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Migration, initializer, routes, importers directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Target generator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Target scaffolding with column parsing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Source::JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Import from JSON file or raw text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Source::API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Import from HTTP endpoint with auth and params&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dry Run&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Transaction + rollback, enriches records with DB errors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twenty components. Each with its specs. Each with an article explaining why it exists and how it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  TDD without a dummy app
&lt;/h3&gt;

&lt;p&gt;The most consequential decision of the series: testing the engine without creating a Rails application in &lt;code&gt;spec/dummy/&lt;/code&gt;. A 60-line &lt;code&gt;spec_helper.rb&lt;/code&gt; that bootstraps in-memory SQLite, configures load paths, and stubs &lt;code&gt;ApplicationController&lt;/code&gt;. It works, and it works well -- the full suite runs in under a second.&lt;/p&gt;

&lt;p&gt;The unexpected benefit: this constraint forces every component to stay decoupled. If a component needs a router to be tested, that is a signal it is too tightly coupled to the framework. Structural controller tests (verifying inheritance, callbacks, method signatures) felt strange at first. In hindsight, they test exactly what the gem owns -- the wiring -- and leave integration testing to the host app.&lt;/p&gt;

&lt;p&gt;The trap to avoid: duplication between the schema in &lt;code&gt;spec_helper.rb&lt;/code&gt; and the migration template. If the two diverge, tests pass but the generated migration does not match what was tested.&lt;/p&gt;

&lt;h3&gt;
  
  
  StoreModel gotchas
&lt;/h3&gt;

&lt;p&gt;StoreModel is powerful, but it has its subtleties:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dirty tracking&lt;/strong&gt;: when you modify an object inside a &lt;code&gt;store_model&lt;/code&gt; attribute, ActiveRecord does not detect the change. You can set &lt;code&gt;data_import.records.first.status = "complete"&lt;/code&gt; and call &lt;code&gt;save&lt;/code&gt; -- nothing gets persisted. The fix: call &lt;code&gt;records_will_change!&lt;/code&gt; before modifying, or reassign the entire attribute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serialization round-trip&lt;/strong&gt;: symbol keys become string keys after save/reload. &lt;code&gt;{ name: "Alice" }&lt;/code&gt; comes back as &lt;code&gt;{ "name" =&amp;gt; "Alice" }&lt;/code&gt;. You need to know this and code accordingly -- either always use string keys, or call &lt;code&gt;symbolize_keys&lt;/code&gt; on the way out. DataPorter does the latter in &lt;code&gt;ImportRecord#attributes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLite vs PostgreSQL&lt;/strong&gt;: in tests, StoreModel columns are &lt;code&gt;text&lt;/code&gt;. In production, they are &lt;code&gt;jsonb&lt;/code&gt;. StoreModel handles the difference transparently, but certain JSONB queries (indexes, contains) cannot be tested in SQLite. An acceptable tradeoff for the speed of the feedback loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phlex in an engine: &lt;code&gt;plain&lt;/code&gt; vs &lt;code&gt;text&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A Phlex-specific trap: to emit raw text inside an element, you must use &lt;code&gt;plain&lt;/code&gt; (not &lt;code&gt;text&lt;/code&gt;). In earlier Phlex versions, &lt;code&gt;text&lt;/code&gt; existed but was renamed. If you use &lt;code&gt;text&lt;/code&gt; with a recent version, you get a cryptic &lt;code&gt;NoMethodError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The other subtlety: calling &lt;code&gt;super()&lt;/code&gt; in every component's &lt;code&gt;initialize&lt;/code&gt;. Phlex requires it, and forgetting it produces silent errors or empty renders.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing patterns: controllers, channels, JS
&lt;/h3&gt;

&lt;p&gt;Testing JavaScript from Ruby by reading the file as text and asserting on strings -- it sounds hacky. In practice, it catches the most common bug class in an engine: misalignment between Ruby and JS code. The channel is called &lt;code&gt;DataPorter::ImportChannel&lt;/code&gt; in Ruby and &lt;code&gt;"DataPorter::ImportChannel"&lt;/code&gt; in JS. If one changes and the other does not, the test fails. For a single 30-line Stimulus file, that beats adding Jest and &lt;code&gt;node_modules&lt;/code&gt; to the project.&lt;/p&gt;

&lt;p&gt;Structural controller tests (&lt;code&gt;_process_action_callbacks&lt;/code&gt;, &lt;code&gt;instance_method&lt;/code&gt;, &lt;code&gt;superclass&lt;/code&gt;) form a contract: the gem guarantees the controller has the right shape. The host app guarantees it behaves correctly in context. A clean separation of responsibilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is next
&lt;/h2&gt;

&lt;p&gt;DataPorter 0.1.0 covers the standard workflow. Here is what could come in future versions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch imports&lt;/strong&gt;: for 100k+ line files, import in batches of 1000 with &lt;code&gt;insert_all&lt;/code&gt; instead of &lt;code&gt;create!&lt;/code&gt; per record. This requires rethinking the &lt;code&gt;persist&lt;/code&gt; contract -- instead of one record at a time, the target would receive a batch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streaming progress&lt;/strong&gt;: replace ActionCable with Server-Sent Events (SSE) for apps that do not need bidirectional WebSocket. Lighter, no Redis dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom validators&lt;/strong&gt;: let targets declare validators with a DSL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;validate: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"already exists"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;email: &lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Export&lt;/strong&gt;: the reverse path. If we can parse and validate records, we can serialize them to CSV/JSON. The Target already has all the information needed (columns, types, labels).&lt;/p&gt;

&lt;h2&gt;
  
  
  Final reflection
&lt;/h2&gt;

&lt;p&gt;Building DataPorter was an exercise in discipline as much as code. The method -- Taskmaster for planning, TDD for implementation, one article to document each step -- forces explicit decisions. No "we will figure it out later". Every component exists because a test demands it, and every test exists because a behavior was specified.&lt;/p&gt;

&lt;p&gt;The choice to skip the dummy app was a gamble. It paid off: tests are fast, components are decoupled, and the gem is testable without Rails infrastructure. But it has a cost -- some integration bugs will only surface in the host app. That is an accepted tradeoff: the gem tests its wiring, the host app tests its behavior.&lt;/p&gt;

&lt;p&gt;StoreModel, Phlex, Stimulus -- each dependency brought its share of surprises. StoreModel's dirty tracking, Phlex's &lt;code&gt;plain&lt;/code&gt; vs &lt;code&gt;text&lt;/code&gt;, Stimulus's double-dash naming for engines. These gotchas appear in no documentation. They appear when a test fails at 11 PM and you read the gem's source code to understand why. That is the real advantage of TDD: you discover problems in the terminal, not in production.&lt;/p&gt;

&lt;p&gt;DataPorter is now a published gem on RubyGems. One &lt;code&gt;bundle add data_porter&lt;/code&gt;, one &lt;code&gt;rails generate data_porter:install&lt;/code&gt;, a 15-line Target, and any Rails app has a complete import system with preview, validation, real-time progress, and dry run.&lt;/p&gt;

&lt;p&gt;That was the plan from the start. It took 16 articles to get there.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part 16 of the series "Building DataPorter - A Data Import Engine for Rails". &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-15-erb-views-meet-phlex-components-bd9"&gt;Previous: ERB Views Meet Phlex Components&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/SerylLns/data_porter" rel="noopener noreferrer"&gt;SerylLns/data_porter&lt;/a&gt; | &lt;strong&gt;RubyGems:&lt;/strong&gt; &lt;a href="https://rubygems.org/gems/data_porter" rel="noopener noreferrer"&gt;data_porter&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Rails Engine #15 -- ERB Views Meet Phlex Components</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Tue, 31 Mar 2026 13:33:36 +0000</pubDate>
      <link>https://dev.to/seryllns_/building-a-rails-engine-15-erb-views-meet-phlex-components-bd9</link>
      <guid>https://dev.to/seryllns_/building-a-rails-engine-15-erb-views-meet-phlex-components-bd9</guid>
      <description>&lt;h1&gt;
  
  
  ERB View Templates: Composing Phlex Components
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Phlex components are pure Ruby. ERB templates are what the browser actually sees. The glue between the two is a single method: &lt;code&gt;.call&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;This is part 16 of the series where we build &lt;strong&gt;DataPorter&lt;/strong&gt;, a mountable Rails engine for data import workflows. In &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-9-building-the-ui-with-phlex-tailwind-1mn6"&gt;part 9&lt;/a&gt;, we built six Phlex components -- StatusBadge, SummaryCards, PreviewTable, ProgressBar, ResultsSummary, FailureAlert. In &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-10-controllers-routing-in-a-rails-engine-492j"&gt;part 10&lt;/a&gt;, we built the controller and routes. But we never connected the two.&lt;/p&gt;

&lt;p&gt;The engine has a fully wired backend and a complete component library, but visiting &lt;code&gt;/imports&lt;/code&gt; crashes with &lt;code&gt;ActionView::MissingTemplate&lt;/code&gt;. The &lt;code&gt;app/views/data_porter/imports/&lt;/code&gt; directory is empty. The controller sets instance variables, the components know how to render HTML, but there is no view template to orchestrate them.&lt;/p&gt;

&lt;p&gt;In this article, we create three ERB templates -- index, new, and show -- that compose the existing Phlex components into full pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The missing attachment
&lt;/h2&gt;

&lt;p&gt;Before touching views, there is a bug to fix. The CSV and JSON sources both call &lt;code&gt;@data_import.file.download&lt;/code&gt;, but the DataImport model has no file attachment declared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/data_porter/data_import.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DataImport&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"data_porter_imports"&lt;/span&gt;

    &lt;span class="n"&gt;has_one_attached&lt;/span&gt; &lt;span class="ss"&gt;:file&lt;/span&gt;

    &lt;span class="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;polymorphic: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;optional: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two changes here. &lt;code&gt;has_one_attached :file&lt;/code&gt; makes ActiveStorage available on the model. And &lt;code&gt;optional: true&lt;/code&gt; on the &lt;code&gt;belongs_to&lt;/code&gt; -- in Rails 5+, &lt;code&gt;belongs_to&lt;/code&gt; associations are required by default. An engine should not enforce that constraint; the host app decides whether imports require a user.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;has_one_attached&lt;/code&gt; has a cascade effect on the test setup. ActiveStorage needs its tables (blobs, attachments, variant_records), a configured service, and a running Rails application to initialize the engine. Our previous spec_helper -- 15 lines of manual requires and an in-memory SQLite connection -- is not enough anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading the test infrastructure
&lt;/h2&gt;

&lt;p&gt;The spec_helper needed a fundamental change: bootstrapping a real Rails application instead of manually requiring individual components.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/spec_helper.rb&lt;/span&gt;
&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"RAILS_ENV"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt;

&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"rails"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"active_record"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"active_job"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"action_controller"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"action_cable"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"active_storage/engine"&lt;/span&gt;

&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;TestApp&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Application&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Application&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_defaults&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VERSION&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_f&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eager_load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:test&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;service_configurations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="ss"&gt;test: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;service: &lt;/span&gt;&lt;span class="s2"&gt;"Disk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;root: &lt;/span&gt;&lt;span class="no"&gt;Dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tmpdir&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active_job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;queue_adapter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:test&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;secret_key_base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"test_secret"&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;".."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"data_porter"&lt;/span&gt;

&lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize!&lt;/span&gt;

&lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;establish_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;adapter: &lt;/span&gt;&lt;span class="s2"&gt;"sqlite3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;database: &lt;/span&gt;&lt;span class="s2"&gt;":memory:"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: &lt;code&gt;Rails.application.initialize!&lt;/code&gt; must run &lt;em&gt;after&lt;/em&gt; the connection is established, and &lt;em&gt;before&lt;/em&gt; the schema is defined. ActiveStorage's engine hooks into the initialization process to register its models and set up the attachment macros. Without &lt;code&gt;initialize!&lt;/code&gt;, &lt;code&gt;has_one_attached&lt;/code&gt; raises &lt;code&gt;NoMethodError&lt;/code&gt; because &lt;code&gt;ActiveStorage::Attached::Model&lt;/code&gt; is never included into &lt;code&gt;ActiveRecord::Base&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The schema definition gains three new tables for ActiveStorage -- &lt;code&gt;active_storage_blobs&lt;/code&gt;, &lt;code&gt;active_storage_attachments&lt;/code&gt;, and &lt;code&gt;active_storage_variant_records&lt;/code&gt; -- mirroring what &lt;code&gt;rails active_storage:install&lt;/code&gt; creates in a host app.&lt;/p&gt;

&lt;p&gt;We also need a &lt;code&gt;User&lt;/code&gt; stub and a &lt;code&gt;config/database.yml&lt;/code&gt; for Rails to find during initialization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/database.yml&lt;/span&gt;
&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adapter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sqlite3&lt;/span&gt;
  &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:memory:"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/spec_helper.rb (after schema)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="k"&gt;defined?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The User model was previously not needed because without a full Rails app, the polymorphic &lt;code&gt;belongs_to&lt;/code&gt; never tried to resolve the &lt;code&gt;User&lt;/code&gt; constant. With &lt;code&gt;initialize!&lt;/code&gt;, Rails activates the &lt;code&gt;belongs_to&lt;/code&gt; presence validation, and any test that creates a &lt;code&gt;DataImport&lt;/code&gt; with &lt;code&gt;user_type: "User"&lt;/code&gt; triggers a &lt;code&gt;NameError&lt;/code&gt;. The stub class fixes it cleanly.&lt;/p&gt;

&lt;p&gt;One more piece: engine routes must be mounted in the test app &lt;em&gt;after&lt;/em&gt; initialization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;mount&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;at: &lt;/span&gt;&lt;span class="s2"&gt;"/data_porter"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is crucial for the view specs. Without it, the engine's URL helpers (&lt;code&gt;import_path&lt;/code&gt;, &lt;code&gt;imports_path&lt;/code&gt;) cannot resolve paths because the mount point is unknown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering Phlex in ERB
&lt;/h2&gt;

&lt;p&gt;DataPorter uses Phlex without &lt;code&gt;phlex-rails&lt;/code&gt;. This means no &lt;code&gt;render&lt;/code&gt; helper for Phlex components in ERB templates. Instead, we call &lt;code&gt;.call&lt;/code&gt; directly, which returns an HTML string, and wrap it in &lt;code&gt;raw&lt;/code&gt; to prevent double-escaping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;StatusBadge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is explicit and requires no gems, no monkey-patching, no initializer configuration. The tradeoff is that every component call is slightly verbose. But for a gem that ships views, this is a feature: the host app does not need to install phlex-rails, and there is no risk of version conflicts between the gem's Phlex setup and the host app's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index template
&lt;/h2&gt;

&lt;p&gt;The index page lists all imports with their status, target, source type, and creation date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;%# app/views/data_porter/imports/index.html.erb %&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"data-porter"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"dp-header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"dp-title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Imports&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;link_to&lt;/span&gt; &lt;span class="s2"&gt;"New Import"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_import_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-btn dp-btn--primary"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;table&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"dp-table"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;thead&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;ID&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;Target&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;Source&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;Status&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;Created&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/thead&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tbody&amp;gt;&lt;/span&gt;
      &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="vi"&gt;@imports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;target_key&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;source_type&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;StatusBadge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;created_at&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%Y-%m-%d %H:%M"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;link_to&lt;/span&gt; &lt;span class="s2"&gt;"View"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;import_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-link"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
      &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/tbody&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/table&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each row includes a StatusBadge component rendered inline. The outer &lt;code&gt;&amp;lt;div class="data-porter"&amp;gt;&lt;/code&gt; scopes all styles. The URL helpers (&lt;code&gt;new_import_path&lt;/code&gt;, &lt;code&gt;import_path&lt;/code&gt;) are resolved through the engine's isolated namespace -- no &lt;code&gt;data_porter.&lt;/code&gt; prefix needed inside engine views.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new import form
&lt;/h2&gt;

&lt;p&gt;The form uses &lt;code&gt;form_with&lt;/code&gt; with three fields: a target select (populated from &lt;code&gt;DataPorter::Registry.available&lt;/code&gt;), a source type select (from &lt;code&gt;DataPorter.configuration.enabled_sources&lt;/code&gt;), and a file upload field. The key detail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;form_with&lt;/span&gt; &lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;url: &lt;/span&gt;&lt;span class="n"&gt;imports_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-form"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt; &lt;span class="ss"&gt;:target_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="vi"&gt;@targets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:label&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:key&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;prompt: &lt;/span&gt;&lt;span class="s2"&gt;"Select a target..."&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-select"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file_field&lt;/span&gt; &lt;span class="ss"&gt;:file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-file-input"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt; &lt;span class="s2"&gt;"Start Import"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-btn dp-btn--primary"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;url: imports_path&lt;/code&gt; is necessary because the model is a &lt;code&gt;DataPorter::DataImport&lt;/code&gt;, and Rails would try to generate a &lt;code&gt;data_porter_data_import_path&lt;/code&gt; without the explicit URL. Inside engine views, explicit URLs for form actions avoid routing surprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  The show template: status-driven composition
&lt;/h2&gt;

&lt;p&gt;The show page is where all the Phlex components come together. The key design: the template renders different components based on the import's current status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;%# app/views/data_porter/imports/show.html.erb %&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"data-porter"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"dp-header"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"dp-title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_label&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; Import #&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;StatusBadge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parsing?&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importing?&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dry_running?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ProgressBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;import_id: &lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;previewing?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SummaryCards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;report: &lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PreviewTable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="ss"&gt;columns: &lt;/span&gt;&lt;span class="vi"&gt;@target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;records: &lt;/span&gt;&lt;span class="vi"&gt;@records&lt;/span&gt;
        &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"dp-actions"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;button_to&lt;/span&gt; &lt;span class="s2"&gt;"Confirm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confirm_import_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="ss"&gt;method: :post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-btn dp-btn--primary"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
      &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_dry_run_enabled&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
        &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;button_to&lt;/span&gt; &lt;span class="s2"&gt;"Dry Run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dry_run_import_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
              &lt;span class="ss"&gt;method: :post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-btn dp-btn--secondary"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
      &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
      &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;button_to&lt;/span&gt; &lt;span class="s2"&gt;"Cancel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel_import_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="ss"&gt;method: :post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-btn dp-btn--danger"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completed?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ResultsSummary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;report: &lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;failed?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FailureAlert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;report: &lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"dp-actions"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;button_to&lt;/span&gt; &lt;span class="s2"&gt;"Retry"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parse_import_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="ss"&gt;method: :post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-btn dp-btn--primary"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five states, five rendering paths:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Components rendered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;parsing&lt;/code&gt;, &lt;code&gt;importing&lt;/code&gt;, &lt;code&gt;dry_running&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ProgressBar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;previewing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SummaryCards + PreviewTable + action buttons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;completed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ResultsSummary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FailureAlert + Retry button&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pending&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Header only (waiting for job to start)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Dry Run button appears only when the target has enabled it (&lt;code&gt;@target._dry_run_enabled&lt;/code&gt;). This is the DSL flag from part 14 surfacing in the UI -- targets opt in to dry run, and the view respects that choice without any extra configuration.&lt;/p&gt;

&lt;p&gt;The action buttons use &lt;code&gt;button_to&lt;/code&gt; which generates a mini-form with a POST request. This matches the route design from part 10: all member actions (confirm, cancel, dry_run, parse) are POST endpoints. No JavaScript needed for these actions -- they are standard form submissions that Turbo handles automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CSS stylesheet
&lt;/h2&gt;

&lt;p&gt;DataPorter ships a plain CSS file with styles for all &lt;code&gt;dp-*&lt;/code&gt; classes. No build step, no Tailwind compilation, no PostCSS pipeline. Every class is scoped under &lt;code&gt;.data-porter&lt;/code&gt; or prefixed with &lt;code&gt;dp-&lt;/code&gt; -- the host app's styles cannot accidentally override DataPorter's components, and DataPorter's styles cannot leak out.&lt;/p&gt;

&lt;p&gt;The full stylesheet covers tables, badges, cards, progress bars, forms, buttons, alerts, and results. Host apps can override any of these styles or ignore the stylesheet entirely and provide their own -- the &lt;code&gt;dp-&lt;/code&gt; prefix convention means they always have a clean selector to target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing views in a Rails 8 engine
&lt;/h2&gt;

&lt;p&gt;Testing ERB templates in a Rails engine without a dummy app is not well documented. Rails 8 changed how &lt;code&gt;ActionView::Base&lt;/code&gt; works -- you need &lt;code&gt;with_empty_template_cache&lt;/code&gt; to create a usable view class, and the view needs both the engine's URL helpers and the main app's URL helpers to resolve paths correctly.&lt;/p&gt;

&lt;p&gt;The solution lives in a shared helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/support/view_test_helper.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;ViewTestHelper&lt;/span&gt;
  &lt;span class="no"&gt;VIEW_CLASS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ActionView&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_empty_template_cache&lt;/span&gt;
  &lt;span class="no"&gt;VIEW_CLASS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url_helpers&lt;/span&gt;
  &lt;span class="no"&gt;VIEW_CLASS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url_helpers&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assigns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;lookup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ActionView&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;LookupContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view_paths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ctrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ImportsController&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
    &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ActionDispatch&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TestRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_url_options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;host: &lt;/span&gt;&lt;span class="s2"&gt;"localhost"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;VIEW_CLASS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assigns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_url_options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;host: &lt;/span&gt;&lt;span class="s2"&gt;"localhost"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;view&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;view_paths&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"../../app/views"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are critical here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ActionView::Base.with_empty_template_cache&lt;/code&gt;&lt;/strong&gt; -- In Rails 8, &lt;code&gt;ActionView::Base.new&lt;/code&gt; raises &lt;code&gt;NotImplementedError&lt;/code&gt; asking for &lt;code&gt;compiled_method_container&lt;/code&gt;. The &lt;code&gt;with_empty_template_cache&lt;/code&gt; class method creates a subclass that has the right template caching setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both route helper modules&lt;/strong&gt; -- The engine's URL helpers (&lt;code&gt;import_path&lt;/code&gt;, &lt;code&gt;imports_path&lt;/code&gt;) resolve paths within the engine. The main app's URL helpers provide the mount point (&lt;code&gt;data_porter_path&lt;/code&gt;) that the engine needs to construct full URLs. Without the main app helpers, named routes raise &lt;code&gt;NoMethodError: undefined method 'data_porter_path'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;DataPorter::ImportsController.new&lt;/code&gt; as the controller&lt;/strong&gt; -- The view delegates &lt;code&gt;_routes&lt;/code&gt; resolution to its controller. Using &lt;code&gt;ActionController::Base.new&lt;/code&gt; fails because it has no &lt;code&gt;_routes&lt;/code&gt; for the engine namespace. Using the actual &lt;code&gt;ImportsController&lt;/code&gt; -- which inherits the engine's route set -- makes all URL helpers work correctly.&lt;/p&gt;

&lt;p&gt;The specs themselves are straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"data_porter/imports/show.html.erb"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;ViewTestHelper&lt;/span&gt;

  &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="s2"&gt;"when previewing"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;:previewing&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"renders the summary cards"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-summary-cards"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"renders the preview table"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-preview-table"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"shows confirm button"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Confirm"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="s2"&gt;"when failed"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"renders the failure alert"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-alert"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"shows retry button"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Retry"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each context sets a different import status and verifies that the correct components and buttons are present. The assertions check for CSS class names (&lt;code&gt;dp-summary-cards&lt;/code&gt;, &lt;code&gt;dp-alert&lt;/code&gt;) rather than exact HTML structure, making them resilient to markup changes while still catching the important behavior: "this component is rendered for this status".&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions &amp;amp; tradeoffs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;We chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Phlex integration&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;raw component.call&lt;/code&gt; in ERB&lt;/td&gt;
&lt;td&gt;phlex-rails gem with &lt;code&gt;render&lt;/code&gt; helper&lt;/td&gt;
&lt;td&gt;No extra dependency; explicit about what is happening; host app does not need phlex-rails installed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Template format&lt;/td&gt;
&lt;td&gt;ERB wrappers composing Phlex&lt;/td&gt;
&lt;td&gt;Pure Phlex page components&lt;/td&gt;
&lt;td&gt;ERB is familiar to every Rails developer; forms and URL helpers work naturally; Phlex handles the complex rendering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status-driven rendering&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;if @import.parsing?&lt;/code&gt; conditionals&lt;/td&gt;
&lt;td&gt;Separate templates per status, Turbo Frame swapping&lt;/td&gt;
&lt;td&gt;Simple, readable, works without JavaScript; one template to understand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSS approach&lt;/td&gt;
&lt;td&gt;Plain CSS file, no build step&lt;/td&gt;
&lt;td&gt;Tailwind build, CSS-in-JS, Sass&lt;/td&gt;
&lt;td&gt;Zero dependencies; host app can use any CSS pipeline; works out of the box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;View test approach&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ActionView::Base.with_empty_template_cache&lt;/code&gt; + engine controller&lt;/td&gt;
&lt;td&gt;Dummy app, request specs, Capybara&lt;/td&gt;
&lt;td&gt;Fast, no extra infrastructure; tests the template rendering directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form URL&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;url: imports_path&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Let Rails infer from model&lt;/td&gt;
&lt;td&gt;Avoids routing surprises with engine-namespaced models&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;has_one_attached :file&lt;/code&gt;&lt;/strong&gt; was missing from DataImport -- a bug that would crash CSV and JSON imports at runtime. Adding it required upgrading the test infrastructure to bootstrap a full Rails application with ActiveStorage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ERB templates&lt;/strong&gt; compose Phlex components via &lt;code&gt;raw component.call&lt;/code&gt; -- no phlex-rails dependency needed. The show template renders different components based on import status, making the page dynamic without JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain CSS&lt;/strong&gt; with &lt;code&gt;dp-*&lt;/code&gt; prefixed classes ships with the gem and works out of the box. Host apps can override or replace it entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;View testing in Rails 8&lt;/strong&gt; requires &lt;code&gt;ActionView::Base.with_empty_template_cache&lt;/code&gt;, both engine and main app URL helpers, and the engine's own controller for proper route resolution. The &lt;code&gt;ViewTestHelper&lt;/code&gt; encapsulates this setup in a reusable module.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;22 view specs&lt;/strong&gt; cover all three templates and all status-driven rendering paths, running in under a second with no browser or dummy app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;With the views in place, DataPorter is feature-complete. In the next and final article, we step back for a &lt;strong&gt;showcase and retrospective&lt;/strong&gt; -- screenshots of the full workflow in a real app, a walkthrough from upload to import, and reflections on what the 16-article journey taught us about building Rails engines.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part 16 of the series "Building DataPorter - A Data Import Engine for Rails". &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-14-dry-run-validate-before-you-import-1gl5"&gt;Dry Run: Validate Before You Import &lt;/a&gt; | Next: Previous: Publishing &amp;amp; Retrospective&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/SerylLns/data_porter" rel="noopener noreferrer"&gt;SerylLns/data_porter&lt;/a&gt; | &lt;strong&gt;RubyGems:&lt;/strong&gt; &lt;a href="https://rubygems.org/gems/data_porter" rel="noopener noreferrer"&gt;data_porter&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>tutorial</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Building a Rails Engine #14 -- Dry Run: Validate Before You Import</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Wed, 25 Mar 2026 13:57:47 +0000</pubDate>
      <link>https://dev.to/seryllns_/building-a-rails-engine-14-dry-run-validate-before-you-import-1gl5</link>
      <guid>https://dev.to/seryllns_/building-a-rails-engine-14-dry-run-validate-before-you-import-1gl5</guid>
      <description>&lt;h1&gt;
  
  
  Dry Run: Validate Before You Import
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;The preview catches column errors. The dry run catches database errors. Two safety nets, two levels of confidence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;This is part 14 of the series where we build &lt;strong&gt;DataPorter&lt;/strong&gt;, a mountable Rails engine for data import workflows. In &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-13-how-to-test-a-mountable-rails-engine-without-a-dummy-app-1na2"&gt;part 13&lt;/a&gt;, we detailed the testing strategy: in-memory SQLite, structural controller specs, anonymous target classes, and a spec_helper that bootstraps just enough Rails to cover every layer.&lt;/p&gt;

&lt;p&gt;We now have a complete import pipeline: parse the file, preview the records, confirm, persist. But there is a gap between the preview and the real import. The preview validates the &lt;em&gt;data&lt;/em&gt; -- required fields, types, formats. It does not validate what happens when that data hits the database. A uniqueness constraint, a foreign key violation, a custom model validation that queries other tables -- none of these surface until &lt;code&gt;persist&lt;/code&gt; is actually called. By then, the import is running for real.&lt;/p&gt;

&lt;p&gt;In this article, we build a &lt;strong&gt;dry run&lt;/strong&gt; mode that bridges this gap. It runs the full persist logic inside a transaction, captures any database-level errors on each record, then rolls back. The user sees exactly which records would fail &lt;em&gt;before&lt;/em&gt; committing to the import.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two validation layers
&lt;/h2&gt;

&lt;p&gt;The preview phase runs the RecordValidator against column definitions. It catches structural problems: "this field is required and it is empty", "this field should be an email but it is not". These are fast, stateless checks that do not touch the database.&lt;/p&gt;

&lt;p&gt;But many real-world validations are stateful. A &lt;code&gt;validates_uniqueness_of :email&lt;/code&gt; on the User model requires a database query. A &lt;code&gt;belongs_to :company&lt;/code&gt; with a foreign key constraint requires the company to exist. A custom validation that checks &lt;code&gt;if: -&amp;gt; { some_scope.exists? }&lt;/code&gt; requires the full ActiveRecord context. None of these can run during preview because there is no model instance, no transaction, no database connection in the validation path.&lt;/p&gt;

&lt;p&gt;The dry run fills this gap. It calls the target's &lt;code&gt;persist&lt;/code&gt; method -- the same method that the real import uses -- but captures exceptions instead of letting them propagate. Each record gets annotated with its result: passed or failed, with the error message attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;dry_run_enabled&lt;/code&gt; DSL flag
&lt;/h2&gt;

&lt;p&gt;Not every import needs a dry run. A simple CSV-to-table import with no uniqueness constraints might not benefit from the overhead. We make it opt-in at the target level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/importers/user_import.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserImport&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Target&lt;/span&gt;
  &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s2"&gt;"Users"&lt;/span&gt;
  &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="s2"&gt;"User"&lt;/span&gt;
  &lt;span class="n"&gt;dry_run_enabled&lt;/span&gt;

  &lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dry_run_enabled&lt;/code&gt; class method is a simple flag on the Target DSL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/target.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;
  &lt;span class="nb"&gt;attr_reader&lt;/span&gt; &lt;span class="ss"&gt;:_dry_run_enabled&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dry_run_enabled&lt;/span&gt;
    &lt;span class="vi"&gt;@_dry_run_enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No arguments, no block, no configuration. Either the target supports dry run or it does not. The controller checks &lt;code&gt;target_class._dry_run_enabled&lt;/code&gt; to decide whether to show the "Dry Run" button on the preview page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;dry_running&lt;/code&gt; status
&lt;/h2&gt;

&lt;p&gt;The DataImport enum needed a new state. The import transitions from &lt;code&gt;previewing&lt;/code&gt; to &lt;code&gt;dry_running&lt;/code&gt; while the dry run executes, then back to &lt;code&gt;previewing&lt;/code&gt; when it completes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/data_porter/data_import.rb&lt;/span&gt;
&lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="ss"&gt;pending: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;parsing: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;previewing: &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;importing: &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;completed: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;failed: &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;dry_running: &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value &lt;code&gt;6&lt;/code&gt; is appended at the end rather than inserted in logical order. This is intentional -- existing records in production have integer status values. Inserting &lt;code&gt;dry_running&lt;/code&gt; at position 3 would shift &lt;code&gt;importing&lt;/code&gt;, &lt;code&gt;completed&lt;/code&gt;, and &lt;code&gt;failed&lt;/code&gt;, corrupting every existing import. Enums with integer backing must be append-only.&lt;/p&gt;

&lt;p&gt;The state flow is: &lt;code&gt;previewing -&amp;gt; dry_running -&amp;gt; previewing&lt;/code&gt;. The dry run is not a terminal state. It enriches the records with database-level feedback and returns to the preview so the user can review the results and decide whether to proceed with the real import.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;dry_run!&lt;/code&gt; flow
&lt;/h2&gt;

&lt;p&gt;The Orchestrator gains a third public method alongside &lt;code&gt;parse!&lt;/code&gt; and &lt;code&gt;import!&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/orchestrator.rb&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dry_run!&lt;/span&gt;
  &lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dry_running!&lt;/span&gt;
  &lt;span class="n"&gt;run_dry_run_records&lt;/span&gt;
  &lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :previewing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;build_report&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;StandardError&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
  &lt;span class="n"&gt;handle_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The structure mirrors &lt;code&gt;parse!&lt;/code&gt; and &lt;code&gt;import!&lt;/code&gt;: transition to the working status, do the work, transition to the result status, rebuild the report. The &lt;code&gt;rescue&lt;/code&gt; catches catastrophic failures and transitions to &lt;code&gt;failed&lt;/code&gt; with an error report.&lt;/p&gt;

&lt;p&gt;The real work happens in &lt;code&gt;run_dry_run_records&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_dry_run_records&lt;/span&gt;
  &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;records&lt;/span&gt;
  &lt;span class="n"&gt;importable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:importable?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;build_context&lt;/span&gt;

  &lt;span class="n"&gt;importable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;dry_run_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;records_will_change!&lt;/span&gt;
  &lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;records: &lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dry_run_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="vi"&gt;@target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;context: &lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dry_run_passed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;StandardError&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
  &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dry_run_passed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each importable record, we call the target's actual &lt;code&gt;persist&lt;/code&gt; method. If it succeeds, &lt;code&gt;dry_run_passed&lt;/code&gt; is set to &lt;code&gt;true&lt;/code&gt;. If it raises -- an &lt;code&gt;ActiveRecord::RecordInvalid&lt;/code&gt;, a constraint violation, any exception -- we capture the message on the record and mark it as failed. The import data is never committed because the dry run operates at the record level, catching errors individually.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;dry_run_passed&lt;/code&gt; attribute on ImportRecord is a simple boolean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/store_models/import_record.rb&lt;/span&gt;
&lt;span class="n"&gt;attribute&lt;/span&gt; &lt;span class="ss"&gt;:dry_run_passed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the dry run, the preview table can show a green check or a red cross next to each record, along with the specific error message for failures. The user gets a precise map of what will work and what will not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The StoreModel dirty tracking gotcha
&lt;/h2&gt;

&lt;p&gt;There is a subtle but critical line in &lt;code&gt;run_dry_run_records&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;records_will_change!&lt;/span&gt;
&lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;records: &lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why &lt;code&gt;records_will_change!&lt;/code&gt; before &lt;code&gt;update!&lt;/code&gt;? The answer lies in how ActiveRecord tracks changes on complex attributes.&lt;/p&gt;

&lt;p&gt;StoreModel attributes are serialized to JSON and stored in a text (or JSONB) column. When you modify an object &lt;em&gt;in place&lt;/em&gt; -- setting &lt;code&gt;record.dry_run_passed = true&lt;/code&gt; on a record that already exists in the &lt;code&gt;records&lt;/code&gt; array -- ActiveRecord does not detect the change. From its perspective, the &lt;code&gt;records&lt;/code&gt; attribute still points to the same Ruby array at the same memory address. The serialized value has changed, but ActiveRecord's dirty tracking compares object identity, not serialized content.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;records_will_change!&lt;/code&gt;, the &lt;code&gt;update!&lt;/code&gt; call would see "records has not changed" and skip the column in the SQL UPDATE. The dry run results would be computed correctly in memory but never persisted to the database. The user would see no change on the preview page.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;records_will_change!&lt;/code&gt; explicitly marks the attribute as dirty, forcing ActiveRecord to include it in the next save. This is a well-known pattern with serialized attributes, but it is easy to forget -- and the failure mode is silent. The data looks correct in the current process, the tests that do not reload from the database pass, and only the production user sees stale results.&lt;/p&gt;

&lt;p&gt;This is one of those bugs that TDD catches early. The spec reloads the import from the database and checks &lt;code&gt;dry_run_passed&lt;/code&gt; on the reloaded records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"marks records as dry_run_passed on success"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Orchestrator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;dry_run!&lt;/span&gt;
  &lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dry_run_passed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;import.reload&lt;/code&gt; forces a fresh read from SQLite. Without &lt;code&gt;records_will_change!&lt;/code&gt;, this spec fails -- &lt;code&gt;dry_run_passed&lt;/code&gt; is still &lt;code&gt;false&lt;/code&gt; in the database even though it was set to &lt;code&gt;true&lt;/code&gt; in memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job, controller, and route
&lt;/h2&gt;

&lt;p&gt;The wiring follows the exact same pattern as &lt;code&gt;parse!&lt;/code&gt; and &lt;code&gt;import!&lt;/code&gt;: a thin job delegates to the Orchestrator, a controller action enqueues it, a member route exposes it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/jobs/data_porter/dry_run_job.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DryRunJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveJob&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;
  &lt;span class="n"&gt;queue_as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;queue_name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;data_import&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataImport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;Orchestrator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_import&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;dry_run!&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# app/controllers/data_porter/imports_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dry_run&lt;/span&gt;
  &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DryRunJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;import_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;POST triggers the job, redirect back to the show page where ActionCable pushes progress updates. The view conditionally shows the "Dry Run" button only when &lt;code&gt;target_class._dry_run_enabled&lt;/code&gt; is true and the import is in &lt;code&gt;previewing&lt;/code&gt; status.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;The dry run specs follow the series' established patterns -- anonymous target classes, registry cleanup, and database round-trip assertions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"Dry Run"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:target_class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;klass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s2"&gt;"Guests"&lt;/span&gt;
      &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="s2"&gt;"Guest"&lt;/span&gt;
      &lt;span class="n"&gt;dry_run_enabled&lt;/span&gt;

      &lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
        &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="n"&gt;klass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:persist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="ss"&gt;:|&lt;/span&gt;
      &lt;span class="n"&gt;record&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="n"&gt;klass&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"Orchestrator#dry_run!"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"transitions to previewing after dry run"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Orchestrator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;dry_run!&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"previewing"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"marks records as dry_run_passed on success"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Orchestrator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;dry_run!&lt;/span&gt;
      &lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dry_run_passed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"captures errors from failing persist"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="c1"&gt;# Target that raises ActiveRecord::RecordInvalid&lt;/span&gt;
      &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Orchestrator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failing_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;dry_run!&lt;/span&gt;

      &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;failing_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dry_run_passed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errors_list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:message&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/Validation failed/&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failing target class simulates a database-level error by raising &lt;code&gt;ActiveRecord::RecordInvalid&lt;/code&gt; in &lt;code&gt;persist&lt;/code&gt;. The spec verifies that the error is captured on the record, that &lt;code&gt;dry_run_passed&lt;/code&gt; is false, and that the import still transitions to &lt;code&gt;previewing&lt;/code&gt; -- not &lt;code&gt;failed&lt;/code&gt;. A record-level error is expected operational feedback, not a catastrophic failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions &amp;amp; tradeoffs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;We chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Opt-in flag&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dry_run_enabled&lt;/code&gt; on Target DSL&lt;/td&gt;
&lt;td&gt;Always-on dry run&lt;/td&gt;
&lt;td&gt;Not every import benefits from the overhead; simple imports can skip it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status value&lt;/td&gt;
&lt;td&gt;Append &lt;code&gt;dry_running: 6&lt;/code&gt; at the end of the enum&lt;/td&gt;
&lt;td&gt;Insert in logical order&lt;/td&gt;
&lt;td&gt;Integer-backed enums must be append-only to avoid corrupting existing data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dirty tracking&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;records_will_change!&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reassigning the array (&lt;code&gt;self.records = records.dup&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;More explicit about intent; avoids unnecessary array duplication; documents the StoreModel gotcha&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error boundary&lt;/td&gt;
&lt;td&gt;Per-record rescue in &lt;code&gt;dry_run_record&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Wrapping all records in a single begin/rescue&lt;/td&gt;
&lt;td&gt;One failing record should not prevent the others from being validated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;dry run&lt;/strong&gt; bridges the gap between preview (column-level validation) and real import (database-level validation), giving users a complete picture before any data is committed.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;dry_run_enabled&lt;/code&gt; DSL flag&lt;/strong&gt; makes it opt-in per target -- not every import needs the overhead.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;dry_running&lt;/code&gt; status&lt;/strong&gt; follows the append-only rule for integer-backed enums, preserving existing data.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;records_will_change!&lt;/code&gt; call&lt;/strong&gt; is the key to making StoreModel in-place mutations persist -- without it, ActiveRecord skips the attribute in the SQL UPDATE because its dirty tracking does not detect in-place changes on serialized objects.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;DryRunJob&lt;/strong&gt; follows the same thin-job pattern as ParseJob and ImportJob: find, delegate, done.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;controller action and route&lt;/strong&gt; mirror the existing member actions: POST triggers a job, redirect back to show.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;We now have a full-featured, tested, and safe import engine. In part 15, we wrap up the series: &lt;strong&gt;publishing the gem to RubyGems&lt;/strong&gt;, writing a proper CHANGELOG, choosing a versioning strategy, and reflecting on what worked, what we would do differently, and what DataPorter looks like from the outside.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part 14 of the series "Building DataPorter - A Data Import Engine for Rails". &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-13-how-to-test-a-mountable-rails-engine-without-a-dummy-app-1na2"&gt;Previous: Testing a Rails Engine with RSpec&lt;/a&gt; | Next: Publishing the Gem &amp;amp; Retrospective&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/SerylLns/data_porter" rel="noopener noreferrer"&gt;SerylLns/data_porter&lt;/a&gt; | &lt;strong&gt;RubyGems:&lt;/strong&gt; &lt;a href="https://rubygems.org/gems/data_porter" rel="noopener noreferrer"&gt;data_porter&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Rails Engine #13 -- How to test a mountable Rails engine without a dummy app</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Tue, 24 Mar 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/seryllns_/building-a-rails-engine-13-how-to-test-a-mountable-rails-engine-without-a-dummy-app-1na2</link>
      <guid>https://dev.to/seryllns_/building-a-rails-engine-13-how-to-test-a-mountable-rails-engine-without-a-dummy-app-1na2</guid>
      <description>&lt;h1&gt;
  
  
  Testing a Rails Engine with RSpec
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;How to test a mountable Rails engine without a dummy app -- just an in-memory SQLite database and 60 lines of spec_helper.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;This is part 13 of the series where we build &lt;strong&gt;DataPorter&lt;/strong&gt;, a mountable Rails engine for data import workflows. In &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-12-beyond-csv-json-and-api-sources-1ljb"&gt;part 12&lt;/a&gt;, we extended the Source layer to support JSON files and API endpoints.&lt;/p&gt;

&lt;p&gt;We have been writing specs throughout the series, but we never stepped back to explain &lt;em&gt;how&lt;/em&gt; the test suite works. A Rails engine is not a Rails app. There is no &lt;code&gt;config/database.yml&lt;/code&gt;. There is no &lt;code&gt;spec/rails_helper.rb&lt;/code&gt; generated by &lt;code&gt;rspec-rails&lt;/code&gt;. There is no schema to load, no test database to create, no &lt;code&gt;ApplicationController&lt;/code&gt; to inherit from. Every piece of infrastructure that a typical Rails project gives you for free -- the database connection, the load paths, the base controller class -- has to be set up manually in a gem's test suite.&lt;/p&gt;

&lt;p&gt;In this article, we look at the full testing strategy: the spec_helper that bootstraps a minimal Rails environment, the patterns we use for each layer (models, controllers, components, generators, JavaScript), and the TDD workflow that shaped the series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;A Rails engine gem ships as a library. It has no &lt;code&gt;bin/rails&lt;/code&gt;, no &lt;code&gt;config/application.rb&lt;/code&gt;, no running server. When you run &lt;code&gt;rspec&lt;/code&gt; inside the gem, Ruby loads your &lt;code&gt;spec_helper.rb&lt;/code&gt; and your spec files -- and that is it. If your code depends on ActiveRecord, you need a database. If it depends on ActionController, you need a controller base class. If it depends on load paths for &lt;code&gt;app/models&lt;/code&gt; or &lt;code&gt;app/controllers&lt;/code&gt;, you need to set those up yourself.&lt;/p&gt;

&lt;p&gt;The conventional solution is a "dummy app" -- a minimal Rails application inside &lt;code&gt;spec/dummy/&lt;/code&gt; that boots the full Rails stack, mounts the engine, and provides the infrastructure specs need. This works, but it comes with overhead: you maintain a separate &lt;code&gt;Gemfile.lock&lt;/code&gt;, a database config, an application class, route files, and all the boilerplate of a Rails app that exists only for tests. When the engine is small and focused, that overhead feels disproportionate.&lt;/p&gt;

&lt;p&gt;DataPorter takes a different approach: no dummy app. The &lt;code&gt;spec_helper.rb&lt;/code&gt; bootstraps just enough Rails to make the specs work, and nothing more. No application class. No route loading. No middleware stack. Just the frameworks we actually use -- ActiveRecord, ActiveJob, ActionController, ActionCable -- configured at the minimum viable level.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;Here is the spec_helper that powers the entire test suite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/spec_helper.rb&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"rails"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"active_record"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"active_job"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"action_controller"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"action_cable"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"data_porter"&lt;/span&gt;

&lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;establish_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;adapter: &lt;/span&gt;&lt;span class="s2"&gt;"sqlite3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;database: &lt;/span&gt;&lt;span class="s2"&gt;":memory:"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;create_table&lt;/span&gt; &lt;span class="ss"&gt;:data_porter_imports&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;force: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;  &lt;span class="ss"&gt;:target_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;  &lt;span class="ss"&gt;:source_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="s2"&gt;"csv"&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt; &lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;    &lt;span class="ss"&gt;:records&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;    &lt;span class="ss"&gt;:report&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;    &lt;span class="ss"&gt;:config&lt;/span&gt;

    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;  &lt;span class="ss"&gt;:user_type&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt; &lt;span class="ss"&gt;:user_id&lt;/span&gt;

    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="sx"&gt;%w[models jobs]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="vg"&gt;$LOAD_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unshift&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"../app/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"data_porter/data_import"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"data_porter/parse_job"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"data_porter/import_job"&lt;/span&gt;

&lt;span class="c1"&gt;# Stub for controller inheritance in test context&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApplicationController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActionController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="k"&gt;defined?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ApplicationController&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="vg"&gt;$LOAD_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unshift&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"../app/controllers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"data_porter/imports_controller"&lt;/span&gt;

&lt;span class="vg"&gt;$LOAD_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unshift&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"../app/channels"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"data_porter/import_channel"&lt;/span&gt;

&lt;span class="vg"&gt;$LOAD_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unshift&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"../lib"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;example_status_persistence_file_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;".rspec_status"&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disable_monkey_patching!&lt;/span&gt;

  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expect_with&lt;/span&gt; &lt;span class="ss"&gt;:rspec&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;syntax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:expect&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixty lines of setup that replace a 200-file dummy app. Every line exists because a spec somewhere needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 -- The in-memory database
&lt;/h3&gt;

&lt;p&gt;The first problem is the database. DataPorter's &lt;code&gt;DataImport&lt;/code&gt; model is an ActiveRecord class that needs a real table with real columns. In a Rails app, you would run &lt;code&gt;rails db:create db:migrate&lt;/code&gt;. In a gem, there is no migration runner. We need to create the schema from scratch every time the specs run.&lt;/p&gt;

&lt;p&gt;SQLite's &lt;code&gt;:memory:&lt;/code&gt; database solves this perfectly. Look at the first block in the spec_helper above: &lt;code&gt;establish_connection&lt;/code&gt; creates a database that lives entirely in memory -- no file on disk, no cleanup needed, no state leaking between test runs. The &lt;code&gt;Schema.define&lt;/code&gt; block creates the table immediately. By the time the first spec runs, the database exists with the right schema.&lt;/p&gt;

&lt;p&gt;The schema here mirrors the migration that the install generator creates for host apps, but it is defined inline rather than loaded from a migration file. This is a deliberate duplication: the spec_helper schema is the &lt;em&gt;test contract&lt;/em&gt;, not the migration itself. If the migration changes and we forget to update the spec_helper, a spec will fail -- which is exactly the signal we want.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;text&lt;/code&gt; columns for &lt;code&gt;records&lt;/code&gt;, &lt;code&gt;report&lt;/code&gt;, and &lt;code&gt;config&lt;/code&gt; are where StoreModel does its work. In production with PostgreSQL, these would be &lt;code&gt;jsonb&lt;/code&gt; columns. In the test suite with SQLite, they are &lt;code&gt;text&lt;/code&gt; columns -- and StoreModel handles the serialization transparently in both cases. This is one of the advantages of StoreModel over raw JSONB: the serialization adapter abstracts the column type difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 -- Load paths and the ApplicationController stub
&lt;/h3&gt;

&lt;p&gt;A Rails engine's code lives in &lt;code&gt;app/models&lt;/code&gt;, &lt;code&gt;app/controllers&lt;/code&gt;, &lt;code&gt;app/jobs&lt;/code&gt;, and &lt;code&gt;app/channels&lt;/code&gt;. In a running Rails app, the autoloader resolves these paths. In a gem's test suite, there is no autoloader. We add the directories to &lt;code&gt;$LOAD_PATH&lt;/code&gt; manually and require each file explicitly (see the middle section of the spec_helper above). Without this, &lt;code&gt;require "data_porter/data_import"&lt;/code&gt; would fail because Ruby does not know to look in &lt;code&gt;app/models&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The controller layer is trickier. &lt;code&gt;ImportsController&lt;/code&gt; inherits from the host app's &lt;code&gt;ApplicationController&lt;/code&gt; (or whatever &lt;code&gt;DataPorter.configuration.parent_controller&lt;/code&gt; is set to). In a host Rails app, that class already exists. In the gem's test suite, it does not. We stub it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApplicationController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActionController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="k"&gt;defined?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ApplicationController&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;unless defined?&lt;/code&gt; guard is important. If something else in the test environment has already defined &lt;code&gt;ApplicationController&lt;/code&gt; (which happens in some CI setups), we do not want to redefine it. The stub gives us just enough to verify that &lt;code&gt;ImportsController&lt;/code&gt; is loadable, inherits from the right parent, and defines the expected methods. It does not give us routing or request processing -- but we do not need those for the kind of controller testing we do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 -- Testing models and StoreModel objects
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;DataImport&lt;/code&gt; model uses StoreModel for its &lt;code&gt;records&lt;/code&gt;, &lt;code&gt;report&lt;/code&gt;, and &lt;code&gt;config&lt;/code&gt; attributes. The model specs test validations, enum behavior, and persistence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/data_porter/data_import_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DataImport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :model&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:target_class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;Class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s2"&gt;"Guests"&lt;/span&gt;
      &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="s2"&gt;"Guest"&lt;/span&gt;
      &lt;span class="n"&gt;icon&lt;/span&gt; &lt;span class="s2"&gt;"fas fa-users"&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;
    &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:guests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"validations"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"requires target_key"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;import&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;source_type: &lt;/span&gt;&lt;span class="s2"&gt;"csv"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;not_to&lt;/span&gt; &lt;span class="n"&gt;be_valid&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:target_key&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"can't be blank"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"persistence"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"saves and reloads with records"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;import&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;target_key: &lt;/span&gt;&lt;span class="s2"&gt;"guests"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;source_type: &lt;/span&gt;&lt;span class="s2"&gt;"csv"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;user_type: &lt;/span&gt;&lt;span class="s2"&gt;"User"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;StoreModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ImportRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;line_number: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;data: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"Alice"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;records: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

      &lt;span class="n"&gt;reloaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reloaded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;line_number&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reloaded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="s2"&gt;"name"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Alice"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things stand out. First, every spec that depends on a target class creates an &lt;strong&gt;anonymous class&lt;/strong&gt; with &lt;code&gt;Class.new(DataPorter::Target)&lt;/code&gt; and registers it in the &lt;code&gt;before&lt;/code&gt; block. This avoids polluting the global namespace with named test classes and ensures each spec controls its own target definition. The &lt;code&gt;Registry.clear&lt;/code&gt; in &lt;code&gt;before&lt;/code&gt; prevents target registrations from one spec leaking into another.&lt;/p&gt;

&lt;p&gt;Second, the persistence spec exercises the full StoreModel round-trip: create a DataImport, add ImportRecord objects to the &lt;code&gt;records&lt;/code&gt; attribute, save, reload from the database, and verify the data survived serialization. This is critical because StoreModel serializes to JSON under the hood, and the round-trip through SQLite's &lt;code&gt;text&lt;/code&gt; column can surface subtle issues -- symbol keys becoming string keys, for instance, which is exactly what &lt;code&gt;{ "name" =&amp;gt; "Alice" }&lt;/code&gt; (not &lt;code&gt;{ name: "Alice" }&lt;/code&gt;) is testing.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ImportRecord&lt;/code&gt; StoreModel type has no table -- it lives inside a &lt;code&gt;DataImport&lt;/code&gt;'s &lt;code&gt;records&lt;/code&gt; array. Testing it requires no database at all: instantiate with a hash, call methods, assert on state. The key spec verifies that &lt;code&gt;#attributes&lt;/code&gt; returns symbolized, compacted data (&lt;code&gt;{ name: "Alice" }&lt;/code&gt; not &lt;code&gt;{ "name" =&amp;gt; "Alice", "email" =&amp;gt; nil }&lt;/code&gt;), because the target's &lt;code&gt;persist&lt;/code&gt; method depends on this contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 -- Testing controllers structurally
&lt;/h3&gt;

&lt;p&gt;In a full Rails app, you would test controllers with request specs: send a POST, check the response status, verify the redirect. In a gem without a dummy app, there is no router, no middleware stack, and no request processing. We cannot send HTTP requests because there is nowhere to send them.&lt;/p&gt;

&lt;p&gt;Instead, we test controllers &lt;em&gt;structurally&lt;/em&gt; -- verifying the class hierarchy, the callback registrations, and the method signatures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/data_porter/imports_controller_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ImportsController&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"inheritance"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"inherits from the configured parent controller"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;superclass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parent_controller&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"before_actions"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"registers set_import callback"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_process_action_callbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="ss"&gt;:set_import&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;not_to&lt;/span&gt; &lt;span class="n"&gt;be_empty&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"action methods"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"defines index, new, create, show, parse, confirm, and cancel"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sx"&gt;%i[index new create show parse confirm cancel]&lt;/span&gt;
      &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be_a&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UnboundMethod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"private methods"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"defines set_import"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;private_instance_methods&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:set_import&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"defines import_params"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;private_instance_methods&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:import_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach tests four things that matter at the gem level: (1) the controller inherits from the right parent class, which proves the dynamic inheritance mechanism works; (2) the &lt;code&gt;before_action&lt;/code&gt; callbacks are registered, which proves the controller's lifecycle is set up correctly; (3) every expected action method exists as an instance method; (4) the private helper methods exist and are private.&lt;/p&gt;

&lt;p&gt;What this does &lt;em&gt;not&lt;/em&gt; test is the actual behavior of those actions -- what happens when you call &lt;code&gt;create&lt;/code&gt; with certain params, or whether &lt;code&gt;show&lt;/code&gt; renders the right component. Those are integration concerns that belong in the host app's test suite, or in a separate integration test suite with a dummy app. The gem-level specs verify the &lt;em&gt;contract&lt;/em&gt; -- the controller exists, has the right shape, and plugs into the right place. The host app tests verify the &lt;em&gt;behavior&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;_process_action_callbacks&lt;/code&gt; API is an internal Rails method that returns the registered callbacks. It is not a public API, but it is stable across Rails versions and it is the only way to verify callback registration without actually processing a request. Using it here is a pragmatic choice -- the alternative is a full request spec, which requires a dummy app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 -- Testing Phlex components
&lt;/h3&gt;

&lt;p&gt;Phlex components are the easiest layer to test in a gem context because they have zero Rails dependencies. A Phlex component is a Ruby object with a &lt;code&gt;call&lt;/code&gt; method that returns an HTML string. No view context, no request, no controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/data_porter/components/preview_table_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PreviewTable&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;component&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;component&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:target_class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;Class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s2"&gt;"Guests"&lt;/span&gt;
      &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="s2"&gt;"Guest"&lt;/span&gt;
      &lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
        &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;StoreModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ImportRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;line_number: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"complete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;data: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"first_name"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"last_name"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Smith"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"renders column headers from target"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;columns: &lt;/span&gt;&lt;span class="n"&gt;target_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;records: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"First name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Last name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;render&lt;/code&gt; helper is just &lt;code&gt;component.call&lt;/code&gt;. The assertions are string-based (&lt;code&gt;include("Alice")&lt;/code&gt;) rather than DOM-based (&lt;code&gt;have_css("td", text: "Alice")&lt;/code&gt;) -- no Capybara, no Nokogiri. String matching is sufficient for verifying content in component output, and it keeps the test dependencies minimal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6 -- Testing generators
&lt;/h3&gt;

&lt;p&gt;Generator specs follow the same structural pattern as controllers: verify inheritance, source root, and method definitions. We covered the full walkthrough in &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-11-generators-install-target-scaffolding-2i58"&gt;part 11&lt;/a&gt; -- the key insight is that we test the generator &lt;em&gt;class&lt;/em&gt;, not its filesystem output, because running &lt;code&gt;rails generate&lt;/code&gt; would require a dummy app. This catches the most common bugs: misspelled method names, wrong source roots, or missing base classes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7 -- Testing JavaScript from Ruby specs
&lt;/h3&gt;

&lt;p&gt;DataPorter ships a Stimulus controller in &lt;code&gt;app/javascript/data_porter/progress_controller.js&lt;/code&gt;. In a typical frontend project, you would test this with Jest or Vitest, running the JavaScript in a Node.js environment. But DataPorter is a Ruby gem. Adding a JavaScript test runner means adding &lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt;, and a separate CI step. For a single 30-line Stimulus controller, that is a lot of overhead.&lt;/p&gt;

&lt;p&gt;Instead, we test the JavaScript file as a &lt;em&gt;text artifact&lt;/em&gt; from Ruby:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/data_porter/progress_controller_js_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"progress_controller.js"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;js_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"imports Stimulus Controller"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'import { Controller } from "@hotwired/stimulus"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"subscribes to ImportChannel on connect"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"DataPorter::ImportChannel"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"updates progress bar width and text"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"this.barTarget.style.width"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the file as a string, assert it contains the expected patterns. No JavaScript execution, no Node.js toolchain. This catches a specific class of bugs: someone renames the ActionCable channel in Ruby but forgets to update the JS subscription string, or removes a Stimulus target from the HTML but leaves the declaration in the controller. These are integration seams where Ruby and JavaScript must agree, and string matching catches disagreements.&lt;/p&gt;

&lt;p&gt;Is this a substitute for real JS testing? No. But for a single 30-line Stimulus controller, it provides meaningful coverage without adding &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;node_modules&lt;/code&gt; to the project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8 -- The anonymous target class pattern
&lt;/h3&gt;

&lt;p&gt;A pattern that runs through nearly every spec file is the anonymous target class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:target_class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;Class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s2"&gt;"Guests"&lt;/span&gt;
    &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="s2"&gt;"Guest"&lt;/span&gt;
    &lt;span class="n"&gt;icon&lt;/span&gt; &lt;span class="s2"&gt;"fas fa-users"&lt;/span&gt;
    &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="ss"&gt;:csv&lt;/span&gt;

    &lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
      &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;
      &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :email&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;csv_mapping&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="s2"&gt;"First Name"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="ss"&gt;:first_name&lt;/span&gt;
      &lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="s2"&gt;"Last Name"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="ss"&gt;:last_name&lt;/span&gt;
      &lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="s2"&gt;"Email"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;define_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:persist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**|&lt;/span&gt;
      &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a new class that inherits from &lt;code&gt;DataPorter::Target&lt;/code&gt; without giving it a constant name. Each spec defines exactly the target it needs -- the Orchestrator spec defines one with &lt;code&gt;persist&lt;/code&gt;, the CSV spec defines one with &lt;code&gt;csv_mapping&lt;/code&gt;, the PreviewTable spec defines one with just &lt;code&gt;columns&lt;/code&gt;. No spec depends on a shared target fixture that might change.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;define_method(:persist)&lt;/code&gt; pattern (instead of &lt;code&gt;def persist&lt;/code&gt;) is required because anonymous class blocks use &lt;code&gt;Class.new&lt;/code&gt; with a block, and &lt;code&gt;def&lt;/code&gt; inside that block defines a method on the class -- but if the method needs to capture a local variable (like &lt;code&gt;records&lt;/code&gt; for the collection array), &lt;code&gt;define_method&lt;/code&gt; with a closure is the only way. This is a Ruby metaprogramming detail that comes up often when testing DSL-heavy code.&lt;/p&gt;

&lt;p&gt;The companion pattern is registry cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;
  &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:guests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every spec that registers a target clears the registry before and after. Without this, target registrations accumulate across specs, leading to flaky tests where the order of execution matters. The &lt;code&gt;clear&lt;/code&gt; method on the Registry exists primarily for this testing use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions &amp;amp; tradeoffs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;We chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Test infrastructure&lt;/td&gt;
&lt;td&gt;Inline spec_helper with in-memory SQLite&lt;/td&gt;
&lt;td&gt;Dummy Rails app in &lt;code&gt;spec/dummy/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Less maintenance overhead, faster boot time, no separate Gemfile or config to maintain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controller testing&lt;/td&gt;
&lt;td&gt;Structural tests (inheritance, callbacks, method existence)&lt;/td&gt;
&lt;td&gt;Request specs with a dummy app&lt;/td&gt;
&lt;td&gt;Verifies the contract without requiring routing, middleware, or a full request cycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component testing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;component.call&lt;/code&gt; with string assertions&lt;/td&gt;
&lt;td&gt;Capybara or Nokogiri DOM assertions&lt;/td&gt;
&lt;td&gt;Minimal dependencies; string matching is sufficient for verifying content in component output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generator testing&lt;/td&gt;
&lt;td&gt;Class-level structural tests&lt;/td&gt;
&lt;td&gt;Running the generator against a temp directory&lt;/td&gt;
&lt;td&gt;Catches wiring bugs without requiring a writable Rails app filesystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript testing&lt;/td&gt;
&lt;td&gt;Ruby string matching on file content&lt;/td&gt;
&lt;td&gt;Jest/Vitest with a Node.js test runner&lt;/td&gt;
&lt;td&gt;Avoids adding a JS toolchain for a single 30-line file; tests the Ruby/JS integration contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target fixtures&lt;/td&gt;
&lt;td&gt;Anonymous classes per spec with &lt;code&gt;Class.new&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Shared named target classes&lt;/td&gt;
&lt;td&gt;Each spec controls its own target definition; no coupling between specs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registry isolation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;clear&lt;/code&gt; before and after each spec that registers targets&lt;/td&gt;
&lt;td&gt;Global shared state&lt;/td&gt;
&lt;td&gt;Prevents registration leakage and ordering-dependent test failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StoreModel testing&lt;/td&gt;
&lt;td&gt;Direct instantiation without database&lt;/td&gt;
&lt;td&gt;Round-trip through ActiveRecord&lt;/td&gt;
&lt;td&gt;StoreModel objects are plain Ruby objects; database round-trips are tested separately on the DataImport model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The TDD workflow
&lt;/h2&gt;

&lt;p&gt;In a Rails app, you can spike code, hit refresh, and see if it works. In a gem, there is no browser -- the spec suite &lt;em&gt;is&lt;/em&gt; the feedback loop. Every feature in this series followed the same cycle: write a spec that describes the behavior, watch it fail, implement the minimum code to make it pass. The anonymous target classes, the structural controller tests, the JS string matching -- all of these patterns emerged from necessity, not theory. When you have no dummy app and no browser, you find the simplest way to verify each layer's contract, and that simplicity turns out to be an advantage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No dummy app&lt;/strong&gt;: the spec_helper bootstraps an in-memory SQLite database, sets up load paths, and stubs &lt;code&gt;ApplicationController&lt;/code&gt; -- just enough Rails to test a full engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;StoreModel objects&lt;/strong&gt; are tested as plain Ruby objects, with database round-trip tests on the parent ActiveRecord model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controllers&lt;/strong&gt; are tested structurally -- inheritance, callbacks, and method existence -- rather than with request specs, because the gem has no router.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phlex components&lt;/strong&gt; are tested by calling &lt;code&gt;.call&lt;/code&gt; and asserting against the HTML string, with no view context or browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generators&lt;/strong&gt; are tested by verifying class hierarchy, source root, and method definitions -- the wiring, not the filesystem output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript files&lt;/strong&gt; are tested as text artifacts from Ruby, verifying the contract between the JS and the rest of the engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anonymous target classes&lt;/strong&gt; with &lt;code&gt;Class.new&lt;/code&gt; keep each spec self-contained, and &lt;code&gt;Registry.clear&lt;/code&gt; prevents state leakage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;Now that we have a tested, fully-featured engine, there is one more safety net to add before imports touch the database. In part 14, we build &lt;strong&gt;Dry Run&lt;/strong&gt; -- a validation mode that wraps the import in a transaction, rolls it back, and enriches records with the database-level errors that the preview phase cannot catch. Preview validates the &lt;em&gt;data&lt;/em&gt;; dry run validates the &lt;em&gt;persistence&lt;/em&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part 13 of the series "Building DataPorter - A Data Import Engine for Rails". &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-12-beyond-csv-json-and-api-sources-1ljb"&gt;Previous: Adding JSON &amp;amp; API Sources&lt;/a&gt; | Next: Dry Run: Validate Before You Persist&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/SerylLns/data_porter" rel="noopener noreferrer"&gt;SerylLns/data_porter&lt;/a&gt; | &lt;strong&gt;RubyGems:&lt;/strong&gt; &lt;a href="https://rubygems.org/gems/data_porter" rel="noopener noreferrer"&gt;data_porter&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>opensource</category>
      <category>testing</category>
    </item>
    <item>
      <title>Building a Rails Engine #12 -- Beyond CSV: JSON and API Sources</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Thu, 19 Mar 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/seryllns_/building-a-rails-engine-12-beyond-csv-json-and-api-sources-1ljb</link>
      <guid>https://dev.to/seryllns_/building-a-rails-engine-12-beyond-csv-json-and-api-sources-1ljb</guid>
      <description>&lt;h2&gt;
  
  
  Beyond CSV: JSON and API Sources
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;CSV is the king of data import -- but in the real world, data also arrives as JSON from a file, or directly from a third-party API. Here is how DataPorter extends its source architecture to absorb these new formats without breaking anything.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;This is part 12 of the series where we build &lt;strong&gt;DataPorter&lt;/strong&gt;, a mountable Rails engine for data import workflows. In part 11, we built the install and target generators so adopting the gem requires a single command.&lt;/p&gt;

&lt;p&gt;Until now, DataPorter only reads CSV. That covers a lot of cases, but the real world is more varied. If every new format requires rearchitecting the engine, something went wrong. The &lt;code&gt;Sources::Base&lt;/code&gt; abstraction we established in part 6 is about to prove its worth.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://explaina.app/d/4bd1d069-ba9e-4b9d-a263-ce89ed635e09" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Interactive Walkthrough →&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://explaina.app/d/4bd1d069-ba9e-4b9d-a263-ce89ed635e09" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fexplaina.app%2Ft%2F4bd1d069-ba9e-4b9d-a263-ce89ed635e09" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://explaina.app/d/4bd1d069-ba9e-4b9d-a263-ce89ed635e09" rel="noopener noreferrer" class="c-link"&gt;
            DataPorter Sources Architecture — Explaina
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Interactive explorer showing how one fetch contract powers CSV, JSON, and API sources — pick a source type and see the code.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fexplaina.app%2Ffavicon-light.png"&gt;
          explaina.app
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;




&lt;h2&gt;
  
  
  Why multiple sources?
&lt;/h2&gt;

&lt;p&gt;An import engine that only speaks CSV forces users to convert their data before importing. That is unnecessary friction. Your hotel partner sends a nightly JSON export of guest reservations. Your CRM exposes a REST API with paginated contacts. A front-end form lets operators paste raw JSON into a text field. Each of these is a valid data source, and none of them is a CSV.&lt;/p&gt;

&lt;p&gt;By supporting JSON and API natively, we cover all three without asking users to convert anything first. The key point: every source must respect the same contract -- a &lt;code&gt;fetch&lt;/code&gt; method that returns an array of hashes with symbol keys. The rest of the pipeline (validation, transformation, persistence) does not change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JSON source
&lt;/h2&gt;

&lt;p&gt;The JSON source must handle three ways to receive content: direct injection (for tests or programmatic use), raw JSON stored in the import configuration, and download from an ActiveStorage attachment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/sources/json.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Sources&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Json&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_import&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_import&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="vi"&gt;@content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;
        &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json_content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extract_records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
          &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform_keys&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parameterize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;separator: &lt;/span&gt;&lt;span class="s2"&gt;"_"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_sym&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="kp"&gt;private&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;json_content&lt;/span&gt;
        &lt;span class="vi"&gt;@content&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;config_raw_json&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;download_file&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;config_raw_json&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"raw_json"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;download_file&lt;/span&gt;
        &lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@target_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_json_root&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;

        &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things stand out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;json_content&lt;/code&gt; cascade.&lt;/strong&gt; The method tries three sources in order: content injected into the constructor, the &lt;code&gt;raw_json&lt;/code&gt; key in the import configuration, and finally the ActiveStorage file. This cascade allows great flexibility without explicit configuration -- the right path is chosen automatically based on what is available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;json_root&lt;/code&gt; for nested paths.&lt;/strong&gt; Real-world APIs and JSON files often wrap data in a structure: &lt;code&gt;{"data": {"guests": [...]}}&lt;/code&gt;. Rather than forcing the user to flatten their JSON, we give them a DSL method in the Target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GuestsTarget&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Target&lt;/span&gt;
  &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s2"&gt;"Guests"&lt;/span&gt;
  &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="s2"&gt;"Guest"&lt;/span&gt;
  &lt;span class="n"&gt;json_root&lt;/span&gt; &lt;span class="s2"&gt;"data.guests"&lt;/span&gt;

  &lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;extract_records&lt;/code&gt; method uses &lt;code&gt;dig&lt;/code&gt; by splitting the path on dots. &lt;code&gt;"data.guests"&lt;/code&gt; becomes &lt;code&gt;parsed.dig("data", "guests")&lt;/code&gt;. Simple, readable, and supports any level of nesting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key normalization.&lt;/strong&gt; As with the CSV source, every key is transformed via &lt;code&gt;parameterize(separator: "_").to_sym&lt;/code&gt;. &lt;code&gt;"First Name"&lt;/code&gt; becomes &lt;code&gt;:first_name&lt;/code&gt;. This guarantees the rest of the pipeline always receives keys in the same format, regardless of the source format.&lt;/p&gt;

&lt;p&gt;The JSON source covers file-based and programmatic use cases. But sometimes the data lives behind an HTTP endpoint, and the engine needs to go fetch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API source
&lt;/h2&gt;

&lt;p&gt;The API source fetches data from an HTTP endpoint. It must support static and dynamic endpoints, fixed and lazily-generated headers, and data extraction from a response key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/sources/api.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Sources&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Api&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;
        &lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@target_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_api_config&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;perform_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extract_records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
          &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform_keys&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parameterize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;separator: &lt;/span&gt;&lt;span class="s2"&gt;"_"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_sym&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="kp"&gt;private&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolve_endpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolve_headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;URI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="no"&gt;Net&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;use_ssl: &lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheme&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"https"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
          &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Net&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_endpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;symbolize_keys&lt;/span&gt;
        &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Proc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endpoint&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Proc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;response_root&lt;/span&gt;
        &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core logic lives in &lt;code&gt;resolve_endpoint&lt;/code&gt; and &lt;code&gt;resolve_headers&lt;/code&gt;. Each accepts either a static value or a lambda. This opens two usage modes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Static endpoint, fixed headers&lt;/span&gt;
&lt;span class="n"&gt;api_config&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="s2"&gt;"https://api.example.com/stays"&lt;/span&gt;
  &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="s2"&gt;"Authorization"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Bearer abc123"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="n"&gt;response_root&lt;/span&gt; &lt;span class="ss"&gt;:stays&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# Dynamic endpoint, lazily-generated headers&lt;/span&gt;
&lt;span class="n"&gt;api_config&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"https://api.example.com/items?id=&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:item_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"Authorization"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Bearer &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the endpoint lambda, parameters come from &lt;code&gt;@data_import.config.symbolize_keys&lt;/code&gt;. The user passes &lt;code&gt;config: { item_id: "42" }&lt;/code&gt; when creating the import, and the lambda receives those parameters to build the URL. For headers, the lambda is called with no argument -- it retrieves the token from wherever it lives (environment variable, model, external service).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;response_root&lt;/code&gt; works like &lt;code&gt;json_root&lt;/code&gt; from the JSON source, but simpler: it extracts a single key from the response hash. &lt;code&gt;response_root :stays&lt;/code&gt; on a response &lt;code&gt;{"stays": [...]}&lt;/code&gt; returns the array directly. If no &lt;code&gt;response_root&lt;/code&gt; is defined, the entire response is used.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ApiConfig DSL pattern
&lt;/h2&gt;

&lt;p&gt;The API configuration uses a dedicated DSL object rather than plain &lt;code&gt;attr_accessor&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/dsl/api_config.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DSL&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiConfig&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="vi"&gt;@endpoint&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nil?&lt;/span&gt;

        &lt;span class="vi"&gt;@endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="vi"&gt;@headers&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nil?&lt;/span&gt;

        &lt;span class="vi"&gt;@headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;response_root&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="vi"&gt;@response_root&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nil?&lt;/span&gt;

        &lt;span class="vi"&gt;@response_root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each method plays a dual role: called with an argument, it acts as a setter; called without, it acts as a getter. In the Target, &lt;code&gt;api_config&lt;/code&gt; creates an &lt;code&gt;ApiConfig&lt;/code&gt; instance and executes the block via &lt;code&gt;instance_eval&lt;/code&gt; -- the same DSL object + &lt;code&gt;instance_eval&lt;/code&gt; pattern we used for the &lt;code&gt;columns&lt;/code&gt; block. A classic Ruby idiom that gives clean syntax while keeping the implementation testable as a plain PORO.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dispatch via Sources.resolve
&lt;/h2&gt;

&lt;p&gt;Adding new sources does not modify any existing code. The &lt;code&gt;Sources&lt;/code&gt; module maintains a simple registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/sources.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Sources&lt;/span&gt;
    &lt;span class="no"&gt;REGISTRY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="ss"&gt;api: &lt;/span&gt;&lt;span class="no"&gt;Api&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;csv: &lt;/span&gt;&lt;span class="no"&gt;Csv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="no"&gt;Json&lt;/span&gt;
    &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="no"&gt;REGISTRY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_sym&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Unknown source type: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Orchestrator calls &lt;code&gt;Sources.resolve(import.source_type)&lt;/code&gt; and receives the right class. It then instantiates the source and calls &lt;code&gt;fetch&lt;/code&gt;. Neither the Orchestrator nor the controllers know which source type is being used -- it is the &lt;code&gt;source_type&lt;/code&gt; stored in the import that decides. Adding an XML or Parquet source would require: a class inheriting from &lt;code&gt;Base&lt;/code&gt;, one entry in the &lt;code&gt;REGISTRY&lt;/code&gt;, and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TDD approach
&lt;/h2&gt;

&lt;p&gt;Both sources were built test-first. The JSON source specs cover each path through the cascade -- direct injection, &lt;code&gt;json_root&lt;/code&gt; extraction, and &lt;code&gt;raw_json&lt;/code&gt; fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"parses JSON array content"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'[{"first_name": "Alice", "last_name": "Smith"}]'&lt;/span&gt;
  &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:first_name&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"extracts records from a nested path"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{"data": {"guests": [{"name": "Alice"}, {"name": "Bob"}]}}'&lt;/span&gt;
  &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import_with_root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API source specs stub &lt;code&gt;Net::HTTP.start&lt;/code&gt; and test the same axes: static vs lambda endpoint, header resolution, and &lt;code&gt;response_root&lt;/code&gt; extraction. We are not testing that &lt;code&gt;Net::HTTP&lt;/code&gt; works -- we are testing that our code correctly composes the URL, headers, and extracts the right data from the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions &amp;amp; tradeoffs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;We chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP client&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Net::HTTP&lt;/code&gt; (stdlib)&lt;/td&gt;
&lt;td&gt;Faraday, HTTParty&lt;/td&gt;
&lt;td&gt;Zero extra dependency; sufficient for simple GETs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic endpoint&lt;/td&gt;
&lt;td&gt;Lambda receiving &lt;code&gt;params&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;String with interpolation&lt;/td&gt;
&lt;td&gt;The lambda allows any logic (conditions, service calls) without string eval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic headers&lt;/td&gt;
&lt;td&gt;Lambda with no argument&lt;/td&gt;
&lt;td&gt;Callback with context&lt;/td&gt;
&lt;td&gt;Headers often come from a global service (ENV, token store), not the import context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON cascade&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;content&lt;/code&gt; &amp;gt; &lt;code&gt;raw_json&lt;/code&gt; &amp;gt; &lt;code&gt;file&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Mandatory argument&lt;/td&gt;
&lt;td&gt;Maximum flexibility; each use case finds its natural path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key normalization&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;parameterize&lt;/code&gt; + &lt;code&gt;to_sym&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Explicit mapping&lt;/td&gt;
&lt;td&gt;Consistent with the CSV source; the downstream pipeline always receives the same format&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;JSON source&lt;/strong&gt; supports three input modes (injection, config &lt;code&gt;raw_json&lt;/code&gt;, file) via a cascade of fallbacks, and uses &lt;code&gt;json_root&lt;/code&gt; to navigate nested structures.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;API source&lt;/strong&gt; dynamically resolves endpoints and headers through a static/lambda dual system, and extracts data via &lt;code&gt;response_root&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;ApiConfig&lt;/code&gt; DSL&lt;/strong&gt; uses a getter/setter pattern without &lt;code&gt;attr_reader&lt;/code&gt;, evaluated in an &lt;code&gt;instance_eval&lt;/code&gt; block for natural syntax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Sources.resolve&lt;/code&gt;&lt;/strong&gt; dispatches to the right class via a frozen registry -- adding a source is a two-line operation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests&lt;/strong&gt; cover every path through every source without touching the network, thanks to content injection and HTTP stubbing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;JSON and API sources complete the trio of supported formats. But we have not yet talked about the engine's overall testing strategy -- how to test a Rails engine without a full host application, how to organize specs between unit and integration tests, how to mock ActiveStorage and ActionCable. In part 13, we dive into &lt;strong&gt;testing a Rails engine with RSpec&lt;/strong&gt; and the patterns that keep the suite fast and reliable.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part 12 of the series "Building DataPorter - A Data Import Engine for Rails". Previous: Generators: Install &amp;amp; Target Scaffolding | Next: Testing a Rails Engine with RSpec&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>api</category>
      <category>development</category>
    </item>
    <item>
      <title>Turn Messy WhatsApp Messages Into Structured JSON-Reliably</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Tue, 17 Mar 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/seryllns_/designing-deterministic-outputs-from-unstructured-messages-2hlc</link>
      <guid>https://dev.to/seryllns_/designing-deterministic-outputs-from-unstructured-messages-2hlc</guid>
      <description>&lt;h1&gt;
  
  
  Designing Deterministic Outputs From Unstructured Messages
&lt;/h1&gt;

&lt;p&gt;Your users send messy, unstructured text. Your backend expects clean, validated JSON. Bridging that gap is the hard part — and most teams get it wrong.&lt;/p&gt;

&lt;p&gt;This article walks through &lt;strong&gt;how to turn a raw WhatsApp message into a structured payload your application can safely act on.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;A real message from a hotel guest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hey can u move bk-4521 to fri plzz?? also ned more towls in rm 312 thx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typos. Abbreviations. Two requests in one message. No punctuation.&lt;/p&gt;

&lt;p&gt;Your PMS expects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"booking_ref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BK-4521"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"new_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-03-14"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"room"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"312"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"extra towels"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How do you get from A to B — &lt;strong&gt;reliably?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Define Your Schema
&lt;/h2&gt;

&lt;p&gt;Before touching any LLM, define what your backend expects. This is your contract.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string (enum)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string (action identifier)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"field_1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string | number | null"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"field_2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string | number | null"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number (0-1)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"suggested_reply"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string | null"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key principles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Finite set of intents&lt;/strong&gt; — your agent only handles what you define&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed fields&lt;/strong&gt; — each field has an expected type&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nullable&lt;/strong&gt; — missing data is &lt;code&gt;null&lt;/code&gt;, not hallucinated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidence score&lt;/strong&gt; — so your backend can decide when to auto-act vs. ask for confirmation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: Extract, Don't Converse
&lt;/h2&gt;

&lt;p&gt;The classic mistake is building a multi-turn flow to gather missing fields. Instead, extract everything from the first message in a &lt;strong&gt;single LLM call&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The messy message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hey can u move bk-4521 to fri plzz?? also ned more towls in rm 312 thx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gets processed into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"modify_booking"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.93&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reservation.booking.reschedule"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"booking_ref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BK-4521"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"new_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-03-14"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"housekeeping.task.create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"room"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"312"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"extra towels"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"suggested_reply"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Done! Booking BK-4521 moved to Friday. Extra towels are on the way to room 312."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One message → two actions. No follow-up questions needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxj6owz8hicjugv9df7u5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxj6owz8hicjugv9df7u5.gif" alt="worflow action" width="760" height="361"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Validate Before Acting
&lt;/h2&gt;

&lt;p&gt;Never trust raw LLM output blindly. Validate the payload before executing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Your webhook handler&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_agent_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s2"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="c1"&gt;# Check confidence threshold&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;

  &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"booking.reschedule"&lt;/span&gt;
      &lt;span class="c1"&gt;# Validate booking exists&lt;/span&gt;
      &lt;span class="n"&gt;booking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Booking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;ref: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"booking_ref"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt;

      &lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reschedule!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;new_date: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"new_date"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"housekeeping.task.create"&lt;/span&gt;
      &lt;span class="no"&gt;HousekeepingTask&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;room: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"room"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;items: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="c1"&gt;# Send the suggested reply back via WhatsApp&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"suggested_reply"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;send_whatsapp_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;session_id: &lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s2"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="ss"&gt;text: &lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"suggested_reply"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your backend stays in control. The LLM proposes, your code decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Handle the Edges
&lt;/h2&gt;

&lt;p&gt;What about messages that don't match any intent?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"What's the weather like in Paris tomorrow?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"system.noop.skip"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"suggested_reply"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"analysis_summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The message is unrelated to hotel services."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No action taken. No hallucinated response. No made-up answer about Paris weather. The system &lt;strong&gt;knows what it doesn't handle&lt;/strong&gt; and stays silent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwkxzmp0gqwqq70ae54wb.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwkxzmp0gqwqq70ae54wb.gif" alt="Agent noop" width="760" height="361"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the difference between a &lt;strong&gt;deterministic system&lt;/strong&gt; and a chatbot that tries to answer everything. A chatbot would hallucinate a weather forecast. A message-driven system returns &lt;code&gt;noop&lt;/code&gt; and moves on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Raw message
  ↓
Pre-filter (spam, noise → free, no LLM)
  ↓
Router (classify → which agent handles this?)
  ↓
Agent (LLM → structured payload with schema)
  ↓
Webhook (HMAC-signed → your backend)
  ↓
Validate (confidence check, field validation)
  ↓
Execute (create task, update booking, send reply)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://whatsrb.com/flows/inbound-flow.html" rel="noopener noreferrer"&gt;Explore the full pipeline in detail →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each layer adds reliability:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pre-filter&lt;/strong&gt; removes noise before LLM cost&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Router&lt;/strong&gt; ensures the right agent handles each message type&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema&lt;/strong&gt; constrains LLM output to valid shapes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidence&lt;/strong&gt; lets you set auto-action thresholds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks&lt;/strong&gt; decouple processing from action&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What You Get
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Typical chatbot&lt;/th&gt;
&lt;th&gt;Message-driven&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Round-trips per request&lt;/td&gt;
&lt;td&gt;3-5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;5-15s (multi-turn)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1-2s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State management&lt;/td&gt;
&lt;td&gt;Complex&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;None&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy on intent&lt;/td&gt;
&lt;td&gt;~70%&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;95%+&lt;/strong&gt; (on our hotel dataset)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handles typos/slang&lt;/td&gt;
&lt;td&gt;Poorly&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Well&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend integration&lt;/td&gt;
&lt;td&gt;Custom per-flow&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Standard webhook&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This pipeline works because it treats messages as &lt;strong&gt;data extraction problems&lt;/strong&gt;, not conversations. Define the shape, let the LLM fill it, validate before acting. If you want to skip building it yourself:&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://whatsrb.com" rel="noopener noreferrer"&gt;&lt;strong&gt;WhatsRB Cloud&lt;/strong&gt;&lt;/a&gt; handles this entire pipeline — from raw WhatsApp message to structured webhook. Define your intents, set your schema, and let the platform do the extraction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Beta opens March 31, 2026.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://whatsrb.com/signup" rel="noopener noreferrer"&gt;Join the waitlist →&lt;/a&gt;&lt;/strong&gt; | &lt;strong&gt;&lt;a href="https://readme.whatsrb.com" rel="noopener noreferrer"&gt;Read the API docs →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No chatbot framework. No dialog trees. Just clean data from messy messages.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Ruby, Rails, and the belief that most messages don't need a conversation — they need an action.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>agents</category>
      <category>architecture</category>
      <category>automation</category>
    </item>
    <item>
      <title>How We Route WhatsApp Messages to N Agents With a Single LLM Call</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Tue, 17 Mar 2026 16:58:28 +0000</pubDate>
      <link>https://dev.to/seryllns_/how-we-route-whatsapp-messages-to-n-agents-with-a-single-llm-call-5d22</link>
      <guid>https://dev.to/seryllns_/how-we-route-whatsapp-messages-to-n-agents-with-a-single-llm-call-5d22</guid>
      <description>&lt;p&gt;Most teams building AI-powered messaging systems make the same mistake: they run every inbound message through every agent. Got 5 agents? That's 5 LLM calls per message. Your users send "👍" and you just burned $0.003 classifying a thumbs-up five times.&lt;/p&gt;

&lt;p&gt;We needed a better approach. Here's the pipeline we built — and why each layer exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Naive Approach (And Why It Hurts)
&lt;/h2&gt;

&lt;p&gt;The obvious architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Message arrives
  → Agent 1: "Is this for me?" (LLM call)
  → Agent 2: "Is this for me?" (LLM call)
  → Agent 3: "Is this for me?" (LLM call)
  → Agent 4: "Is this for me?" (LLM call)
  → Agent 5: "Is this for me?" (LLM call)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;5 LLM calls. 5× the latency. 5× the cost. And 4 of them will say "nah, not for me."&lt;/p&gt;

&lt;p&gt;Now imagine your hotel WhatsApp gets 500 messages/day. That's 2,500 LLM calls just for routing. Most of them are "ok", "👍", "thx", and emoji reactions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're paying OpenAI to classify thumbs-ups.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Pipeline: Free Before Paid
&lt;/h2&gt;

&lt;p&gt;Our philosophy is simple: &lt;strong&gt;filter what you can for free, before you spend tokens.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzy2p1ljwscxkoaauhc6x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzy2p1ljwscxkoaauhc6x.png" alt="Routing Pipeline Diagram"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's walk through each layer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 1: Trailing-Edge Debounce
&lt;/h2&gt;

&lt;p&gt;People don't send one message. They send three:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;14:02:01  "I need room service"
14:02:04  "room 204"
14:02:07  "before 2pm if possible"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Without debounce, you'd process "I need room service" and miss the room number. The agent would hallucinate it or ask a follow-up question. Neither is great.&lt;/p&gt;

&lt;p&gt;We use a &lt;strong&gt;trailing-edge debounce&lt;/strong&gt;: each inbound message schedules a delayed job (say, 3 seconds). When the job fires, it checks if newer messages arrived for the same conversation. If yes, it bails — a fresher job is already in the queue.&lt;/p&gt;

&lt;p&gt;The result: "I need room service" + "room 204" + "before 2pm" get batched into &lt;strong&gt;one pipeline run&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;No Redis streams. No WebSocket aggregation. Just a background job with a delay and a freshness check. Dead simple.&lt;/p&gt;

&lt;p&gt;The debounce window is configurable per agent. Too short and you split messages. Too long and response feels slow. We default to 3 seconds — short enough to feel instant, long enough to catch the "oh wait, one more thing" follow-up.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 2: PreFilter — Kill Noise for Free
&lt;/h2&gt;

&lt;p&gt;Before spending a single token, we filter the obvious junk with plain regex:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Empty messages&lt;/strong&gt; — webhook noise, media-only messages with no text&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emoji-only&lt;/strong&gt; — "👍", "😂😂😂", "🙏" (yes, we handle emoji ZWJ sequences)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ultra-short&lt;/strong&gt; — "ok", "ty", "k" (below a configurable minimum length)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. Three rules. Zero LLM cost. Takes less than a millisecond.&lt;/p&gt;

&lt;p&gt;In production, this filters &lt;strong&gt;~30-40% of all inbound messages&lt;/strong&gt; before the LLM even wakes up. On a busy account, that's hundreds of dollars saved per month.&lt;/p&gt;

&lt;p&gt;We thought about adding language detection, spam scoring, sentiment analysis... We didn't. Three regex rules catch enough, and every rule we add is a rule we need to maintain. The LLM router handles the harder cases anyway.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 3: The Single-LLM Router
&lt;/h2&gt;

&lt;p&gt;This is the core trick. Instead of asking each agent "is this for you?", we ask &lt;strong&gt;one cheap model&lt;/strong&gt; to classify AND route in a single call.&lt;/p&gt;

&lt;p&gt;The approach: build a tool-calling schema where the LLM must return:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;actionable&lt;/code&gt; (boolean) — is this a real request or just noise?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reason&lt;/code&gt; — why it classified this way (for audit)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agent_ids&lt;/code&gt; — which agents should handle this message&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key insight is &lt;strong&gt;constraining the tool schema dynamically&lt;/strong&gt;. The &lt;code&gt;agent_ids&lt;/code&gt; parameter uses an &lt;code&gt;enum&lt;/code&gt; that's generated from the user's actual agent IDs:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agent_ids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"uuid-concierge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid-housekeeping"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid-billing"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The LLM literally cannot hallucinate an agent ID — it can only pick from the list. And the system prompt is also dynamic, listing each agent's name and a truncated description of what it handles.&lt;/p&gt;

&lt;p&gt;One call. A tiny model (we use gpt-4.1-nano). Costs about ~$0.00015 per message. Routes to 1, 2, or all agents at once.&lt;/p&gt;

&lt;p&gt;On a real account with ~400 messages/day, &lt;strong&gt;the router costs about $2/month&lt;/strong&gt;. Without it, running 3 agents on every message would cost ~$15/month in classification alone.&lt;/p&gt;
&lt;h3&gt;
  
  
  Fail-Open: Never Lose a Message
&lt;/h3&gt;

&lt;p&gt;What happens when the LLM provider is down?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We fail open.&lt;/strong&gt; If the router call fails (after fallback — more on that in our next article), we route to ALL agents. Each agent's own LLM call will decide if the message is relevant.&lt;/p&gt;

&lt;p&gt;You might burn a few extra tokens during an outage, but you'll never silently drop a customer message. This is a deliberate trade-off: &lt;strong&gt;false positives over false negatives.&lt;/strong&gt; A customer whose message gets processed by an extra agent won't notice. A customer whose message disappears into the void will leave a 1-star review.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 4: Per-Agent Filters
&lt;/h2&gt;

&lt;p&gt;After routing, each agent gets one more chance to reject — based on config, not LLM:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trigger mode&lt;/strong&gt; — only trigger on text messages, skip images/audio/stickers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Min message length&lt;/strong&gt; — ignore very short messages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignore patterns&lt;/strong&gt; — regex patterns to skip (e.g., &lt;code&gt;/^(ok|thx|merci)$/i&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit&lt;/strong&gt; — max N messages/hour per conversation (prevents loops)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excluded numbers&lt;/strong&gt; — blocklist specific phone numbers (internal, test devices)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two design decisions worth highlighting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. User config can only make filters stricter, never weaker.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If our default minimum message length is 3 characters, a user can set it to 5 but not to 0. They can't set &lt;code&gt;trigger_on: "all"&lt;/code&gt; if the default is &lt;code&gt;"text_only"&lt;/code&gt;. Opinionated? Yes. But it prevents a class of "my agent is running on every message and I'm out of quota" support tickets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. User-defined regex runs with a hard timeout.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because users &lt;em&gt;will&lt;/em&gt; write &lt;code&gt;(.+)+$&lt;/code&gt; and wonder why their server is melting. We timeout after 100ms and skip the broken pattern. &lt;a href="https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS" rel="noopener noreferrer"&gt;ReDoS&lt;/a&gt; protection is not optional when you accept user-defined regex.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Audit Trail
&lt;/h2&gt;

&lt;p&gt;Every routing decision gets logged with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Outcome&lt;/strong&gt; — &lt;code&gt;routed&lt;/code&gt;, &lt;code&gt;skip_rules&lt;/code&gt;, &lt;code&gt;skip_router&lt;/code&gt;, &lt;code&gt;no_agent_matched&lt;/code&gt;, &lt;code&gt;error_fallback&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-agent decisions&lt;/strong&gt; — which agents were routed, which were filtered, and why&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token usage&lt;/strong&gt; — how many tokens the router consumed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a user asks "why didn't my agent process this message?", we can answer with data, not guesswork. And on our end, we can spot patterns — if &lt;code&gt;error_fallback&lt;/code&gt; spikes, something is wrong upstream.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://whatsrb.com/tricks/prefilter" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Interactive Demo →&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://whatsrb.com/tricks/prefilter" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;whatsrb.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;





&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;On a real account (hotel, ~400 messages/day, 3 agents):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Messages filtered&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PreFilter (regex)&lt;/td&gt;
&lt;td&gt;~35%&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Router (not actionable)&lt;/td&gt;
&lt;td&gt;~25%&lt;/td&gt;
&lt;td&gt;~$0.06/day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-agent filter&lt;/td&gt;
&lt;td&gt;~5%&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Actually processed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~35%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agent LLM cost&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;65% of messages never reach an agent. The first and last filters are free. The router in the middle costs pennies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The debounce window is the hardest tuning problem.&lt;/strong&gt; There's no perfect value. We expose it as a per-agent config and let users decide. Most never change the default.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fail-open is the right default, but it needs monitoring.&lt;/strong&gt; Without tracking &lt;code&gt;error_fallback&lt;/code&gt; outcomes, you'd never know your router has been down for 3 hours and every message is hitting every agent. Alerting on this is critical.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't over-engineer the PreFilter.&lt;/strong&gt; We almost built a spam classifier. We're glad we didn't. The simplest possible rules give you 80% of the value for 0% of the maintenance.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;a href="https://whatsrb.com/blog/routing-pipeline" rel="noopener noreferrer"&gt;Original Article&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  This Is What We Built
&lt;/h2&gt;

&lt;p&gt;This pipeline powers &lt;a href="https://whatsrb.com" rel="noopener noreferrer"&gt;&lt;strong&gt;WhatsRB Cloud&lt;/strong&gt;&lt;/a&gt; — a platform that turns WhatsApp messages into structured, actionable webhooks. You define agents, set routing rules, and receive clean JSON payloads.&lt;/p&gt;

&lt;p&gt;If you're building something similar on WhatsApp, we've done the hard part so you don't have to.&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://whatsrb.com" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;whatsrb.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;




&lt;p&gt;&lt;strong&gt;&lt;a href="https://whatsrb.com" rel="noopener noreferrer"&gt;Get started →&lt;/a&gt;&lt;/strong&gt; | &lt;strong&gt;&lt;a href="https://readme.whatsrb.com" rel="noopener noreferrer"&gt;API docs →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Ruby, Rails, and a deep respect for not wasting tokens on thumbs-ups.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>ai</category>
      <category>programming</category>
      <category>agents</category>
    </item>
    <item>
      <title>Building a Rails Engine #11 -- Generators: Install &amp; Target Scaffolding</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Tue, 17 Mar 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/seryllns_/building-a-rails-engine-11-generators-install-target-scaffolding-2i58</link>
      <guid>https://dev.to/seryllns_/building-a-rails-engine-11-generators-install-target-scaffolding-2i58</guid>
      <description>&lt;h2&gt;
  
  
  Generators: Install &amp;amp; Target Scaffolding
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;A great gem installs in one command. A great engine scaffolds new import types from the command line. Here is how to build Rails generators that bootstrap everything -- migration, initializer, routes, and per-target files -- so adopters never have to wire anything by hand.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;This is part 11 of the series where we build &lt;strong&gt;DataPorter&lt;/strong&gt;, a mountable Rails engine for data import workflows. In part 10, we built the ImportsController, wired up engine routes, and solved the dynamic parent controller inheritance problem.&lt;/p&gt;

&lt;p&gt;At this point the engine is feature-complete: targets, sources, the orchestrator, real-time progress, a Phlex UI, and controllers all work together. But onboarding a new host app still requires manually creating a migration, writing an initializer, mounting the engine in routes, and knowing the exact Target DSL to define an import type. That is too many steps for someone evaluating the gem for the first time. We need generators that collapse all of that into a single &lt;code&gt;rails generate&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://explaina.app/d/019920a5-864b-4234-b79f-5cfcde2c40fa" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Interactive Walkthrough →&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://explaina.app/d/019920a5-864b-4234-b79f-5cfcde2c40fa" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fexplaina.app%2Ft%2F019920a5-864b-4234-b79f-5cfcde2c40fa" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://explaina.app/d/019920a5-864b-4234-b79f-5cfcde2c40fa" rel="noopener noreferrer" class="c-link"&gt;
            DataPorter Generators Walkthrough — Explaina
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Interactive step-by-step demo showing how Rails generators eliminate manual setup — from 4 error-prone steps to a single command.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fexplaina.app%2Ffavicon-light.png"&gt;
          explaina.app
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;




&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Installing a Rails engine by hand means at least four discrete steps: copy a migration, create an initializer with sane defaults, add a route mount, and create the directory where import targets will live. Miss any one of these and the engine will not work -- but the error messages will not tell you which step you forgot. A missing migration produces an &lt;code&gt;ActiveRecord::StatementInvalid&lt;/code&gt;; a missing route mount means a &lt;code&gt;NoMethodError&lt;/code&gt; when the engine tries to resolve its URL helpers; a missing initializer silently uses defaults that may not match your app.&lt;/p&gt;

&lt;p&gt;On top of that, every time a developer wants to add a new import type, they need to remember the Target DSL: which class to inherit from, which methods to define, how to declare columns. That is cognitive overhead that belongs in a generator, not in someone's memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;With generators, it looks like this.&lt;/p&gt;

&lt;p&gt;Two generators. The first bootstraps the entire engine into a host app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;rails generate data_porter:install
      create  db/migrate/20260206120000_create_data_porter_imports.rb
      create  config/initializers/data_porter.rb
      create  app/importers
       route  mount DataPorter::Engine, at: &lt;span class="s2"&gt;"/imports"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second scaffolds a new target with parsed column definitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;rails generate data_porter:target guests first_name:string:required email:email last_name:string
      create  app/importers/guests_target.rb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command to install, one command per import type. No manual file creation, no DSL memorization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 -- The install generator
&lt;/h3&gt;

&lt;p&gt;The install generator inherits from &lt;code&gt;Rails::Generators::Base&lt;/code&gt; and mixes in &lt;code&gt;ActiveRecord::Generators::Migration&lt;/code&gt; for timestamped migration support. Each public method in the generator becomes a step that Rails executes in definition order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/generators/data_porter/install/install_generator.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Generators&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InstallGenerator&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Generators&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Generators&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Migration&lt;/span&gt;

      &lt;span class="n"&gt;source_root&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"templates"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;copy_migration&lt;/span&gt;
        &lt;span class="n"&gt;migration_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s2"&gt;"create_data_porter_imports.rb.erb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"db/migrate/create_data_porter_imports.rb"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;copy_initializer&lt;/span&gt;
        &lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"initializer.rb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"config/initializers/data_porter.rb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_importers_directory&lt;/span&gt;
        &lt;span class="n"&gt;empty_directory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"app/importers"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mount_engine&lt;/span&gt;
        &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="s1"&gt;'mount DataPorter::Engine, at: "/imports"'&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four methods, four steps. Let us walk through each one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;copy_migration&lt;/code&gt; uses &lt;code&gt;migration_template&lt;/code&gt; instead of the plain &lt;code&gt;template&lt;/code&gt; method. The difference matters: &lt;code&gt;migration_template&lt;/code&gt; adds a timestamp prefix to the filename, turning a static template into a proper Rails migration. It also raises if the migration already exists -- no accidental duplicates. The template itself is an ERB file that interpolates the current ActiveRecord migration version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateDataPorterImports&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sx"&gt;%= ActiveRecord::Migration.current_version %&amp;gt;]
  def change
    create_table :data_porter_imports do |t|
      t.string  :target_key,  null: false
      t.string  :source_type, null: false, default: "csv"
      t.integer :status,      null: false, default: 0
      t.jsonb   :records,     null: false, default: []
      t.jsonb   :report,      null: false, default: {}
      t.jsonb   :config,      null: false, default: {}

      t.references :user, polymorphic: true, null: false

      t.timestamps
    end

    add_index :data_porter_imports, :status
    add_index :data_porter_imports, :target_key
  end
end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;%= ActiveRecord::Migration.current_version %&amp;gt;&lt;/code&gt; call means the generated migration always matches the host app's Rails version -- a Rails 7.1 app gets &lt;code&gt;Migration[7.1]&lt;/code&gt;, a Rails 7.2 app gets &lt;code&gt;Migration[7.2]&lt;/code&gt;. No hardcoding, no compatibility issues.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;copy_initializer&lt;/code&gt; generates a commented-out configuration file. Every option is present but disabled, so developers can see what is available without digging through source code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/generators/data_porter/install/templates/initializer.rb&lt;/span&gt;
&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="c1"&gt;# Parent controller for the engine's controllers to inherit from.&lt;/span&gt;
  &lt;span class="c1"&gt;# Defaults to ActionController::Base. Set to "ApplicationController"&lt;/span&gt;
  &lt;span class="c1"&gt;# to inherit authentication, layouts, and helpers from your app.&lt;/span&gt;
  &lt;span class="c1"&gt;# config.parent_controller = "ApplicationController"&lt;/span&gt;

  &lt;span class="c1"&gt;# Context builder: inject business data into targets.&lt;/span&gt;
  &lt;span class="c1"&gt;# Receives the DataImport record.&lt;/span&gt;
  &lt;span class="c1"&gt;# config.context_builder = -&amp;gt;(data_import) {&lt;/span&gt;
  &lt;span class="c1"&gt;#   { user: data_import.user }&lt;/span&gt;
  &lt;span class="c1"&gt;# }&lt;/span&gt;

  &lt;span class="c1"&gt;# Enabled source types.&lt;/span&gt;
  &lt;span class="c1"&gt;# config.enabled_sources = %i[csv json xlsx api]&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full initializer includes additional options for queue name, storage service, cable prefix, and preview limits -- each with the same self-documenting pattern. The key design choice: every option documents itself with a comment that explains &lt;em&gt;what it controls&lt;/em&gt;, not just what it is. When someone opens this file for the first time, they should understand the engine's configuration surface without reading the README.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;create_importers_directory&lt;/code&gt; calls &lt;code&gt;empty_directory&lt;/code&gt;, which creates &lt;code&gt;app/importers&lt;/code&gt; and silently skips if it already exists. This is where host apps will place their target files.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mount_engine&lt;/code&gt; uses the built-in &lt;code&gt;route&lt;/code&gt; helper, which injects the mount line into &lt;code&gt;config/routes.rb&lt;/code&gt;. The default mount point is &lt;code&gt;/imports&lt;/code&gt;, but because it is a standard route mount, developers can change the path or wrap it in constraints after generation.&lt;/p&gt;

&lt;p&gt;The engine is installed. Now let us make it just as easy to add new import types.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 -- The target generator
&lt;/h3&gt;

&lt;p&gt;The target generator inherits from &lt;code&gt;Rails::Generators::NamedBase&lt;/code&gt; instead of &lt;code&gt;Base&lt;/code&gt;. This gives us the &lt;code&gt;name&lt;/code&gt; argument for free -- Rails automatically parses the first argument as the target name and provides helper methods like &lt;code&gt;class_name&lt;/code&gt;, &lt;code&gt;file_name&lt;/code&gt;, and &lt;code&gt;singular_name&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/generators/data_porter/target/target_generator.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Generators&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TargetGenerator&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Generators&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;NamedBase&lt;/span&gt;
      &lt;span class="n"&gt;source_root&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"templates"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="n"&gt;argument&lt;/span&gt; &lt;span class="ss"&gt;:columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="ss"&gt;banner: &lt;/span&gt;&lt;span class="s2"&gt;"name:type[:required]"&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_target_file&lt;/span&gt;
        &lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"target.rb.tt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"app/importers/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;file_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_target.rb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="kp"&gt;private&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;target_class_name&lt;/span&gt;
        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;class_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Target"&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;model_name&lt;/span&gt;
        &lt;span class="n"&gt;class_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;singularize&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;target_label&lt;/span&gt;
        &lt;span class="n"&gt;class_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;titleize&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parsed_columns&lt;/span&gt;
        &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;parse_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;":"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="ss"&gt;type: &lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"required"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;columns&lt;/code&gt; argument uses &lt;code&gt;type: :array&lt;/code&gt; with a &lt;code&gt;default: []&lt;/code&gt;, which means you can generate a bare target with no columns and fill them in later, or you can specify everything up front. The &lt;code&gt;banner&lt;/code&gt; string &lt;code&gt;"name:type[:required]"&lt;/code&gt; tells &lt;code&gt;rails generate data_porter:target --help&lt;/code&gt; exactly what format columns expect.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;parse_column&lt;/code&gt; method splits each column definition on colons. &lt;code&gt;first_name:string:required&lt;/code&gt; becomes &lt;code&gt;{ name: "first_name", type: "string", required: true }&lt;/code&gt;. &lt;code&gt;email:email&lt;/code&gt; becomes &lt;code&gt;{ name: "email", type: "email", required: false }&lt;/code&gt;. If you omit the type entirely, it defaults to &lt;code&gt;"string"&lt;/code&gt;. This mirrors the familiar &lt;code&gt;rails generate model&lt;/code&gt; syntax but adapts it to our column DSL.&lt;/p&gt;

&lt;p&gt;The naming helpers derive everything from the single &lt;code&gt;name&lt;/code&gt; argument. Pass &lt;code&gt;guests&lt;/code&gt; and you get: &lt;code&gt;target_class_name&lt;/code&gt; returns &lt;code&gt;"GuestsTarget"&lt;/code&gt;, &lt;code&gt;model_name&lt;/code&gt; returns &lt;code&gt;"Guest"&lt;/code&gt; (singularized, because the target usually maps to one ActiveRecord model), and &lt;code&gt;target_label&lt;/code&gt; returns &lt;code&gt;"Guests"&lt;/code&gt; (titleized, for the UI).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 -- The target template
&lt;/h3&gt;

&lt;p&gt;The template uses Thor's &lt;code&gt;.tt&lt;/code&gt; format (which processes ERB tags using the generator's binding) to produce a complete, working target file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;# lib/generators/data_porter/target/templates/target.rb.tt
class &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;target_class_name&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nt"&gt;DataPorter::Target&lt;/span&gt;
  &lt;span class="na"&gt;label&lt;/span&gt; &lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;target_label&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;model_name&lt;/span&gt; &lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;icon&lt;/span&gt; &lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="na"&gt;fas&lt;/span&gt; &lt;span class="na"&gt;fa-file-import&lt;/span&gt;&lt;span class="err"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;sources&lt;/span&gt; &lt;span class="na"&gt;:csv&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parsed_columns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

  &lt;span class="na"&gt;columns&lt;/span&gt; &lt;span class="na"&gt;do&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;parsed_columns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;-%&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;column&lt;/span&gt; &lt;span class="na"&gt;:&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type:&lt;/span&gt; &lt;span class="na"&gt;:&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:type&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&amp;lt;%=&lt;/span&gt; &lt;span class="s2"&gt;", required: true"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:required&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;-%&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;end&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

  &lt;span class="na"&gt;def&lt;/span&gt; &lt;span class="na"&gt;persist&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;context:&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;
    &lt;span class="na"&gt;#&lt;/span&gt; &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="err"&gt;!(&lt;/span&gt;&lt;span class="na"&gt;record.attributes&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;
  &lt;span class="na"&gt;end&lt;/span&gt;
&lt;span class="na"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;rails generate data_porter:target guests first_name:string:required email:email last_name:string&lt;/code&gt; produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/importers/guests_target.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GuestsTarget&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Target&lt;/span&gt;
  &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s2"&gt;"Guests"&lt;/span&gt;
  &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="s2"&gt;"Guest"&lt;/span&gt;
  &lt;span class="n"&gt;icon&lt;/span&gt; &lt;span class="s2"&gt;"fas fa-file-import"&lt;/span&gt;
  &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="ss"&gt;:csv&lt;/span&gt;

  &lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :email&lt;/span&gt;
    &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="c1"&gt;# Guest.create!(record.attributes)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth noting. First, the &lt;code&gt;persist&lt;/code&gt; method is generated with a commented-out implementation line. This is intentional: we want the developer to think about what persistence means for their domain rather than blindly accepting a default. Maybe they need &lt;code&gt;find_or_create_by&lt;/code&gt;, maybe they need to call a service object, maybe they need to update existing records. The commented hint shows the simplest path while making it clear this is the one method they &lt;em&gt;must&lt;/em&gt; customize.&lt;/p&gt;

&lt;p&gt;Second, the &lt;code&gt;columns&lt;/code&gt; block is conditionally rendered. If you run &lt;code&gt;rails generate data_porter:target guests&lt;/code&gt; with no columns, you get a clean skeleton without an empty &lt;code&gt;columns do; end&lt;/code&gt; block. You can add columns later as you figure out your CSV structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions &amp;amp; tradeoffs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;We chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generator base class&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NamedBase&lt;/code&gt; for target, &lt;code&gt;Base&lt;/code&gt; for install&lt;/td&gt;
&lt;td&gt;Both using &lt;code&gt;Base&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NamedBase&lt;/code&gt; gives us &lt;code&gt;class_name&lt;/code&gt;, &lt;code&gt;file_name&lt;/code&gt;, and argument parsing for free; install does not need a name argument&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration template format&lt;/td&gt;
&lt;td&gt;ERB (&lt;code&gt;.rb.erb&lt;/code&gt;) with &lt;code&gt;ActiveRecord::Migration.current_version&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Hardcoded migration version&lt;/td&gt;
&lt;td&gt;The generated migration automatically matches the host app's Rails version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Column parsing syntax&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;name:type[:required]&lt;/code&gt; colon-separated&lt;/td&gt;
&lt;td&gt;A flag-based approach (&lt;code&gt;--columns name:string --required name&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Matches the familiar &lt;code&gt;rails generate model&lt;/code&gt; convention; less typing, easier to remember&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Initializer style&lt;/td&gt;
&lt;td&gt;All options commented out with explanations&lt;/td&gt;
&lt;td&gt;Only showing uncommented defaults&lt;/td&gt;
&lt;td&gt;Developers discover every configuration option on first read; uncommenting is easier than looking up what is available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target output directory&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app/importers/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;app/data_porter/targets/&lt;/code&gt; or &lt;code&gt;app/models/concerns/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Short, clear, conventional; mirrors how apps organize service objects in &lt;code&gt;app/services/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persist method&lt;/td&gt;
&lt;td&gt;Commented-out hint (&lt;code&gt;# Guest.create!(...)&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Working default implementation&lt;/td&gt;
&lt;td&gt;Forces the developer to make an intentional choice about their persistence strategy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;Generator testing is unusual -- you are testing file creation, not object behavior. The install generator specs are structural (right inheritance, right source root, right methods), so let us focus on the more interesting target generator specs that test column parsing and naming derivation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/data_porter/generators/target_generator_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Generators&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TargetGenerator&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"column parsing"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:generator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"guests"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"first_name:string:required"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"email:email"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"parses column definitions"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:parsed_columns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"first_name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"naming"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:generator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"guests"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"derives the class name"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:target_class_name&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"GuestsTarget"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"derives the model name"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:model_name&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Guest"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"derives the label"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:target_label&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Guests"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The column parsing spec confirms that &lt;code&gt;first_name:string:required&lt;/code&gt; correctly splits into a hash with &lt;code&gt;required: true&lt;/code&gt;, while &lt;code&gt;email:email&lt;/code&gt; (no third segment) defaults to &lt;code&gt;required: false&lt;/code&gt;. The naming specs verify that &lt;code&gt;guests&lt;/code&gt; as input produces &lt;code&gt;GuestsTarget&lt;/code&gt; for the class, &lt;code&gt;Guest&lt;/code&gt; for the model, and &lt;code&gt;Guests&lt;/code&gt; for the label -- the singularization and titleization that the template depends on.&lt;/p&gt;

&lt;p&gt;Notice that the specs instantiate the generator directly with &lt;code&gt;described_class.new(["guests", ...])&lt;/code&gt; rather than invoking it through the Rails generator runner. This keeps the tests fast and focused: we are testing the logic, not the file I/O.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;install generator&lt;/strong&gt; bootstraps the entire engine in one command: migration, initializer, importers directory, and route mount -- four steps that previously required reading the README and manually creating files.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;target generator&lt;/strong&gt; scaffolds a new import type with parsed column definitions, producing a ready-to-customize target file that follows the DSL conventions established in earlier parts of the series.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;migration template&lt;/strong&gt; uses ERB with &lt;code&gt;ActiveRecord::Migration.current_version&lt;/code&gt; to match the host app's Rails version automatically, avoiding hardcoded version numbers.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;column parsing syntax&lt;/strong&gt; (&lt;code&gt;name:type[:required]&lt;/code&gt;) mirrors &lt;code&gt;rails generate model&lt;/code&gt; conventions, keeping the learning curve flat for Rails developers.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;initializer template&lt;/strong&gt; documents every configuration option with comments, making the engine's surface area discoverable without external documentation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;The engine can now parse CSV files, but real-world data does not always arrive in a spreadsheet. In part 12, we extend the Source layer with &lt;strong&gt;JSON and API sources&lt;/strong&gt; -- a JSON source that accepts files or raw text, and an API source that fetches data from HTTP endpoints with injectable parameters and authentication headers. The source abstraction we designed in part 6 is about to prove its worth.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part 11 of the series "Building DataPorter - A Data Import Engine for Rails". Previous: Controllers &amp;amp; Routing in a Rails Engine | Next: Adding JSON &amp;amp; API Sources&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Building a Rails Engine #10 -- Controllers &amp; Routing in a Rails Engine</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Wed, 11 Mar 2026 17:36:48 +0000</pubDate>
      <link>https://dev.to/seryllns_/building-a-rails-engine-10-controllers-routing-in-a-rails-engine-492j</link>
      <guid>https://dev.to/seryllns_/building-a-rails-engine-10-controllers-routing-in-a-rails-engine-492j</guid>
      <description>&lt;h1&gt;
  
  
  Controllers &amp;amp; Routing in a Rails Engine
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Engine controllers are tricky -- they need to inherit from the host app, define routes that do not collide with anything else, and stay thin while coordinating an entire import workflow. Here is the clean way.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;This is part 10 of the series where we build &lt;strong&gt;DataPorter&lt;/strong&gt;, a mountable Rails engine for data import workflows. In &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-9-building-the-ui-with-phlex-tailwind-1mn6"&gt;part 9&lt;/a&gt;, we built the UI layer with Phlex components and scoped Tailwind styles, giving users a visual interface for previewing and managing imports.&lt;/p&gt;

&lt;p&gt;But a UI needs a backend to drive it. We need controller actions that create imports, kick off background jobs, let users confirm or cancel, and route everything under a clean, isolated namespace. In this article, we build the ImportsController, wire up engine routes with member actions, and solve one of the trickiest problems in engine development: making the controller inherit from whichever parent class the host app wants.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parent controller pattern
&lt;/h2&gt;

&lt;p&gt;When you build a Rails engine, your controllers need to inherit from &lt;em&gt;something&lt;/em&gt;. The obvious choice is &lt;code&gt;ActionController::Base&lt;/code&gt; -- it works, no dependencies. But then the host app's authentication, layouts, and error handling do not flow through. The user would need to separately configure auth for every engine route.&lt;/p&gt;

&lt;p&gt;The other extreme is inheriting from &lt;code&gt;ApplicationController&lt;/code&gt;. That gives you auth for free, but crashes when the host app uses authorization gems (Pundit, CanCanCan) that enforce &lt;code&gt;after_action&lt;/code&gt; verification callbacks on &lt;code&gt;ApplicationController&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The solution is dynamic inheritance. The engine defaults to &lt;code&gt;ActionController::Base&lt;/code&gt; -- safe, no implicit dependencies -- but the host app can swap it to any controller in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/configuration.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Configuration&lt;/span&gt;
  &lt;span class="nb"&gt;attr_accessor&lt;/span&gt; &lt;span class="ss"&gt;:parent_controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="ss"&gt;:queue_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="ss"&gt;:storage_service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="ss"&gt;:cable_channel_prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="ss"&gt;:context_builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="ss"&gt;:preview_limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="ss"&gt;:enabled_sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="ss"&gt;:scope&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;
    &lt;span class="vi"&gt;@parent_controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ActionController::Base"&lt;/span&gt;
    &lt;span class="vi"&gt;@queue_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:default&lt;/span&gt;
    &lt;span class="vi"&gt;@storage_service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:local&lt;/span&gt;
    &lt;span class="vi"&gt;@cable_channel_prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"data_porter"&lt;/span&gt;
    &lt;span class="vi"&gt;@context_builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;
    &lt;span class="vi"&gt;@preview_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
    &lt;span class="vi"&gt;@enabled_sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sx"&gt;%i[csv json api]&lt;/span&gt;
    &lt;span class="vi"&gt;@scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller then uses &lt;code&gt;constantize&lt;/code&gt; at class definition time to resolve the string into a class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/data_porter/imports_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImportsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parent_controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constantize&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single line is doing a lot. When Ruby loads the controller file, it evaluates the superclass expression. &lt;code&gt;DataPorter.configuration.parent_controller&lt;/code&gt; returns a string like &lt;code&gt;"ActionController::Base"&lt;/code&gt; or &lt;code&gt;"Admin::BaseController"&lt;/code&gt;, and &lt;code&gt;.constantize&lt;/code&gt; turns it into the actual class. The result is that &lt;code&gt;ImportsController&lt;/code&gt; inherits from whatever the host app configured -- picking up its before_actions, authentication, layouts, and helper methods automatically.&lt;/p&gt;

&lt;p&gt;Out of the box, the engine works without authentication (inheriting from &lt;code&gt;ActionController::Base&lt;/code&gt;). A host app that wants authentication to flow through just sets the parent controller in the initializer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/initializers/data_porter.rb&lt;/span&gt;
&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parent_controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ApplicationController"&lt;/span&gt;
  &lt;span class="c1"&gt;# or a dedicated admin controller:&lt;/span&gt;
  &lt;span class="c1"&gt;# config.parent_controller = "Admin::BaseController"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No monkey-patching, no middleware, no route constraints. The engine's controller &lt;em&gt;is&lt;/em&gt; an admin controller.&lt;/p&gt;

&lt;p&gt;The tradeoff is that this happens once, at load time. If you change the configuration after the controller class has been loaded, the inheritance does not update. In practice this is not an issue -- initializers run before controllers are loaded in production, and in development Rails reloads everything per-request anyway. But it is worth knowing: the parent controller is resolved eagerly, not lazily.&lt;/p&gt;

&lt;h2&gt;
  
  
  Engine routes
&lt;/h2&gt;

&lt;p&gt;DataPorter draws its routes inside the engine's own router, completely isolated from the host app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/routes.rb&lt;/span&gt;
&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;resources&lt;/span&gt; &lt;span class="ss"&gt;:imports&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;only: &lt;/span&gt;&lt;span class="sx"&gt;%i[index new create show]&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ss"&gt;:parse&lt;/span&gt;
      &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ss"&gt;:confirm&lt;/span&gt;
      &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ss"&gt;:cancel&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host app mounts the engine at whatever path it wants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Host app: config/routes.rb&lt;/span&gt;
&lt;span class="n"&gt;mount&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;at: &lt;/span&gt;&lt;span class="s2"&gt;"/data_porter"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces the following route table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;HTTP method&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/data_porter/imports&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;index&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;List all imports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/data_porter/imports/new&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;New import form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/data_porter/imports&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;create&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create and start parsing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/data_porter/imports/:id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;show&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Preview/status page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/data_porter/imports/:id/parse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;parse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Re-parse an import&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/data_porter/imports/:id/confirm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;confirm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Confirm and start importing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/data_porter/imports/:id/cancel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cancel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cancel an import&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice what is &lt;em&gt;not&lt;/em&gt; here: &lt;code&gt;edit&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;destroy&lt;/code&gt;. An import is not a CRUD resource in the traditional sense. You create it, you preview it, you confirm or cancel it. There is no "edit an import" workflow -- if the data is wrong, you cancel and create a new one. The routes reflect this by using &lt;code&gt;only:&lt;/code&gt; to restrict the standard REST actions and adding three custom member routes for the state-transition actions.&lt;/p&gt;

&lt;p&gt;The member routes are all &lt;code&gt;post&lt;/code&gt;, not &lt;code&gt;patch&lt;/code&gt; or &lt;code&gt;put&lt;/code&gt;. These are command actions that trigger side effects (enqueue a job, change status), not updates to a resource's attributes. POST is semantically correct here and plays well with Turbo, which sends POST requests from &lt;code&gt;button_to&lt;/code&gt; helpers by default.&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;isolate_namespace&lt;/code&gt; is set on the engine, all routes are automatically scoped. Inside the controller, you use &lt;code&gt;import_path(@import)&lt;/code&gt; and &lt;code&gt;imports_path&lt;/code&gt; -- no prefix needed. The engine's router handles the namespace. This also means the host app's own &lt;code&gt;imports_path&lt;/code&gt; (if it has one) is completely separate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Each action explained
&lt;/h2&gt;

&lt;p&gt;Here is the full controller, then we will walk through each action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/data_porter/imports_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImportsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parent_controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constantize&lt;/span&gt;
    &lt;span class="n"&gt;before_action&lt;/span&gt; &lt;span class="ss"&gt;:set_import&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;only: &lt;/span&gt;&lt;span class="sx"&gt;%i[show parse confirm cancel]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;index&lt;/span&gt;
      &lt;span class="vi"&gt;@imports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DataImport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;created_at: :desc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;
      &lt;span class="vi"&gt;@import&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DataImport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
      &lt;span class="vi"&gt;@targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;available&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
      &lt;span class="vi"&gt;@import&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DataImport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;respond_to?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:current_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:pending&lt;/span&gt;

      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;
        &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ParseJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;import_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt;
        &lt;span class="vi"&gt;@targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;available&lt;/span&gt;
        &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;:new&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;
      &lt;span class="vi"&gt;@target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;target_class&lt;/span&gt;
      &lt;span class="vi"&gt;@records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;records&lt;/span&gt;
      &lt;span class="vi"&gt;@grouped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;
      &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ParseJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;import_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;confirm&lt;/span&gt;
      &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ImportJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;import_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cancel&lt;/span&gt;
      &lt;span class="vi"&gt;@import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :failed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;imports_path&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="kp"&gt;private&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_import&lt;/span&gt;
      &lt;span class="vi"&gt;@import&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DataImport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;import_params&lt;/span&gt;
      &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data_import&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:target_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:source_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;config: &lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;index&lt;/code&gt;&lt;/strong&gt; -- Lists all imports, newest first. No pagination yet (that is a future enhancement), but the query is simple enough that the view layer can handle it. This is the dashboard view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;new&lt;/code&gt;&lt;/strong&gt; -- Builds a blank DataImport and loads the available targets from the Registry. The view uses these to render a dropdown of import types. This is where the Target DSL pays off: each registered target provides its own label and icon, and the controller just passes the list through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;create&lt;/code&gt;&lt;/strong&gt; -- The most interesting action. It builds the import from strong params, optionally assigns the current user (using &lt;code&gt;respond_to?(:current_user, true)&lt;/code&gt; to avoid crashing if the host app has no authentication), sets the initial status, and saves. On success, it immediately enqueues a &lt;code&gt;ParseJob&lt;/code&gt; and redirects to the show page where the user will see real-time progress. On failure, it reloads the targets and re-renders the form.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;respond_to?(:current_user, true)&lt;/code&gt; check deserves attention. The second argument (&lt;code&gt;true&lt;/code&gt;) includes private methods in the check. Some authentication gems define &lt;code&gt;current_user&lt;/code&gt; as a private helper. By passing &lt;code&gt;true&lt;/code&gt;, we catch both public and private definitions. If the host app has no authentication at all, the check returns false and the user association is simply not set. The import still works -- &lt;code&gt;user&lt;/code&gt; is a polymorphic optional association.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;show&lt;/code&gt;&lt;/strong&gt; -- Loads the target class, the records, and groups them by status. The view uses this grouping to show separate sections for complete, partial, and missing records. This is the preview page when the import is in &lt;code&gt;previewing&lt;/code&gt; status, and the results page when it is &lt;code&gt;completed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;parse&lt;/code&gt;&lt;/strong&gt; -- Re-parses an existing import. This resets the status to &lt;code&gt;pending&lt;/code&gt; and enqueues a new ParseJob. Useful when the user realizes the CSV mapping was wrong and re-uploads a file, or when a previous parse failed and they want to retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;confirm&lt;/code&gt;&lt;/strong&gt; -- The user has reviewed the preview and decided to proceed. This resets the status to &lt;code&gt;pending&lt;/code&gt;, enqueues an ImportJob, and redirects back to the show page, where ActionCable will push real-time progress updates (as we built in part 8). The status reset follows the same pattern as &lt;code&gt;parse&lt;/code&gt; -- setting &lt;code&gt;pending&lt;/code&gt; before enqueuing ensures the UI shows the right state immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cancel&lt;/code&gt;&lt;/strong&gt; -- Marks the import as failed and redirects to the index. No job is enqueued, no further processing happens. The import is preserved in the database for auditing but is effectively dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strong params
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;import_params&lt;/code&gt; method is deliberately minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;import_params&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data_import&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:target_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:source_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;config: &lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four permitted fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;target_key&lt;/code&gt;&lt;/strong&gt; -- A string like &lt;code&gt;"user_import"&lt;/code&gt; that maps to a registered target. The Target DSL and Registry validate this downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source_type&lt;/code&gt;&lt;/strong&gt; -- A string like &lt;code&gt;"csv"&lt;/code&gt;, &lt;code&gt;"json"&lt;/code&gt;, or &lt;code&gt;"api"&lt;/code&gt;. Determines which Source class handles the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;file&lt;/code&gt;&lt;/strong&gt; -- An ActiveStorage attachment. The uploaded CSV, JSON, or other file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;config&lt;/code&gt;&lt;/strong&gt; -- A hash for source-specific configuration (API endpoints, headers, JSON root paths). The &lt;code&gt;config: {}&lt;/code&gt; syntax permits any keys inside the hash, which is intentional: different source types need different config keys, and we validate them at the Source level rather than the controller level. This is safe because &lt;code&gt;config&lt;/code&gt; is a JSONB column -- the values are data, not model attributes. There is no mass assignment risk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notably absent: &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;records&lt;/code&gt;, &lt;code&gt;report&lt;/code&gt;, &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;user_type&lt;/code&gt;. These are all set internally -- status by the controller and orchestrator, records and report by the ParseJob, and user by the controller's &lt;code&gt;current_user&lt;/code&gt; logic. Strong params prevents any of these from being injected through the form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions &amp;amp; tradeoffs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;We chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Parent controller&lt;/td&gt;
&lt;td&gt;Dynamic inheritance via &lt;code&gt;constantize&lt;/code&gt;, defaulting to &lt;code&gt;ActionController::Base&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Hardcoded &lt;code&gt;ApplicationController&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Works out of the box without auth dependencies; host app opts in explicitly; avoids crashes from authorization gem callbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route design&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;only:&lt;/code&gt; with custom member actions&lt;/td&gt;
&lt;td&gt;Full &lt;code&gt;resources&lt;/code&gt; CRUD&lt;/td&gt;
&lt;td&gt;Imports are not a traditional CRUD resource; explicit routes match the actual workflow (create, preview, confirm/cancel)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Member action verbs&lt;/td&gt;
&lt;td&gt;All POST&lt;/td&gt;
&lt;td&gt;PATCH for parse, DELETE for cancel&lt;/td&gt;
&lt;td&gt;These are command actions with side effects, not attribute updates; POST is semantically correct and works naturally with Turbo's &lt;code&gt;button_to&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User assignment&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;respond_to?(:current_user, true)&lt;/code&gt; guard&lt;/td&gt;
&lt;td&gt;Requiring authentication&lt;/td&gt;
&lt;td&gt;The engine must work with and without authentication; optional user association keeps it flexible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config params&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;config: {}&lt;/code&gt; (open hash)&lt;/td&gt;
&lt;td&gt;Explicitly listing every config key&lt;/td&gt;
&lt;td&gt;Different source types need different config keys; validation happens at the Source level where context exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No edit/update/destroy&lt;/td&gt;
&lt;td&gt;Omitted from routes entirely&lt;/td&gt;
&lt;td&gt;Including them "just in case"&lt;/td&gt;
&lt;td&gt;An import is an immutable workflow; if it is wrong, you cancel and start over; fewer routes means fewer security surfaces&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;Testing engine controllers without a full Rails app or a dummy app is the interesting challenge here. The approach we use: stub just enough of the Rails stack to load the controller class, then test its structure directly.&lt;/p&gt;

&lt;p&gt;The spec helper handles the setup. It loads the minimal Rails dependencies, sets up an in-memory SQLite database, stubs &lt;code&gt;ApplicationController&lt;/code&gt;, and manually requires the controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/spec_helper.rb (relevant excerpt)&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"rails"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"action_controller"&lt;/span&gt;

&lt;span class="c1"&gt;# Stub for controller inheritance in test context&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApplicationController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActionController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="k"&gt;defined?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ApplicationController&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="vg"&gt;$LOAD_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unshift&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"../app/controllers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"data_porter/imports_controller"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;unless defined?(ApplicationController)&lt;/code&gt; guard is important -- it prevents double-definition errors if the host app's ApplicationController has already been loaded (which happens in integration test suites).&lt;/p&gt;

&lt;p&gt;With that foundation, the controller spec tests structure rather than HTTP behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/data_porter/imports_controller_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ImportsController&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:target_class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;Class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s2"&gt;"Test Import"&lt;/span&gt;
      &lt;span class="n"&gt;icon&lt;/span&gt; &lt;span class="s2"&gt;"fas fa-test"&lt;/span&gt;
      &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="s2"&gt;"TestModel"&lt;/span&gt;

      &lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
        &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;
    &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:test_import&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"inheritance"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"inherits from the configured parent controller"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;superclass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parent_controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"before_actions"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"registers set_import callback"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_process_action_callbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="ss"&gt;:set_import&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;not_to&lt;/span&gt; &lt;span class="n"&gt;be_empty&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"action methods"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"defines index, new, create, show, parse, confirm, and cancel"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sx"&gt;%i[index new create show parse confirm cancel]&lt;/span&gt;
      &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be_a&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UnboundMethod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"private methods"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"defines set_import and import_params"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;private_instance_methods&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:set_import&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:import_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests verify &lt;em&gt;structure&lt;/em&gt; rather than HTTP behavior: inheritance matches the config, callbacks are registered, action methods exist. Callback registration is tested via &lt;code&gt;_process_action_callbacks&lt;/code&gt; -- a Rails internal API, but stable and far simpler than making real HTTP requests just to check that a before_action is wired up.&lt;/p&gt;

&lt;p&gt;The routes get their own spec that redraws the engine routes and inspects the route set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/data_porter/routes_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"DataPorter routes"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:all&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;resources&lt;/span&gt; &lt;span class="ss"&gt;:imports&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;only: &lt;/span&gt;&lt;span class="sx"&gt;%i[index new create show]&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
        &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ss"&gt;:parse&lt;/span&gt;
          &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ss"&gt;:confirm&lt;/span&gt;
          &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ss"&gt;:cancel&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"defines import resource routes"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;routes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;
    &lt;span class="n"&gt;route_set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:action&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route_set&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/imports(.:format)"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route_set&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"new"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/imports/new(.:format)"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route_set&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/imports(.:format)"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route_set&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"show"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/imports/:id(.:format)"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"defines member routes for parse, confirm, and cancel"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;routes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;
    &lt;span class="n"&gt;route_set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:action&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route_set&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"parse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/imports/:id/parse(.:format)"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route_set&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"confirm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/imports/:id/confirm(.:format)"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route_set&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"cancel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/imports/:id/cancel(.:format)"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;before(:all)&lt;/code&gt; block redraws the routes inside the test environment. This is necessary because the engine's routes file is loaded differently in test versus production. By explicitly drawing the routes, we guarantee the test is exercising the exact route definitions we ship, not whatever routes happened to be loaded by a host app. The tradeoff: if you change the routes file, you must update the spec too -- but that is true of any test that asserts on structure. The specs then map each route to an &lt;code&gt;[action, path]&lt;/code&gt; pair and verify all seven are present with the correct paths.&lt;/p&gt;

&lt;p&gt;This style of testing -- structural rather than behavioral -- may feel unusual. But for an engine, it is the right level of abstraction. Full request specs belong in the host app's integration suite, where they can exercise the real authentication stack, the real database, and the real views. The engine's specs verify that the pieces are correctly defined and wired together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;dynamic parent controller pattern&lt;/strong&gt; uses &lt;code&gt;constantize&lt;/code&gt; to resolve the configured controller name at class-load time, allowing the engine to inherit authentication, layouts, and helpers from whatever base class the host app chooses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engine routes&lt;/strong&gt; are drawn inside &lt;code&gt;DataPorter::Engine.routes.draw&lt;/code&gt;, keeping them isolated from the host app. The host mounts the engine at any path it wants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seven actions&lt;/strong&gt; map to a clean import workflow: list, create, preview, re-parse, confirm, cancel. No edit or destroy -- imports are immutable workflows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strong params&lt;/strong&gt; permit only the fields the user should control (&lt;code&gt;target_key&lt;/code&gt;, &lt;code&gt;source_type&lt;/code&gt;, &lt;code&gt;file&lt;/code&gt;, &lt;code&gt;config&lt;/code&gt;), while status, records, and user are set internally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controller specs&lt;/strong&gt; test structure (inheritance, callbacks, method existence) rather than HTTP behavior, avoiding the need for a full dummy app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;The controller and routes give us a working web interface, but setting up DataPorter in a host app still requires creating a migration, writing an initializer, mounting routes, and knowing the right directory for target classes. In part 11, we build &lt;strong&gt;install and target generators&lt;/strong&gt; -- &lt;code&gt;rails generate data_porter:install&lt;/code&gt; that handles the entire setup in one command, and &lt;code&gt;rails generate data_porter:target&lt;/code&gt; that scaffolds a new import type with columns pre-filled from the command line.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part 10 of the series "Building DataPorter - A Data Import Engine for Rails". &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-9-building-the-ui-with-phlex-tailwind-1mn6"&gt;Previous: Building the UI with Phlex &amp;amp; Tailwind&lt;/a&gt; | Next: Generators: Install &amp;amp; Target Scaffolding&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a Rails Engine #9 -- Building the UI with Phlex &amp; Tailwind</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Tue, 10 Mar 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/seryllns_/building-a-rails-engine-9-building-the-ui-with-phlex-tailwind-1mn6</link>
      <guid>https://dev.to/seryllns_/building-a-rails-engine-9-building-the-ui-with-phlex-tailwind-1mn6</guid>
      <description>&lt;h1&gt;
  
  
  Building the UI with Phlex &amp;amp; Tailwind
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;How to build a full component library for a Rails engine using Phlex, scope all CSS with a &lt;code&gt;dp-&lt;/code&gt; prefix so styles never leak into the host app, and generate preview tables dynamically from the Target DSL.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;This is part 9 of the series where we build &lt;strong&gt;DataPorter&lt;/strong&gt;, a mountable Rails engine for data import workflows. In &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-8-real-time-progress-with-actioncable-stimulus-2cji"&gt;part 8&lt;/a&gt;, we wired up ActionCable and Stimulus so users get real-time progress updates as imports run in the background.&lt;/p&gt;

&lt;p&gt;We now have a working engine: targets define imports, sources parse files, the Orchestrator coordinates the workflow, and ActionCable pushes updates to the browser. But the browser has nothing to render. Every status, every preview row, every progress bar needs a component. In this article, we build the entire view layer -- six Phlex components (plus a shared Base class) that turn DataPorter's internal data structures into HTML the user can actually see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Phlex (not ERB or ViewComponent)
&lt;/h2&gt;

&lt;p&gt;A Rails engine's view layer needs to ship inside the gem. ERB templates in engines are fragile -- they depend on the host app's layout, helpers, and asset pipeline. ViewComponent still relies on template resolution. Phlex takes a different approach: the template &lt;em&gt;is&lt;/em&gt; the Ruby class. A component is a plain object with a &lt;code&gt;view_template&lt;/code&gt; method that uses a Ruby DSL to emit HTML. No template files, no implicit dependencies. Every component lives in &lt;code&gt;lib/&lt;/code&gt;, ships with the gem, and renders anywhere -- in a controller, in a test, in a Turbo Stream. For a gem, this is ideal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 -- The Base component
&lt;/h3&gt;

&lt;p&gt;Every DataPorter component inherits from a shared base class that extends &lt;code&gt;Phlex::HTML&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/components/base.rb&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"phlex"&lt;/span&gt;

&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Components&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Base&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Phlex&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HTML&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is intentionally minimal. The base class exists as a single point of inheritance so we can add shared behavior later -- a common CSS helper, a default wrapper, an HTML safety policy -- without touching every component. Right now it does nothing but establish the hierarchy. That is fine. The value is in the seam, not the code.&lt;/p&gt;

&lt;p&gt;The module barrel file requires all components in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/components.rb&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"components/base"&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"components/status_badge"&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"components/summary_cards"&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"components/preview_table"&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"components/progress_bar"&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"components/results_summary"&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"components/failure_alert"&lt;/span&gt;

&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Components&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2 -- StatusBadge: the simplest component
&lt;/h3&gt;

&lt;p&gt;The StatusBadge renders a single &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; with the import's current status. It demonstrates the pattern every component follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/components/status_badge.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Components&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StatusBadge&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="vi"&gt;@status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;view_template&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-badge dp-badge--&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="vi"&gt;@status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="vi"&gt;@status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capitalize&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constructor takes a keyword argument and calls &lt;code&gt;super()&lt;/code&gt; -- Phlex requires this. The &lt;code&gt;view_template&lt;/code&gt; method emits a span with two CSS classes: a base class &lt;code&gt;dp-badge&lt;/code&gt; for shared badge styles, and a modifier class &lt;code&gt;dp-badge--#{@status}&lt;/code&gt; for status-specific colors. The naming follows BEM conventions, but with the &lt;code&gt;dp-&lt;/code&gt; prefix to scope everything to DataPorter.&lt;/p&gt;

&lt;p&gt;Rendering it is a method call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;StatusBadge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; '&amp;lt;span class="dp-badge dp-badge--completed"&amp;gt;Completed&amp;lt;/span&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No partial lookup, no view context, no render helpers. Just instantiate and call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 -- SummaryCards: parsing report data into a dashboard
&lt;/h3&gt;

&lt;p&gt;After the Orchestrator's &lt;code&gt;parse!&lt;/code&gt; phase completes, the import has a Report object with counts for each record status. The SummaryCards component turns that into a row of four cards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/components/summary_cards.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Components&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SummaryCards&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="vi"&gt;@report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;view_template&lt;/span&gt;
        &lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-summary-cards"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-card--complete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="vi"&gt;@report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Ready"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-card--partial"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="vi"&gt;@report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partial_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Incomplete"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-card--missing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="vi"&gt;@report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;missing_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Missing"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-card--duplicate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="vi"&gt;@report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;duplicate_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Duplicates"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="kp"&gt;private&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;css_class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-card &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;css_class&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="n"&gt;strong&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="n"&gt;plain&lt;/span&gt; &lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The private &lt;code&gt;card&lt;/code&gt; method extracts the repeated pattern: a &lt;code&gt;div&lt;/code&gt; with a modifier class, a bold count, and a label. Each card gets a status-specific class (&lt;code&gt;dp-card--complete&lt;/code&gt;, &lt;code&gt;dp-card--partial&lt;/code&gt;, etc.) so the host app's stylesheet -- or DataPorter's own default styles -- can color-code them. The &lt;code&gt;plain&lt;/code&gt; helper emits raw text without wrapping it in an element, keeping the markup minimal. It is text-safe -- no HTML escaping issues, unlike &lt;code&gt;raw&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This component takes a single &lt;code&gt;report:&lt;/code&gt; argument and does not care where the report came from. In production it comes from &lt;code&gt;data_import.report&lt;/code&gt;. In a test, you can pass a plain &lt;code&gt;Report.new(complete_count: 10, ...)&lt;/code&gt;. The component has no database dependency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 -- PreviewTable: dynamic columns from the Target DSL
&lt;/h3&gt;

&lt;p&gt;The PreviewTable is the most complex component and the one that justifies choosing Phlex. It builds an HTML table whose columns are defined dynamically by the Target class's column DSL -- the same DSL we built in part 5. A target with two columns produces a table with two data columns. A target with ten produces ten. No template changes needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/components/preview_table.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Components&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PreviewTable&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="vi"&gt;@columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;
        &lt;span class="vi"&gt;@records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;view_template&lt;/span&gt;
        &lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-preview-table"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-table"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
            &lt;span class="n"&gt;render_header&lt;/span&gt;
            &lt;span class="n"&gt;render_body&lt;/span&gt;
          &lt;span class="k"&gt;end&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="kp"&gt;private&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_header&lt;/span&gt;
        &lt;span class="n"&gt;thead&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="n"&gt;tr&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
            &lt;span class="n"&gt;th&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"#"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;th&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"Status"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="vi"&gt;@columns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;th&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;label&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;th&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"Errors"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="k"&gt;end&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_body&lt;/span&gt;
        &lt;span class="n"&gt;tbody&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="vi"&gt;@records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;render_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-row--&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="n"&gt;td&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;line_number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="n"&gt;td&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="vi"&gt;@columns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;td&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="n"&gt;td&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-errors"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;error_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errors_list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:message&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The header row always starts with a line number column and a status column, then iterates over the target's &lt;code&gt;_columns&lt;/code&gt; to produce one &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt; per declared column using &lt;code&gt;col.label&lt;/code&gt; (the human-readable name), and ends with an errors column. The body does the same iteration for each record, pulling values from &lt;code&gt;record.data&lt;/code&gt; by column name.&lt;/p&gt;

&lt;p&gt;Each row gets a &lt;code&gt;dp-row--#{record.status}&lt;/code&gt; class. A row with status &lt;code&gt;complete&lt;/code&gt; gets &lt;code&gt;dp-row--complete&lt;/code&gt;, a row with &lt;code&gt;missing&lt;/code&gt; gets &lt;code&gt;dp-row--missing&lt;/code&gt;. This lets the stylesheet highlight problem rows -- red background for missing data, yellow for partial, green for complete -- without any conditional logic in the component.&lt;/p&gt;

&lt;p&gt;The key design choice here is that the PreviewTable takes &lt;code&gt;columns:&lt;/code&gt; and &lt;code&gt;records:&lt;/code&gt; as separate arguments rather than a DataImport object. This keeps it decoupled: the component does not need to know how to resolve a target or load records from the database. The controller (or whoever renders it) passes the data in, and the component turns it into HTML.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 -- ProgressBar: bridging Phlex and Stimulus
&lt;/h3&gt;

&lt;p&gt;The ProgressBar component is where server-rendered Phlex meets client-side Stimulus. It renders the initial HTML with Stimulus data attributes, and the Stimulus controller (from part 8) takes over to animate the bar as ActionCable pushes updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/components/progress_bar.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Components&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProgressBar&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import_id&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="vi"&gt;@import_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;import_id&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;view_template&lt;/span&gt;
        &lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-progress"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;stimulus_controller_attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="n"&gt;render_bar&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="kp"&gt;private&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_bar&lt;/span&gt;
        &lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-progress-bar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="ss"&gt;data_data_porter__progress_target: &lt;/span&gt;&lt;span class="s2"&gt;"bar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="ss"&gt;style: &lt;/span&gt;&lt;span class="s2"&gt;"width: 0%"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;data_data_porter__progress_target: &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"0%"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stimulus_controller_attrs&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="ss"&gt;data_controller: &lt;/span&gt;&lt;span class="s2"&gt;"data-porter--progress"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;data_data_porter__progress_id_value: &lt;/span&gt;&lt;span class="vi"&gt;@import_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Stimulus naming convention for engines uses the &lt;code&gt;data-porter--progress&lt;/code&gt; format -- double dash separating the namespace from the controller name. Phlex's HTML DSL converts Ruby keyword arguments to HTML attributes using a simple mapping:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ruby keyword&lt;/th&gt;
&lt;th&gt;HTML output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;data_controller&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;data-controller&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;data_data_porter__progress_target&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;data-data-porter--progress-target&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Single underscores become dashes. Double underscores (&lt;code&gt;__&lt;/code&gt;) become double dashes (&lt;code&gt;--&lt;/code&gt;), which is exactly what Stimulus expects for namespaced controllers.&lt;/p&gt;

&lt;p&gt;The component renders the bar at &lt;code&gt;width: 0%&lt;/code&gt;. The Stimulus controller subscribes to the ActionCable channel using the &lt;code&gt;id_value&lt;/code&gt; and updates the width and text as progress events arrive. The Phlex component's only job is to emit the correct initial HTML with the right data attributes. It does not know about ActionCable or JavaScript.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6 -- ResultsSummary and FailureAlert: post-import components
&lt;/h3&gt;

&lt;p&gt;After the import completes, two components display the outcome. ResultsSummary shows the final counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/components/results_summary.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Components&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ResultsSummary&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="vi"&gt;@report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;view_template&lt;/span&gt;
        &lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-results"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="nb"&gt;p&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"Created: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="vi"&gt;@report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imported_count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="nb"&gt;p&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"Errors: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="vi"&gt;@report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errored_count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FailureAlert handles the catastrophic failure case -- when the Orchestrator catches an exception and transitions to &lt;code&gt;failed&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/components/failure_alert.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Components&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FailureAlert&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="vi"&gt;@report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;view_template&lt;/span&gt;
        &lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"dp-alert dp-alert--danger"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
          &lt;span class="vi"&gt;@report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error_reports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
            &lt;span class="nb"&gt;p&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="k"&gt;end&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are intentionally simple. The ResultsSummary renders two paragraphs. The FailureAlert iterates over error reports and renders each message. There is no conditional logic, no branching, no fallback states. The controller decides which component to render based on the import's status. The components just render what they are given.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;dp-&lt;/code&gt; CSS prefix strategy
&lt;/h2&gt;

&lt;p&gt;Every CSS class in the component library starts with &lt;code&gt;dp-&lt;/code&gt;: &lt;code&gt;dp-badge&lt;/code&gt;, &lt;code&gt;dp-table&lt;/code&gt;, &lt;code&gt;dp-progress&lt;/code&gt;, &lt;code&gt;dp-alert&lt;/code&gt;, &lt;code&gt;dp-card&lt;/code&gt;, &lt;code&gt;dp-row&lt;/code&gt;, &lt;code&gt;dp-errors&lt;/code&gt;, &lt;code&gt;dp-results&lt;/code&gt;, &lt;code&gt;dp-summary-cards&lt;/code&gt;, &lt;code&gt;dp-preview-table&lt;/code&gt;. This is a manual namespacing strategy that solves a real problem: a Rails engine's styles must coexist with the host app's styles without collisions.&lt;/p&gt;

&lt;p&gt;If DataPorter used generic class names like &lt;code&gt;badge&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, or &lt;code&gt;alert&lt;/code&gt;, they would conflict with Bootstrap, Tailwind UI, or whatever the host app uses. If we used CSS modules or Shadow DOM, we would add complexity and browser constraints that a server-rendered Rails app should not need.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;dp-&lt;/code&gt; prefix is the simplest approach that works. It is a convention, not a mechanism -- there is nothing stopping someone from using &lt;code&gt;dp-badge&lt;/code&gt; in their own app. But it is unlikely, and it is obvious when it happens. The naming follows BEM-style modifiers (&lt;code&gt;dp-badge--failed&lt;/code&gt;, &lt;code&gt;dp-row--complete&lt;/code&gt;, &lt;code&gt;dp-card--partial&lt;/code&gt;) so the structure is predictable.&lt;/p&gt;

&lt;p&gt;DataPorter ships with a default stylesheet that targets these classes. Host apps can override any of them. Because every class is prefixed, a blanket override like &lt;code&gt;.dp-badge { ... }&lt;/code&gt; will only affect DataPorter's badges without touching anything else on the page.&lt;/p&gt;

&lt;p&gt;This strategy also plays well with Tailwind. If the host app uses Tailwind, they can style DataPorter's components with &lt;code&gt;@apply&lt;/code&gt; inside a &lt;code&gt;dp-&lt;/code&gt;-prefixed selector, or they can use Tailwind's &lt;code&gt;@layer&lt;/code&gt; to scope overrides. The prefix gives them a clean hook without requiring any configuration in DataPorter itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions &amp;amp; tradeoffs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;We chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Component framework&lt;/td&gt;
&lt;td&gt;Phlex&lt;/td&gt;
&lt;td&gt;ERB partials, ViewComponent&lt;/td&gt;
&lt;td&gt;Components are plain Ruby objects in &lt;code&gt;lib/&lt;/code&gt;; no template resolution, no sidecar files, no asset pipeline dependency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSS scoping&lt;/td&gt;
&lt;td&gt;Manual &lt;code&gt;dp-&lt;/code&gt; prefix on all classes&lt;/td&gt;
&lt;td&gt;CSS modules, Shadow DOM, Tailwind prefix config&lt;/td&gt;
&lt;td&gt;Simplest approach that prevents collisions; no build step, no browser constraints, works everywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Naming convention&lt;/td&gt;
&lt;td&gt;BEM-style with &lt;code&gt;dp-&lt;/code&gt; namespace (&lt;code&gt;dp-badge--failed&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Flat class names, utility classes&lt;/td&gt;
&lt;td&gt;Predictable structure; modifiers make status-specific styling obvious&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PreviewTable inputs&lt;/td&gt;
&lt;td&gt;Separate &lt;code&gt;columns:&lt;/code&gt; and &lt;code&gt;records:&lt;/code&gt; arguments&lt;/td&gt;
&lt;td&gt;A single &lt;code&gt;data_import:&lt;/code&gt; argument&lt;/td&gt;
&lt;td&gt;Decouples the component from the database; testable with plain objects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ProgressBar strategy&lt;/td&gt;
&lt;td&gt;Server-render initial HTML, let Stimulus animate&lt;/td&gt;
&lt;td&gt;Render progress entirely client-side&lt;/td&gt;
&lt;td&gt;The initial state (0%) is valid HTML; progressive enhancement means the page works even if JS fails to load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component granularity&lt;/td&gt;
&lt;td&gt;Seven small components&lt;/td&gt;
&lt;td&gt;One large "import view" component&lt;/td&gt;
&lt;td&gt;Each component is independently testable, replaceable, and composable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base class&lt;/td&gt;
&lt;td&gt;Empty &lt;code&gt;Base &amp;lt; Phlex::HTML&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Components inherit directly from &lt;code&gt;Phlex::HTML&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Provides a seam for future shared behavior without touching every component&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;Phlex components are pure Ruby objects, which means testing them does not require a view context, a request, or a Rails controller. You instantiate the component and call &lt;code&gt;call&lt;/code&gt; to get the HTML string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/data_porter/components/status_badge_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;StatusBadge&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;component&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;component&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"renders a span with status text"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Completed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-badge"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"includes status-specific CSS class"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"failed"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-badge--failed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"renders all valid statuses"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="sx"&gt;%w[pending parsing previewing importing completed failed]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capitalize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern is the same for every component: define a local &lt;code&gt;render&lt;/code&gt; helper that calls &lt;code&gt;component.call&lt;/code&gt;, construct the component with the right arguments, and assert against the HTML string. No Capybara, no request specs, no browser.&lt;/p&gt;

&lt;p&gt;The PreviewTable spec is the most interesting because it uses an anonymous Target class to generate columns. An anonymous class avoids polluting the test suite with named constants that could leak between specs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/data_porter/components/preview_table_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PreviewTable&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:target_class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;Class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s2"&gt;"Guests"&lt;/span&gt;
      &lt;span class="n"&gt;model_name&lt;/span&gt; &lt;span class="s2"&gt;"Guest"&lt;/span&gt;

      &lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
        &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;required: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ss"&gt;:last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :string&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;StoreModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ImportRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;line_number: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"complete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;data: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"first_name"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"last_name"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Smith"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"renders column headers from target"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;columns: &lt;/span&gt;&lt;span class="n"&gt;target_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;records: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"First name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Last name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"renders record data in rows"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;columns: &lt;/span&gt;&lt;span class="n"&gt;target_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;records: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Smith"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"includes status-specific row class"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;columns: &lt;/span&gt;&lt;span class="n"&gt;target_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;records: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-row--complete"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test proves the full loop: the Target DSL declares columns, the PreviewTable reads those column definitions, and the rendered HTML contains the right headers and data. If someone adds a column to the target, the table grows automatically. No template change required.&lt;/p&gt;

&lt;p&gt;The ProgressBar spec verifies the Stimulus wiring without needing JavaScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/data_porter/components/progress_bar_spec.rb&lt;/span&gt;
&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Components&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ProgressBar&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"renders a progress bar with Stimulus data attributes"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;import_id: &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dp-progress"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"data-controller"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"data-porter--progress"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"42"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"renders bar and text targets"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;import_id: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'data-data-porter--progress-target="bar"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'data-data-porter--progress-target="text"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We do not test that the bar animates. We test that the HTML contract between Phlex and Stimulus is correct -- the right controller name, the right target names, the right value attribute. If those are present, the Stimulus controller (tested separately) will work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phlex&lt;/strong&gt; lets us ship components as plain Ruby classes in &lt;code&gt;lib/&lt;/code&gt;, with no template files, no view context, and no asset pipeline dependency -- ideal for a Rails engine gem.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;dp-&lt;/code&gt; prefix&lt;/strong&gt; on every CSS class prevents style collisions with the host app, using the simplest possible approach: a naming convention.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;PreviewTable&lt;/strong&gt; builds its columns dynamically from the Target DSL, so adding a column to a target automatically adds a column to the preview -- no template changes.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;ProgressBar&lt;/strong&gt; bridges server-rendered Phlex and client-side Stimulus by emitting the correct data attributes at render time, then letting JavaScript handle animation.&lt;/li&gt;
&lt;li&gt;Every component is &lt;strong&gt;independently testable&lt;/strong&gt; by calling &lt;code&gt;component.call&lt;/code&gt; and asserting against the returned HTML string -- no browser, no request context, no Capybara.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;We have components, but nothing renders them yet. In part 10, we build the &lt;strong&gt;controllers and routing layer&lt;/strong&gt; for the engine -- how ImportsController inherits from the host app's parent controller, how engine routes are namespaced, and how Turbo integration ties the components to the import workflow. The tricky part: making authentication flow from the host app into the engine without coupling to any specific auth library.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part 9 of the series "Building DataPorter - A Data Import Engine for Rails". &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-8-real-time-progress-with-actioncable-stimulus-2cji"&gt;Previous: Real-time Progress with ActionCable &amp;amp; Stimulus&lt;/a&gt; | &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-10-controllers-routing-in-a-rails-engine-492j"&gt;Next: Controllers &amp;amp; Routing in a Rails Engine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Building Chatbots. Start Building Message-Driven Systems</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Mon, 09 Mar 2026 15:06:37 +0000</pubDate>
      <link>https://dev.to/seryllns_/stop-building-chatbots-start-building-message-driven-systems-1dcm</link>
      <guid>https://dev.to/seryllns_/stop-building-chatbots-start-building-message-driven-systems-1dcm</guid>
      <description>&lt;p&gt;Most teams building on WhatsApp are building chatbots. Multi-turn conversations, intent trees, fallback loops — the whole nine yards.&lt;/p&gt;

&lt;p&gt;But here's the thing: &lt;strong&gt;most of them don't actually need a chatbot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They need a system that turns incoming messages into &lt;strong&gt;backend actions&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Chatbot Trap
&lt;/h2&gt;

&lt;p&gt;A hotel guest sends:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Can I get extra towels in room 312?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The typical chatbot approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bot: Hi! How can I help you?
Guest: I need towels
Bot: Which room?
Guest: 312
Bot: How many towels?
Guest: ...just some towels
Bot: I didn't understand. How many towels would you like?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;3 round-trips to extract 2 fields. The guest is annoyed. The developer is maintaining a conversation state machine. Everyone loses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Messages Are Not Conversations
&lt;/h2&gt;

&lt;p&gt;Here's the insight: &lt;strong&gt;most inbound messages already contain everything you need.&lt;/strong&gt; You don't need to ask follow-up questions — you need to extract what's already there.&lt;/p&gt;

&lt;p&gt;The shift is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Message
  ↓
Analysis (single LLM call)
  ↓
Structured payload
  ↓
Backend action
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One message in, one structured payload out. No conversation state. No dialog trees. No "I didn't understand."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://whatsrb.com/flow" rel="noopener noreferrer"&gt;See it in action →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like in Practice
&lt;/h2&gt;

&lt;p&gt;The same message — &lt;code&gt;"Can I get extra towels in room 312?"&lt;/code&gt; — processed as a &lt;strong&gt;message-driven system&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"room_service_request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.97&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"housekeeping.task.create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"room"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"312"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"extra towels"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"suggested_reply"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Of course! Extra towels are on the way to room 312."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your backend receives this via webhook. You create the task in your PMS. You send the reply. Done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One message. One payload. One action.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Here's the full flow — from WhatsApp message to backend action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WhatsApp (inbound message)
  ↓
Pre-filter (spam, noise — no LLM cost)
  ↓
Router (classify + route to the right agent)
  ↓
Agent (LLM call → structured output)
  ↓
Webhook (HMAC-signed payload to your app)
  ↓
Your backend (create task, update booking, send reply)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step is deterministic. The LLM is used once, for extraction — not for conversation. The output is a structured JSON payload your backend can trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code, Not Config
&lt;/h2&gt;

&lt;p&gt;Here's what triggering an agent looks like with the Ruby SDK (same way with TypeScript or Python SDK):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"whatsrb"&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;WhatsRB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;api_key: &lt;/span&gt;&lt;span class="s2"&gt;"wrb_live_xxx"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;agent_id: &lt;/span&gt;&lt;span class="s2"&gt;"agt_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;input: &lt;/span&gt;&lt;span class="s2"&gt;"Cancel booking #BK-4521"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;output&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; { "intent" =&amp;gt; "cancel_booking", "actions" =&amp;gt; [...] }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with a simple curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.whatsrb.com/v1/agents/agt_abc123/runs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer wrb_live_xxx"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"input":"Cancel booking #BK-4521"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get back a structured payload. Every time. No conversation state to manage.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Chatbots Make Sense (And When They Don't)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Chatbot?&lt;/th&gt;
&lt;th&gt;Message-driven?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Customer FAQ with branching logic&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Book a table for 4 at 8pm"&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Cancel my order #12345"&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"AC broken in room 204"&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guided product recommendation&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Where's my shipment TRK-789?"&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the message already contains the intent and the data — you don't need a chatbot. You need a &lt;strong&gt;message-to-action pipeline&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real Results
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmih3d07yr9xzs75j02be.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmih3d07yr9xzs75j02be.gif" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With this approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No conversation state&lt;/strong&gt; to maintain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single LLM call&lt;/strong&gt; per message (~1-2s latency)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic outputs&lt;/strong&gt; your backend can trust&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;90%+ accuracy&lt;/strong&gt; on intent + field extraction out of the box&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  This Is What We're Building
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://whatsrb.com" rel="noopener noreferrer"&gt;&lt;strong&gt;WhatsRB Cloud&lt;/strong&gt;&lt;/a&gt; is a platform that turns WhatsApp messages into structured, actionable webhooks. No chatbot framework. No dialog trees. Just messages in, actions out.&lt;/p&gt;

&lt;p&gt;We're opening the beta on &lt;strong&gt;March 31, 2026&lt;/strong&gt; to a limited group of early adopters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://whatsrb.com/signup" rel="noopener noreferrer"&gt;Get early access →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Check out the &lt;a href="https://readme.whatsrb.com" rel="noopener noreferrer"&gt;API docs&lt;/a&gt; to see how it works under the hood.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://x.com/WhatsRB_" rel="noopener noreferrer"&gt;Follow me on X for updates&lt;/a&gt;&lt;br&gt;
&lt;a href="https://github.com/SerylLns" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Ruby, Rails, and a healthy distrust of chatbots.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>ai</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>Building a Rails Engine #8 — Real-time Progress with ActionCable &amp; Stimulus</title>
      <dc:creator>Seryl Lns</dc:creator>
      <pubDate>Thu, 05 Mar 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/seryllns_/building-a-rails-engine-8-real-time-progress-with-actioncable-stimulus-2cji</link>
      <guid>https://dev.to/seryllns_/building-a-rails-engine-8-real-time-progress-with-actioncable-stimulus-2cji</guid>
      <description>&lt;h1&gt;
  
  
  Real-time Progress with ActionCable &amp;amp; Stimulus
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;How to push live progress updates from a background import job to the browser using a Broadcaster service, an ActionCable channel, and a Stimulus controller -- so users never stare at a dead spinner again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;This is part 8 of the series where we build &lt;strong&gt;DataPorter&lt;/strong&gt;, a mountable Rails engine for data import workflows. In &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-7-the-orchestrator-coordinating-the-workflow-1p4i"&gt;part 7&lt;/a&gt;, we built the Orchestrator -- the class that coordinates the parse-then-import workflow in the background via ActiveJob.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The user clicks "Import". The job goes into the queue. And the page sits there.&lt;/p&gt;

&lt;p&gt;No progress indicator. No feedback. No way to know if the import is 10% done, 90% done, or failed entirely. The only option is to refresh and check the status column. For a 50,000-row CSV that takes two minutes, that is a terrible experience.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before:  click "Import" → spinner → ??? → refresh → refresh → done (maybe)
After:   click "Import" → live progress bar 0%...50%...100% → auto-reload with results
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rails ships ActionCable, so we use it. The challenge is keeping the broadcasting layer decoupled from the Orchestrator and providing a Stimulus integration that works without the host developer writing any JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;Here is the flow from server to browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Orchestrator#import!
  |
  |-- for each record:
  |     Broadcaster#progress(current, total)
  |       --&amp;gt; ActionCable.server.broadcast("data_porter/imports/42", { status: :processing, percentage: 65, ... })
  |             --&amp;gt; ImportChannel streams to subscriber
  |                   --&amp;gt; Stimulus progress_controller updates the bar
  |
  |-- on success:
  |     Broadcaster#success
  |       --&amp;gt; { status: :success }
  |             --&amp;gt; Stimulus reloads the page
  |
  |-- on failure:
        Broadcaster#failure(message)
          --&amp;gt; { status: :failure, error: "..." }
                --&amp;gt; Stimulus reloads the page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three objects, three layers. The Broadcaster knows how to format messages. The ImportChannel knows how to route them. The Stimulus controller knows how to render them. None of them knows about the others' internals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 -- The Broadcaster service
&lt;/h3&gt;

&lt;p&gt;The Broadcaster is a plain Ruby object that wraps &lt;code&gt;ActionCable.server.broadcast&lt;/code&gt; with import-specific semantics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/data_porter/broadcaster.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Broadcaster&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;import_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cable_channel_prefix&lt;/span&gt;
      &lt;span class="vi"&gt;@channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/imports/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;import_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_f&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;
      &lt;span class="n"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :processing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;percentage: &lt;/span&gt;&lt;span class="n"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;current: &lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;total: &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;success&lt;/span&gt;
      &lt;span class="n"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :failure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="kp"&gt;private&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="no"&gt;ActionCable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three public methods, three states the browser cares about: work is in progress, work succeeded, or work failed. The percentage is computed server-side so the client just sets a CSS width. The channel name uses a configurable prefix (&lt;code&gt;data_porter/imports/42&lt;/code&gt;) to avoid collisions with the host app's channels.&lt;/p&gt;

&lt;p&gt;The Broadcaster plugs into the Orchestrator's import loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Inside Orchestrator#import_records (conceptual)&lt;/span&gt;
&lt;span class="n"&gt;broadcaster&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Broadcaster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@data_import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;importable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each_with_index&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;persist_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;broadcaster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;importable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="n"&gt;broadcaster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;progress&lt;/code&gt; call per record. ActionCable broadcasts are cheap (in-memory pub/sub with the async adapter, Redis PUBLISH with Redis), and the Stimulus controller handles them idempotently -- it just sets a CSS width, so skipped frames cause no harm.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 -- The ImportChannel
&lt;/h3&gt;

&lt;p&gt;The channel is the thinnest class in the entire engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/channels/data_porter/import_channel.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DataPorter&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImportChannel&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActionCable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Channel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;subscribed&lt;/span&gt;
      &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cable_channel_prefix&lt;/span&gt;
      &lt;span class="n"&gt;stream_from&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/imports/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire file. The channel constructs the same stream name the Broadcaster uses -- same prefix, same format. No authorization logic here: by the time the user sees the progress bar, the controller has already authorized access to that import.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 -- The Stimulus progress controller
&lt;/h3&gt;

&lt;p&gt;On the browser side, a Stimulus controller subscribes to the channel and updates the DOM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/javascript/data_porter/progress_controller.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@hotwired/stimulus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createConsumer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@rails/actioncable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createConsumer&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DataPorter::ImportChannel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idValue&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;updateProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasBarTarget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;barTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller declares two targets (&lt;code&gt;bar&lt;/code&gt; and &lt;code&gt;text&lt;/code&gt;) and one value (&lt;code&gt;id&lt;/code&gt;). The corresponding HTML looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;data-controller=&lt;/span&gt;&lt;span class="s"&gt;"data-porter--progress"&lt;/span&gt; &lt;span class="na"&gt;data-data-porter--progress-id-value=&lt;/span&gt;&lt;span class="s"&gt;"42"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;data-data-porter--progress-target=&lt;/span&gt;&lt;span class="s"&gt;"bar"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width: 0%"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;data-data-porter--progress-target=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;0%&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logic is two branches: if &lt;code&gt;processing&lt;/code&gt;, update the bar; for anything else (&lt;code&gt;success&lt;/code&gt; or &lt;code&gt;failure&lt;/code&gt;), reload the page. The reload is the simplest possible terminal action -- the server-rendered page shows the final state, no client-side state management needed.&lt;/p&gt;

&lt;p&gt;A few design choices worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;createConsumer()&lt;/code&gt;&lt;/strong&gt; instead of a shared consumer -- in an engine context, we cannot assume the host app exports one. ActionCable deduplicates connections internally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hasBarTarget&lt;/code&gt; guard&lt;/strong&gt; -- degrades gracefully during Turbo transitions where the DOM might be partially rendered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;window.location.reload()&lt;/code&gt;&lt;/strong&gt; on completion -- the engine does not need to ship Turbo Stream templates for the result screen. The server renders it once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 4 -- Configuration
&lt;/h3&gt;

&lt;p&gt;The channel prefix defaults to &lt;code&gt;"data_porter"&lt;/code&gt; and is overridable in the initializer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/initializers/data_porter.rb&lt;/span&gt;
&lt;span class="no"&gt;DataPorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cable_channel_prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my_app_imports"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents channel name collisions if the host app runs multiple engines that use ActionCable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions &amp;amp; tradeoffs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;We chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real-time transport&lt;/td&gt;
&lt;td&gt;ActionCable (WebSockets)&lt;/td&gt;
&lt;td&gt;Polling or SSE&lt;/td&gt;
&lt;td&gt;Rails ships ActionCable; no extra dependencies, integrates with existing auth, bidirectional even though we only need server-to-client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broadcast granularity&lt;/td&gt;
&lt;td&gt;One message per record&lt;/td&gt;
&lt;td&gt;Batched (every N records) or throttled (every N seconds)&lt;/td&gt;
&lt;td&gt;Simplicity; ActionCable broadcasts are cheap, and the Stimulus controller handles high-frequency updates idempotently by just setting a CSS width&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completion behavior&lt;/td&gt;
&lt;td&gt;&lt;code&gt;window.location.reload()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Turbo Stream partial updates&lt;/td&gt;
&lt;td&gt;The engine cannot predict what the host app's result page looks like; a full reload lets the server render the final state with its own layout and components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Channel authorization&lt;/td&gt;
&lt;td&gt;None (deferred to controller)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;reject&lt;/code&gt; in &lt;code&gt;subscribed&lt;/code&gt; based on user ownership&lt;/td&gt;
&lt;td&gt;The engine does not know the host's auth system; by the time the user sees the progress bar, the controller has already authorized access&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;Broadcaster&lt;/strong&gt; is a plain Ruby service that wraps &lt;code&gt;ActionCable.server.broadcast&lt;/code&gt; with three semantic methods: &lt;code&gt;progress&lt;/code&gt;, &lt;code&gt;success&lt;/code&gt;, and &lt;code&gt;failure&lt;/code&gt;. It constructs the channel name from a configurable prefix and the import ID.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;ImportChannel&lt;/strong&gt; is a one-method ActionCable channel that streams from the same channel name the Broadcaster writes to. It contains no authorization logic -- that responsibility stays in the controller layer.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Stimulus progress controller&lt;/strong&gt; subscribes on &lt;code&gt;connect&lt;/code&gt;, updates a progress bar on &lt;code&gt;processing&lt;/code&gt; messages, reloads the page on &lt;code&gt;success&lt;/code&gt; or &lt;code&gt;failure&lt;/code&gt;, and cleans up the subscription on &lt;code&gt;disconnect&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;cable_channel_prefix&lt;/strong&gt; configuration option prevents channel name collisions with the host app and gives operations teams control over naming.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up
&lt;/h2&gt;

&lt;p&gt;The import now runs in the background and pushes live progress to the browser. But the progress bar needs a page to live on, and right now the engine has no UI. In part 9, we build the &lt;strong&gt;view layer with Phlex and Tailwind&lt;/strong&gt; -- auto-generated preview tables from the target DSL, status badges, and scoped CSS that does not leak into the host app.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part 8 of the series "Building DataPorter - A Data Import Engine for Rails". &lt;a href="https://dev.to/seryllns_/building-a-rails-engine-7-the-orchestrator-coordinating-the-workflow-1p4i"&gt;Previous: The Orchestrator&lt;/a&gt; | Next: Building the UI with Phlex &amp;amp; Tailwind&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/SerylLns/data_porter" rel="noopener noreferrer"&gt;SerylLns/data_porter&lt;/a&gt; | &lt;strong&gt;RubyGems:&lt;/strong&gt; &lt;a href="https://rubygems.org/gems/data_porter" rel="noopener noreferrer"&gt;data_porter&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>rails</category>
      <category>ruby</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
