Gives every hub in a Rails app one way to declare what it needs from another hub.
A hub usually lives in a domain gem, next to the domain logic it serves. Run the generator from the gem's root, naming each port as the port and the method the hub's own code calls:
bin/rails generate hub_kernel:hub Ledger entry_recorder:record_entryInside a Rails engine gem it writes the hub under the gem's namespace, such as
app/models/billing/ledger.rb with module Billing::Ledger, which extends
HubKernel::Ports and declares each port. It also adds hub_kernel to the gem's
gemspec. It writes no hub check into the gem's tests, since the gem never fills its own
ports.
Run in an app, it writes the hub into its own folder, named after the last part of the
hub's name: Console::Hubs::Supplies goes to
hubs/supplies/app/models/console/hubs/supplies.rb, and its hub check to
test/hubs/supplies/console/hubs/supplies_test.rb, which the app's normal test run
reads.
A hub that lives in the host app can keep its code in its own folder,
hubs/<hub>/app/<kind>/, laid out like the app's own app/ folder:
hubs/billing/app/models/billing/invoice.rb
hubs/billing/app/jobs/billing/reminder_job.rb
hubs/billing/app/event_definitions/billing/invoice_sent.rb
hub_kernel adds each of those folders to the host's load paths, so a class there loads
by its full name, Billing::Invoice, just as it would from app/models/. Moving a
class into its hub's folder changes nothing for the code that calls it. A namespace
module may be defined in app/models/ or in another hub's folder. A host with no
hubs/ folder loads exactly what it loads today.
Files under a hub's lib/ folder, such as its generated event pack, are not loaded.
Every hub module in a hub folder, a module that extends HubKernel::Ports or
HubKernel::Exposes, is loaded at boot and again after each code reload, before the
host's own to_prepare code runs. A hub the host's code never names is still on
HubKernel::Hubs.list for the start check, and hub_kernel-interface still serves it.
A hub folder's views are searched after the host's app/views, so a controller in the
hub renders its templates from there, any view can render a hub partial by its usual
path, and a template in app/views wins over one at the same path in a hub folder. A
hub folder's helpers are added to the host's helper paths, so they are available
wherever every helper is.
The app that installs the gem fills each of the hub's ports in its own setup, then runs the start check:
Rails.application.config.to_prepare do
Billing::Ledger.entry_recorder = Finance.method(:record_entry)
HubKernel::Hubs.check!
endThe app's own test for that hub includes the hub check, where the ports are filled:
class Billing::LedgerHubTest < ActiveSupport::TestCase
include HubKernel::Conformance::Hub
hub { Billing::Ledger }
endRun inside an app rather than a gem, the generator writes the hub under
app/models and its hub check under test/models.
A port is something a hub needs from outside itself. The hub declares it by name, with the method its own code calls:
module Supplies
extend HubKernel::Ports
port :spend_recorder, as: :record_spend
endThe app fills each port with any callable, usually another hub's method:
Rails.application.config.to_prepare do
Supplies.spend_recorder = Finance.method(:record_spend)
endSupplies.record_spend(amount: 5) then calls what the app filled the port with and
returns its answer. An error it raises reaches the caller unchanged. Calling a port the
app left unfilled raises HubKernel::UnwiredPortError, such as "Supplies' spend
recorder is not wired".
A record port is a port the app fills with an Active Record model class, so a hub can name a model another part of the app owns without naming its class. The hub declares it by name:
module Fulfillment
extend HubKernel::Ports
record_port :job_record
endThe app fills it with the model, in the same setup as its other ports:
Rails.application.config.to_prepare do
Fulfillment.job_record = Job
endFulfillment.job_record then answers Job. A record port is on the hub's
port_names, so the start check, the hub check and kept ports all read it.
A hub's model links to a record another hub owns with belongs_to_port, naming its own
hub and one of that hub's record ports instead of the other model's class:
module Sales
class Quote < ApplicationRecord
belongs_to_port :job, port: Sales, record: :job_record
end
endhub_kernel defines an ordinary belongs_to :job whose class is the model the app filled
the port with. quote.job reads the job, and includes, preload, joins, where and
order work on the link as on any belongs_to. Every other belongs_to option, such as
foreign_key: or optional:, and a scope passed before the options, reach the
association unchanged. The class name comes from the port, so one passed is not used.
A link is always read-only, and no option makes it writable:
- A record read through the link, loaded or preloaded, raises
ActiveRecord::ReadOnlyRecordwhen it is saved or destroyed. build_job,create_jobandcreate_job!raiseActiveRecord::ReadOnlyRecord, and so does assigning an unsaved record tojob.- The records a scope loads are read-only too.
autosave,dependent,touchandcounter_cacheeach write to the linked record, so passing any of them raises anArgumentErrornaming it.
The model still sets which record it links to, by assigning a saved record or its id, and saves itself. A change to the linked record goes through the hub that owns it.
The link is defined as soon as both the model has loaded and the port is filled, in whichever order those happen. A model that loads after its port is filled has the link when it loads. A model that loads first waits on its hub, and filling the port defines the link. A boot with eager loading on defines it the same way. After a code reload in development, the reloaded model has the link again, and it points at the reloaded model the app fills the port with.
A link that names a port its hub does not declare, or one of the hub's call ports, raises
an ArgumentError when the model loads, naming the model, the hub and the port. A link
whose port: is not a hub module raises an ArgumentError naming the model.
The model names only its own hub, so the crossing check passes it. The app's
to_prepare block that fills the port is the one place both models are named.
A hub's model lists the records another hub owns that point back at it with
has_many_port, naming its own hub and one of that hub's record ports:
module Sales
class Job < ApplicationRecord
has_many_port :quotes, port: Sales, record: :quote_record
end
endhub_kernel defines an ordinary has_many :quotes whose class is the model the app filled
the port with. job.quotes lists the quotes, and includes, preload, joins, where
and order work on the link as on any has_many. Every other has_many option, such as
foreign_key: or inverse_of:, and a scope passed before the options, reach the
association unchanged, apart from the options below that write to the listed records.
The class name comes from the port, so one passed is not used.
A has-many link is always read-only, and no option makes it writable:
- A record listed through the link, loaded, preloaded or queried, raises
ActiveRecord::ReadOnlyRecordwhen it is saved or destroyed. - Adding a record to the list with
<<,push,append,concatorreplace, removing one withdelete,destroy,delete_all,destroy_allorclear, and building or creating one withbuild,new,createorcreate!raiseActiveRecord::ReadOnlyRecord, on the list and on a query made from it. - Assigning the list with
quotes=orquote_ids=raisesActiveRecord::ReadOnlyRecord. - Writing the listed records all at once with
update_all,update_counters,insert,insert!,insert_all,insert_all!,upsertorupsert_allraisesActiveRecord::ReadOnlyRecord. autosave,counter_cacheand adependentvalue that destroys, deletes or nullifies the listed records each write to them, so passing any of them raises anArgumentErrornaming it.
dependent: :restrict_with_exception and dependent: :restrict_with_error write nothing
to the listed records, so the link takes them. Deleting the model while the link lists
records is refused, the first way by raising ActiveRecord::DeleteRestrictionError and
the second by adding an error and returning false, and deleting it when the link lists
none goes ahead.
The link is defined as soon as both the model has loaded and the port is filled, in
whichever order those happen, the same way as a belongs_to_port link. A boot with eager
loading on defines it, and after a code reload in development the reloaded model lists
the reloaded model the app fills the port with.
A has-many link refuses a port that is not one of its hub's record ports, and a port:
that is not a hub module, in the same way as a belongs_to_port link.
Every hub that declares a port or exposes a method is on HubKernel::Hubs.list. Call
HubKernel::Hubs.check! after the app fills its ports, and the app refuses to start
while any port is unfilled:
Rails.application.config.to_prepare do
Supplies.spend_recorder = Finance.method(:record_spend)
HubKernel::Hubs.check!
endIt raises HubKernel::UnwiredPortError naming every unfilled port with its hub, one per
line. It also names each record port filled with something that is not an Active Record
model, such as "Fulfillment's job record is filled with something that is not an Active
Record model". Running it inside to_prepare checks again after every code reload, and a hub
declared again on a reload stays on the list once.
While any hub exposes a method, it also names the permission check or the account scope when either is unset or set to something that cannot be called. An app whose hubs expose nothing starts without either.
A hub's own test can check that the app filled every one of its ports, so a missing fill shows in the suite rather than on the next start:
require "hub_kernel/conformance/hub"
class SuppliesHubTest < ActiveSupport::TestCase
include HubKernel::Conformance::Hub
hub { Supplies }
endThe test fails and names each port the app left unfilled, each port filled with something that cannot be called, and each record port filled with something that is not an Active Record model.
The exposed list, calling by name, the permission check, the account scope and the errors
they raise come from hub_kernel-interface, which hub_kernel depends on, so a hub keeps
writing extend HubKernel::Exposes with no other gem to add. Interface gems such as
hub_kernel-api depend on hub_kernel-interface alone.
A hub names each method outside callers may reach, the values it takes, and whether it writes. A layer such as a JSON endpoint looks a method up by name and serves only what the hub exposes:
module Supplies
extend HubKernel::Exposes
exposes :record_purchase, takes: %i[supplier_id bought_on lines], writes: true
end
Supplies.exposed("record_purchase").takes # => [:supplier_id, :bought_on, :lines]
Supplies.exposed("delete_everything") # => nilAn interface such as a JSON API calls a hub through its exposed list, by name, with the values a caller sent and the person and account the call is made for:
Supplies.call_exposed("record_purchase", values: { supplier_id: 4, bought_on: "2026-10-01", lines: [] }, person: current_person, account: current_account)It raises HubKernel::UnexposedMethodError for a name the hub does not expose,
HubKernel::MissingArgumentError for a call with no person, no account, or a value the
method requires, and passes on only the values the method is listed with. A hub raises
HubKernel::Refused with a reason when it will not do what was asked, and the reason
reaches the caller unchanged.
An interface can show a caller what it may call before it calls anything. exposures
gives every entry a hub exposes, each with its name, the values it takes and whether it
writes. exposures_for gives only the entries the host's permission check allows one
person on one account:
Supplies.exposures_for(person: current_person, account: current_account).map(&:name)It refuses a missing person or account as a call by name does, and a hub that exposes nothing, or a person allowed nothing, gets an empty list.
hub_kernel-interface holds both settings. Every call by name asks the host whether the person may take the action on the account, and runs the method inside the host's scope for that account. The host sets both once for the whole app:
HubKernel::Authz.check = ->(person, action, account) { Permissions.allow?(person, action, account) }
HubKernel::Context.scope = ->(account, &call) { Current.set(account: account, &call) }The action is named after the hub and the method, such as supplies:record_purchase. A
refused call raises HubKernel::NotAllowed before the method runs or its values are
checked, and a check that answers anything but true or false raises
HubKernel::NonBooleanAnswerError. A call made while either is unset raises
HubKernel::UnwiredPortError naming it.
A hub's own test can check its exposed list, so a listed method the hub lacks, or one listed with values it does not take, shows in the suite rather than on a caller's request:
require "hub_kernel/conformance/exposed"
class SuppliesHubTest < ActiveSupport::TestCase
include HubKernel::Conformance::Exposed
hub { Supplies }
endThe test fails and names each problem. A method that takes ** values accepts any
values it is listed with, as long as it is listed with each value it requires.
A host's test can check that no hub's code names a class another hub owns. The host passes a map of which classes each hub owns, and the test fails listing each file and the class it names:
require "hub_kernel/conformance/crossings"
class HubCrossingsTest < ActiveSupport::TestCase
include HubKernel::Conformance::Crossings
crossings files: "app/{models,controllers,jobs,views}/shop/**/*.{rb,erb}", except: [ "app/models/shop/hubs.rb" ] do
HubKernel::Crossings.new(
owners: { supplies: %w[Shop::Purchase Shop::PurchasesController], finance: %w[Shop::Expense], shop: %w[Shop::Home] },
shared: %w[Shop::BaseController],
host_layer: :shop,
interfaces: { supplies: "Shop::Hubs::Supplies", finance: "Shop::Hubs::Finance" },
namespaces: { billing: %w[Billing] }
)
end
end- A file under a hub's folder,
hubs/<hub>/, belongs to that hub, and so does each class defined there, without any entry inowners.ownersis only asked about files and classes outside every hub folder, and may be left out once every hub has its folder. - By default the test reads every
.rband.erbfile underapp/and under each hub folder'sapp/, helpers and services included. - A constant inside another hub's class counts as naming that class.
- A name no hub owns, such as a namespace, is not a crossing, and neither is a class on
the
sharedlist. namespacesgives a hub every class under a namespace, such as a hub gem'sBilling, so its classes need not be listed one by one. A class listed by name inownersbelongs to its listed hub even under another hub's namespace.- A view belongs to the hub that owns its controller, then to the hub that owns the
record its folder is named after, and otherwise to the
host_layer. - The host's layer may name any hub's interface module, and a hub naming another hub's
interface module is a crossing. A hub's interface module is found in its folder, as the
module there that extends
HubKernel::PortsorHubKernel::Exposes, so a hub in a folder needs no entry ininterfaces. A folder may hold several such modules, such as the modules of hubs merged into one area, and every one of them counts. - The test fails with "The crossing check found no files to read" when none of the files
it read belongs to a hub or the host's layer, so a wrong
files:pattern or an empty map does not pass unnoticed. - It also fails naming each class defined in the files it reads that no hub owns, such as "No hub owns Shop::Refund", and each class the map gives to two hubs, such as "Two hubs own Shop::Receipt".
- It fails naming each class the map names that no longer exists in the app, such as "The map names Shop::Ghost, which does not exist", so a renamed or removed class does not stay in the map. A class whose file sits in a hub folder is never reported this way.
- While hubs move into folders, a class the map gives one hub whose file sits in another hub's folder is owned by two hubs. A class the map still lists whose file already sits in that hub's folder fails with "Shop::Purchase sits in its hub's folder, so take it off the list".
- It fails naming each folder under
hubs/that holds no module extendingHubKernel::PortsorHubKernel::Exposes, such as "hubs/suplies holds no hub module", so a misspelled or half-made hub folder does not pass unnoticed.
A host's test can check that every link a hub's model makes is an association, so a new link to another hub's record cannot be added as a bare id column:
require "hub_kernel/conformance/unlinked_ids"
class UnlinkedIdsTest < ActiveSupport::TestCase
include HubKernel::Conformance::UnlinkedIds
unlinked_ids except: [ "Billing::Invoice#legacy_id" ]
end- The test reads every Active Record model under each hub folder's
app/models, and fails naming each column ending in_idthat nobelongs_toon that model uses as its foreign key, such as "Kitchen::Dish#oven_id has no association using it". - A
belongs_to_portlink counts, and so does an ordinarybelongs_toinside a hub. exceptlists columns the test does not report, each written as the model and the column joined by#. Theunlinked_idsline may be left out when there are none.- Abstract classes and models whose table does not exist are skipped.
- The test fails with "The unlinked id check found no model with a table in any hub folder" when it finds none, so a wrong setup does not pass unnoticed.
Add this line to your application's Gemfile:
gem "hub_kernel"And then execute:
$ bundleOr install it yourself as:
$ gem install hub_kernelContribution directions go here.
The gem is available as open source under the terms of the MIT License.