Using 'Packs' (a.k.a Packwerk) as code organization / Engine alternative' - Starting point and recipes

23rd June 2024 – 2231 words

Packs is a code organization technique introduced by Shopify and Gusto. It’s a “specification” for organizing (lots of) code in a Rails application. In short, you can imagine that you split your Majestic Monolith internally into multiple local “microservices” that are still part of the same Rails application. Each “pack” is a separate directory and partially mimics the Rails application structure (app/models, app/controllers, etc.). The idea is to have a clear separation of concerns and to make it easier to work on a specific part of the application without having to deal with the whole monolith when working on a specific feature.

We at pludoni have been using that in two of our projects for about 6 months now, and we are quite happy with the results. Also, I recently gave a short intro presentation on “Packs” at the Ruby User Group Frankfurt which I want to recap here in this post.

Code structuring by feature

By default, in Rails applications, code is structured by type of code (models, controllers, views, jobs, mailers, and the infamous “services”). The mantra Convention over Configuration leads to a structure that is easy to understand and navigate. But as the application grows, it becomes harder to find the code you are looking for. Especially, if you start adding namespaces (modules) to bundle related code together, you end up with a structure that is hard to navigate and jump between related classes.

find . | grep billing
app/models/billing/invoice.rb
app/models/billing/payment.rb
app/controllers/billing/invoices_controller.rb
app/views/billing/invoices/index.html.erb
app/views/billing/invoices/...
app/jobs/billing/invoice_remind_unpaid_job.rb
#...
app/javascript/billing/index.js
#...
spec/models/billing/invoice_spec.rb
spec/requests/billing/invoice_flow_spec.rb
#...

Imagine, your app has a “Billing” concern, that includes models, controllers and jobs, and maybe a significant section of your config/routes.rb. All these files are spread across the application foler, and you have to jump between directories to work on a feature. If your app grows, you might have hundreds of files in app/models. Especially, when your app code grows more horizontally - that is, you add more and more relatively independent features, such as: Billing, Auth, statistics/analytics, onboarding, import/export, API, Admin-UI, etc - then might be a good time to start looking into Packs or similar feature-based code organization.

Why Packs? What’s wrong with Engines?

Engines (also named Rails plugins) are a way to share reusable code across multiple Rails applications. They are always a Gem and facilitate the sharing of code, views, controllers, etc. between multiple Rails applications. They are a great way to share code between multiple applications, but they have some downsides when it comes to code organization within a single application:

  • Engines are isolated: They are isolated from the main application, which means that you can’t access the main application’s code from the engine (easily). Also, referencing the styling (layout) of the “Main-App” can be tricky. Also, accessing global objects, such as Current-User is more difficult than necessary.
  • Increased complexity: By favoring reusability, Engines introduce complexity that we don’t need in a monolith, such as: distribution as a Gem, “Dummy App” management; needs to be familiar with the variants: “–mountable”, and isolate_namespace.
  • Added boilerplate: Engines are Gems, so you need: Railtie, Gemfile, Gemfile.lock, gemspec, and maybe Initializers. On the monolith side, you need the Gem declaration or explicit mounting in the routes.

Thus, let me introduce you to Packs, which get rid of the downsides for single-use integrated mini-apps

Getting started

In a bog-standard Rails application, just add the packs-rails Gem to your Gemfile:

bundle add packs-rails 

# create package.yml in root of app
bundle binstub packwerk
bin/packwerk init

Packs-Rails is also “Convention-Over-Configuration”, and assume your feature-Packs live under ./packs/. I suggest, you keep it that way. packs also ships with a bare-bones generator that can create a base pack skeleton for you:

bin/packs create packs/billing
# you can create more base folder
mkdir -p packs/billing/app/models/billing
mkdir -p packs/billing/app/controllers/billing
mkdir -p packs/billing/app/views/billing
mkdir -p packs/billing/app/jobs/billing
mkdir -p packs/billing/spec/models/billing
mkdir -p packs/billing/spec/requests/billing
# ...

After restarting your Rails app, autoloading should already work, that means, it doesn’t matter, if you put a file under app/models/billing/invoice.rb or packs/billing/app/models/billing/invoice.rb. As you notice, there is no automatic module namespacing, like you might be familiar with in Engines. I welcome this, as it makes it easier to understand and navigate.

That should be enough to get started! After you get more familiar with Packs, you can try to move more code into Packs. In the following sections, I document some ways, to integrate more type of code into a Pack.

Recipes

This sections contains some recipes, how to integrate parts, that are not always needed and depend on your stack.

AutoAnnotateModels

We use annotate-models to put a schema comment at the top of each model file. You need to tell annotate about those paths:

# lib/tasks/auto_annotate_models.rake

if Rails.env.development?
  require 'annotate'
  task :set_annotation_options do
    # You can override any of these by setting an environment variable of the
    # same name.
    Annotate.set_defaults(
      'model_dir' => (Dir['packs/*/app/models'] + ["app/models"]).join(','),
      #...

Routes

Each Pack can have its own routes file. Starting from Rails 6.1, you can include routes that match the convention config/routes/ROUTE_NAME.rb with draw(:ROUTE_NAME) in your main routes file.

# config/routes.rb
Rails.application.routes.draw do
  draw(:billing)
  # ...
end

In your Pack, you can define routes as usual:

# packs/billing/config/routes/billing.rb
namespace :billing do
  resources :invoices
end

Needs server restart.

Vite / TypeScript

We like to move some of related frontend code into the Pack as well. For example, we use Vite as a frontend build tool and TypeScript as a language. We can tell both to look into the packs:

// vite.config.ts

export default defineConfig({
  resolve: {
    alias: {
      '@billing': path.resolve(__dirname, 'packs/billing/app/javascript'),
    },
  },
  // ...
});

And Typescript:

# tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@billing/*": ["packs/billing/app/javascript/*"]
    }
  }
}

GraphQL frontend queries

In one app, we use GraphQL for a lot of Frontend queries. We use graphqlcodegen and graphql-request to compile the queries ahead as a simple Javascript function call.

overwrite: true
schema: "app/graphql/schema.json"
documents:
  - "app/javascript/graphql/queries/**/*.graphql"
  - "packs/*/app/graphql/queries/**/*.graphql"
generates:
  app/javascript/graphql_queries/requests.ts:
    config:
      avoidOptionals: true
      immutableTypes: true
      constEnums: true
      disableDescriptions: true

    plugins:
      - "typescript"
      - typescript-operations
      - typescript-graphql-request

Phlex Components

We dabble with Phlex for some of our Frontend (Non-JS) components. We can tell Phlex to look into the Packs as well:

# config/application.rb

module MyApp
  class Application < Rails::Application
    config.autoload_paths << "#{root}/app/views/components"
    Dir['packs/*/app/views/components'].each do |path|
      config.autoload_paths << path
    end
    #...

Rspec/Test-Unit

Packs-Rails has documentation, on how to integrate your test suite and make just running the tests in a Pack work. I suggest you read the official documentation.

For Rspec, just add this line to .rspec

--require packs/rails/rspec

Parallel Testing using test-boosters

We use test_boosters to parallelize our test suite. You need to explicitly tell test-boosters to also run your Pack tests, with Rspec:

export TEST_BOOSTERS_RSPEC_TEST_FILE_PATTERN="{packs,spec}/**/*_spec.rb"
rspec_booster --job $CI_NODE_INDEX/$CI_NODE_TOTAL

I18n-Tasks

We use i18n-tasks to manage our translation files. Using the write: configs + i18n-tasks normalize -p command, you can define the deterministic file for each translation key based on it’s prefix.

So, for each pack we define custom translations like this:

# Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom:
# `i18n-tasks normalize -p` will force move the keys according to these rules
write:
  - ['{billing, models.billing_jobboard, components.billing }.*', 'packs/billing/config/locales/%{locale}.yml']
  - ['simple_form.*.billing_*.*', 'packs/billing/config/locales/%{locale}.yml']
  - config/locales/%{locale}.yml

Generator

To make creating new packs easier, I strongly suggest, you create a generator based on your App’s needs. Here is a starting point:

rails generate generator pack

vim lib/generators/pack/pack_generator.rb

Here is our current generator:

# lib/generators/pack/pack_generator.rb

class PackGenerator < Rails::Generators::NamedBase
  source_root File.expand_path("templates", __dir__)

  def create_pack_subdir
    pack_dir = "./packs/#{file_name}"

    empty_directory pack_dir
    %w[app/models app/controllers app/views app/jobs app/mailers app/jobs].each do |dir|
      empty_directory "#{pack_dir}/#{dir}/#{file_name}"
    end
    empty_directory "#{pack_dir}/app/javascript"
    empty_directory "#{pack_dir}/config/locales"

    empty_directory "#{pack_dir}/config/routes"
    route_file = <<~RUBY
      # use your routes here:
      # namespace :#{file_name} do
      #  resources :#{file_name}
      # end
    RUBY
    create_file "#{pack_dir}/config/routes/#{file_name}.rb", route_file

    route = "  draw(:#{file_name})\n"
    insert_into_file "config/routes.rb", route, after: "Rails.application.routes.draw do\n"


    create_file "#{pack_dir}/config/environment.rb", <<~RUBY
      # this file is only for vim-rails to pick it up as a Rails environment
    RUBY

    create_file "#{pack_dir}/package.yml", <<~YAML
      enforce_dependencies: true
      # Turn on privacy checks for this package
      # enforcing privacy is often not useful for the root package, because it would require defining a public interface
      # for something that should only be a thin wrapper in the first place.
      # We recommend enabling this for any new packages you create to aid with encapsulation.
      enforce_privacy: false
      # By default the public path will be app/public/, however this may not suit all applications' architecture so
      # this allows you to modify what your package's public path is.
      # public_path: app/public/
      dependencies:
      - "."
    YAML

    readme = <<~README
      # #{file_name}

      TODO: Write description of this pack
    README
    create_file "#{pack_dir}/README.md", readme

    empty_directory "#{pack_dir}/spec/models/#{file_name}"
    empty_directory "#{pack_dir}/spec/controllers/#{file_name}"
    empty_directory "#{pack_dir}/spec/controllers/#{file_name}"
    puts "Restart the server to setup load paths"
  end

TBD: migrations

We still put the migrations into db/migrate.

Conclusion

Packs-Rails is great, as you can start easily, and just make your new feature in the Pack structure, but still keep your main app intact. For our main apps, we strive to make every new feature into a new Pack, and we have begun to extract related classes into a pack. At the moment, we are at about 10 Packs in one of our apps. The best feature is, with each Pack you get the “New App Smell”! ✨🤩✨ every single time. It’s easier to load a feature into your head and not be overwhelmed with hundreds of files. Also, you can run all related tests in one command.

If you have additional recipes or questions, feel free to reach out to me on Mastodon!

Follow up resources

If you’ve got some packs in place, feel free to check out packwerk by Shopify, which is a kind of linting tool, that checks if your packs respect the defined dependencies and privacy rules. We don’t use it yet, but it might be helpful if you have a larger team and/or many packs.