Rails Engines as Products: Extract Only After Proof

A Rails engine should be extracted only after its boundary has survived production work. The Responsa case shows what moves out, what stays, and why now.

The questionnaire code had already survived an education platform before I pulled it out: it built educator check-ins, student feedback, public surveys and assessments; rendered a Typeform-style builder; took respondents through one question at a time; saved partial progress; branched on answers; issued public tokens; and marked what it could, while the surrounding product handled course deliveries, course steps, anonymous student identity, signed links with a six-month lifetime, progression rules, staff roles and reporting.

So I extracted the form system into Responsa, a Rails engine, because the boundary had been found by building a real product, including where it did not fit cleanly.

I do not start Rails applications by creating engines/ for imagined reusable components, because that creates a private framework with no users and makes ordinary changes slower, whereas an engine is a product surface that, even inside one company, needs compatibility, installation instructions, migrations, test fixtures and an escape route when assumptions fail.

The application had already drawn the boundary

The source was a Rails 8.1 platform for a youth education non-profit, where educators delivered courses of modules, lessons and ordered steps; recorded class context; moved through published content; requested volunteer sessions; and completed lesson check-ins, while administrators managed content, people and reporting and students and public-survey respondents needed a different privacy model from ordinary signed-in users.

The reusable centre was clear outside product language: forms have ordered questions; responses have answers and completion state; question types have answer shapes; respondents resume; answers choose the next question; and some answers are marked against a model answer while long written answers need an assessor, which makes it a coherent system with its own state transitions.

Application code began where a response meant something to education: an educator check-in attaches to a course delivery, course step and audience, then changes course progression when complete, while a student responds through a purpose-bound signed link, so those are application policy rather than generic form properties.

My extraction test is whether I can describe the candidate without the host's central records, because “A delivery response which advances a course for an educator” is a feature while “A response flow with partial saving, ordered questions and optional assessment marking” may be a boundary, and it also needs callers with distinct policy, since this host wanted its own routes, authentication and lifecycle while another may want only public feedback; the platform's educator feedback, anonymous student feedback and public surveys proved both the common mechanics and the differences.

Two callers alone are not proof: the policy must differ, otherwise the engine forces a fake generalisation before either application has earned it.

A gem becomes an engine when Rails owns part of the contract

A plain Ruby gem can parse a file, talk to an API, format money or calculate a score, but it should not own routes, controllers, assets or database migrations.

Responsa owns Responsa::Form, Responsa::Response and Responsa::Answer; short text, long text, boolean, scale, multiple choice, number, location and ranking questions; a builder, response flow, marking screens and migrations; and JavaScript controllers for nested and dynamically added fields, plus CSS prefixed .responsa-, so calling it a gem would force every host to discover its tables, copy routes, find JavaScript, assemble the builder and debug the joins.

Responsa does not own host policy: forms stand alone, with no required Lesson, Delivery, User or tenant model, so a host can associate a form with its own record or nothing, and responses store user_uid, not the host's User or authentication scheme; because the education app had authenticated educators but signed identifiers for students and public surveys, one model relationship would weaken reuse and blur that privacy distinction.

Course records, progress updates and signed-link policy stayed in the app because a candidate is still coupled if it needs ApplicationRecord, calls current_user from a model or assumes one global route helper; namespacing does not fix that.

The first host must be allowed to be strange

Generic by subtraction produces fifteen configuration options and no useful default, so Responsa has a baseline: install the gem, run bin/rails generate responsa:install, run migrations and add responsa_for to routes, with routes defaulting to /responsa, while the installer copies Stimulus controllers, pins them in the import map, installs engine migrations and runs the Action Text installer.

That path requires Rails 8, importmap, Turbo, Stimulus and Propshaft or Sprockets, while the proven escape hatches are responsa_for path: "admin", path: nil, and forms_as: when “survey” is a better resource name, which deliberately narrows the supported path but lets a host start without reverse-engineering how the UI is assembled.

Hosts can own controllers without a fork because engine controllers inherit from the host's ApplicationController, while a host can subclass Responsa::ResponsesController, add authentication and use nested resources; Responsa detects host helpers such as form_response_path for redirects and actions instead of forcing responsa_*.

The education controller attaches delivery, step and audience to a completed response, then updates course progression, while the engine still knows nothing of courses and the host does not reimplement one-question-at-a-time answering. Hosts may generate all engine views, or only response, form, answer, question or marking groups, into app/views/responsa/, which Rails prefers, and a Tailwind host can omit packaged CSS and style semantic markup.

The actual extensions were routes, authentication, lifecycle context and presentation, not themes, authentication providers or arbitrary callbacks.

Version numbers turn your internal code into a promise

An engine creates a second release cycle: in a monolith I change model, migration and controller together, run one suite and deploy one artifact, whereas hosts can be on different versions, a migration may run in one but not another, a renamed route helper breaks callers and an installed JavaScript controller may miss a later fix, making Rails, Ruby and the asset path a compatibility matrix.

Responsa requires Ruby 3.1 or later, Rails 8.0 or later, a supported asset pipeline, import maps, Turbo and Stimulus, because “Any Rails app” is too broad to test honestly, and its migrations, table names, indexes and foreign keys are an upgrade contract, so additive migrations are easy while removing a column or changing stored-data meaning needs a release plan, as do seeds, generated files and routes.

Breaking changes need a major version, supported behaviour a minor and fixes patches; anything documented, generated, rendered or named in a route is public enough to assume a dependency, while the education response controller stays outside the engine, so it is not overwritten, and its progression rules remain application tests rather than a permanent one-product after_response_complete hook.

Reuse is not the decision; independent change is, because when host and candidate change on different schedules, the boundary is real.

Test the package with no knowledge of the first application

Responsa's fully wired spec/dummy application creates and migrates a database, seeds sample forms and assessments, starts as Rails at /forms, and exercises ordinary, branching and partial-saving forms plus assessments with automatic marking and required human review.

That tests installation joins: migrations into a host database, routes, response rendering, builder JavaScript, view assets and seeded forms a user can answer, while unit and request tests still cover response completion and marking; branching with no matching branch; exact-answer auto marking versus needs_review; default and overridden helpers; and installer files, because engine failures happen more often at those joins than inside a validation method, which is why the dummy app matters more than unit tests alone.

No engine test may reach into the education platform for a Course, course delivery or authentication helper; instead, its integration tests ask whether a completed educator check-in attaches to the right delivery and step, gets the right audience and advances progression only when it should, while anonymous student feedback must accept its signed link for the permitted period without becoming authenticated educator identity, so those suites keep question-flow regressions in the engine and course-completion regressions in the host.

For more on treating tests as contracts rather than a coverage ritual, I wrote about that in Tests as Contracts.

An engine is finished when it refuses work

Responsa does not decide who creates forms or marks assessments, define privacy policy, student safeguarding, course hierarchy or impact reports, or decide whether completion unlocks a lesson, sends a notification, creates a certificate or does nothing, because those decisions belong to the host precisely because their consequences belong to the host, and that restraint lets a second app install it without importing the first application's worldview.

I would reject a universal authentication adapter, response workflow engine, reporting layer, multi-tenant layer and application-specific question-type library until a real host needs them; extract what has earned a name, keep policy where the product can see it and version the seam as a promise, because I build Rails systems where those boundaries survive real change, and my CV has the work and rates.

Related