Skip to content

Creating Packages

Organize your app into feature and portal packages.

Goal

Domain code (models, policies, definitions, interactions) lives in feature packages. Web interfaces (controllers, views, routes, auth) live in portal packages. Both are Rails engines with Plutonium conventions on top.

Two types

TypePurposeGeneratorExamples
FeatureBusiness logicpu:pkg:package NAMEblogging, billing, inventory
PortalWeb interfacepu:pkg:portal NAMEadmin_portal, customer_portal, public_portal

🚨 Don't mix the two. Feature packages have NO routes, views, or controllers. Portal packages have NO models or interactions.

Feature package

1. Generate

bash
rails g pu:pkg:package blogging

2. Structure

packages/blogging/
├── app/
│   ├── models/blogging/             # Blogging::Post
│   ├── definitions/blogging/        # Blogging::PostDefinition
│   ├── policies/blogging/           # Blogging::PostPolicy
│   └── interactions/blogging/       # Blogging::PublishPostInteraction
├── db/migrate/
└── lib/engine.rb

3. Create resources inside it

bash
rails g pu:res:scaffold Blogging::Post title:string --dest=blogging
rails db:migrate

4. Expose it via a portal

bash
rails g pu:res:conn Blogging::Post --dest=admin_portal

Portal package

1. Generate

bash
rails g pu:pkg:portal admin --auth=user

Options:

  • --auth=NAME — Rodauth account to authenticate with.
  • --public — public access, no auth.
  • --byo — bring your own auth.
  • --scope=CLASS — entity class for multi-tenancy.

2. Mount it

ruby
# config/routes.rb
Rails.application.routes.draw do
  constraints Rodauth::Rails.authenticate(:user) do
    mount AdminPortal::Engine, at: "/admin"
  end
end

3. Connect resources

bash
rails g pu:res:conn Post Blogging::Post --dest=admin_portal

See Reference › App › Portals for the full portal surface.

Auto-namespacing

Every file under app/<kind>/blogging/ resolves to Blogging::*:

  • app/models/blogging/post.rbBlogging::Post
  • app/policies/blogging/post_policy.rbBlogging::PostPolicy

Each feature package gets base classes — Blogging::ApplicationRecord, Blogging::ResourceRecord, Blogging::ResourcePolicy, Blogging::ResourceDefinition, Blogging::ResourceInteraction — that inherit from the main app's.

Cross-package references

bash
rails g pu:res:scaffold Comment user:belongs_to blogging/post:belongs_to body:text --dest=comments

The blogging/post syntax expands to Blogging::Post.

When to use which

Feature package

When the code:

  • Could be reused across multiple portals (admin and customer both edit Blogging::Post).
  • Has no inherent UI / auth.
  • You want isolated from other domains (billing shouldn't depend on blogging).

Portal package

When the code:

  • Has a specific auth flow (admin vs customer vs public).
  • Renders different views of the same underlying resources.
  • Needs different policies / definitions per audience.

When NOT to make a package

For an app that doesn't need cross-portal sharing, just put resources in --dest=main_app. Packages add organization, not power.

Typical architecture

packages/
├── blogging/                # Feature: blog functionality
├── billing/                 # Feature: payments/invoicing
├── admin_portal/            # Portal: admin interface
└── customer_portal/         # Portal: customer dashboard

The portals expose the features. A single feature can be exposed by multiple portals — usually with different policies and definitions per portal.

Package loading

Generated by pu:core:install:

ruby
# config/packages.rb
Dir.glob(File.expand_path("../packages/**/lib/engine.rb", __dir__)) do |package|
  load package
end

Loaded from config/application.rb. Migrations from all packages are picked up by rails db:migrate automatically.

Per-portal overrides

ruby
# Definition — different fields per portal
class AdminPortal::PostDefinition < ::PostDefinition
  input :internal_notes, as: :text     # admins see this; customers don't
  scope :pending_review
end

# Policy — different rules per portal
class AdminPortal::PostPolicy < ::PostPolicy
  include AdminPortal::ResourcePolicy

  def destroy? = true
  def permitted_attributes_for_create = %i[title content featured internal_notes]
end

Common issues

  • Class not loading — namespace must match the directory: app/models/blogging/post.rb MUST be Blogging::Post.
  • Migration not running — package migrations are auto-included. If they aren't running, check config/packages.rb is loaded from application.rb.
  • Cross-package association fails — use blogging/post:belongs_to in pu:res:scaffold, OR manually set class_name: "Blogging::Post" on the belongs_to.

Released under the MIT License.