Project

hub_kernel

0.0
The project is in a healthy, maintained state
hub_kernel wraps a domain and connects it to a host app's infrastructure through named ports. It ships no domain of its own: each hub is hub_kernel wrapped around one domain.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

HubKernel

Gives every hub in a Rails app one way to declare what it needs from another hub.

Usage

Starting a 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_entry

Inside 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 folder in the host app

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.

Using a hub from a gem

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!
end

The 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 }
end

Run inside an app rather than a gem, the generator writes the hub under app/models and its hub check under test/models.

Ports

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
end

The 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)
end

Supplies.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".

Record ports

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
end

The app fills it with the model, in the same setup as its other ports:

Rails.application.config.to_prepare do
  Fulfillment.job_record = Job
end

Fulfillment.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.

Links through a record port

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
end

hub_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::ReadOnlyRecord when it is saved or destroyed.
  • build_job, create_job and create_job! raise ActiveRecord::ReadOnlyRecord, and so does assigning an unsaved record to job.
  • The records a scope loads are read-only too.
  • autosave, dependent, touch and counter_cache each write to the linked record, so passing any of them raises an ArgumentError naming 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.

Has-many links through a record port

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
end

hub_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::ReadOnlyRecord when it is saved or destroyed.
  • Adding a record to the list with <<, push, append, concat or replace, removing one with delete, destroy, delete_all, destroy_all or clear, and building or creating one with build, new, create or create! raise ActiveRecord::ReadOnlyRecord, on the list and on a query made from it.
  • Assigning the list with quotes= or quote_ids= raises ActiveRecord::ReadOnlyRecord.
  • Writing the listed records all at once with update_all, update_counters, insert, insert!, insert_all, insert_all!, upsert or upsert_all raises ActiveRecord::ReadOnlyRecord.
  • autosave, counter_cache and a dependent value that destroys, deletes or nullifies the listed records each write to them, so passing any of them raises an ArgumentError naming 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.

The start check

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!
end

It 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.

The hub check

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 }
end

The 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

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")     # => nil

Calling an exposed method by name

An 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.

Listing what a person may call

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.

The host's permission check and account scope

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.

The exposed-list check

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 }
end

The 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.

The crossing check

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 in owners. owners is 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 .rb and .erb file under app/ and under each hub folder's app/, 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 shared list.
  • namespaces gives a hub every class under a namespace, such as a hub gem's Billing, so its classes need not be listed one by one. A class listed by name in owners belongs 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::Ports or HubKernel::Exposes, so a hub in a folder needs no entry in interfaces. 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 extending HubKernel::Ports or HubKernel::Exposes, such as "hubs/suplies holds no hub module", so a misspelled or half-made hub folder does not pass unnoticed.

The unlinked id check

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 _id that no belongs_to on that model uses as its foreign key, such as "Kitchen::Dish#oven_id has no association using it".
  • A belongs_to_port link counts, and so does an ordinary belongs_to inside a hub.
  • except lists columns the test does not report, each written as the model and the column joined by #. The unlinked_ids line 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.

Installation

Add this line to your application's Gemfile:

gem "hub_kernel"

And then execute:

$ bundle

Or install it yourself as:

$ gem install hub_kernel

Contributing

Contribution directions go here.

License

The gem is available as open source under the terms of the MIT License.