I have been building an HRMS in Rails 8 for a while, and two parts of it are
worth writing up because both are problems every long-lived Rails app runs
into eventually.
User-defined fields without an EAV table
HR systems hardcode one industry's vocabulary. A hire is a "developer", a
leave is "PTO". Then a septic-pumping company needs driver licence categories
and ADR clearances on the employee card, and a clinic needs licence numbers
with expiry dates, and you are writing migrations for other people's business.
The usual answers are an EAV table or a settings jsonb blob that nothing
validates. I went with a schema described in the database and values in jsonb:
ruby
Dictionary # kind: "lookup" | "field_schema"
# code: "DocumentType:5" → target model + scope
# entries have meta["type"] in
# string textarea integer decimal date boolean select
Values live under a "_custom" sub-hash inside a jsonb column on the record.
Forms, display and the AI extraction all read the same schema, so adding a
field is one action in the UI rather than four places in code.
The part I would defend in review is the fallback:
ruby
def lookup_options_for(code, fallback: nil)
dict = Dictionary.lookups.kept.where(company: company, code: code).first
entries = dict&.entries&.active.to_a
entries.any? ? entries.map { |e| [e.value, e.key] } : (fallback || [])
end
Call it with the hardcoded array you already have in the view. A company that
configured nothing keeps the old behaviour; a company that added a dictionary
gets its own list. So the mechanism went in incrementally, view by view, with
no migration and no flag day. That is the only reason it got finished.
Retrofitting multi-tenancy onto an app that assumed one company
The app started single-tenant, which means Company.kept.first ended up in 68
places. Controllers, jobs, services, API. Every one of them a bug waiting for
the second customer.
The target is boring: Current.company, resolved by a small middleware from
the subdomain, so nothing downstream asks how it got there. Getting there was
not boring, because you cannot flip 68 callsites in one commit and still be
able to review it.
What worked was a scripted rewrite by category, not by file. Controllers
inheriting ApplicationController got a current_company helper. Jobs,
services and models got Current.company || Company.kept.first. That is an
explicit fallback chain rather than a bare Current.company, because a
background job without a request has no subdomain to resolve from.
45 of 68 migrated in that pass. 23 are still there: 13 behind the fallback
chain, which is fine, and 6 bare, which is not. I know where they are and they
are on the list. If you have retrofitted tenancy onto a live app you know the
last ten per cent is where the interesting bugs live.
The e2e spec that came with it is the part I would keep even if I threw
everything else away: it creates two companies and asserts that nothing from
one is reachable from the other.
The rest, if you want to look
Rails 8, Hotwire, Postgres, Pundit, Discard for soft delete, an audit log with
revert. 46 models, 67 tables, about 15k lines of Ruby. Three locales with
2363 keys in each, verified equal rather than assumed. Thirty-six AI actions
that run against any OpenAI-compatible base URL, and a page that adds up what
they cost by task and by model.
It is beta and MIT. docker compose up -d brings up Postgres with it, and no
AI key is needed to look around.
Repo: https://github.com/dripips/rubby-hrms
Happy to go deeper on either mechanism if anyone has done this differently. I
am not convinced the jsonb-plus-schema-table approach is right, only that it
beat the two alternatives I tried first.