Chapter I - Modular Monolith with Packwerk
(avr. time for this chapter: 3 to 4 days)
Packwerk enforces boundaries inside a Rails monolith. The code is organized in packs, each one declaring which other packs it depends on and which constants it exposes publicly.
In this chapter, you will reorganize the entire ebook store into packs. Every domain must live in its own pack, including an analytics system and a new invoicing system.
Continue building upon the application from the previous chapters.
Reference: Packwerk - Usage
Reference: Shopify Engineering - Enforcing Modularity in Rails Apps with Packwerk
Exercise 1: Split the Application into Packs
Requirements
- Dependency and privacy checks are enforced in every pack
- Every part of the ebook store belongs to a pack; the root application contains only configuration, the layout, and shared infrastructure
- Each pack has its own specs and factories
- Dependencies flow in one direction, with no cycles
Suggested Packs
packs/users- users, sellers and buyers, profile, statuspacks/authentication- sign up, sign in, sign out, password expirationpacks/catalog- ebooks, status workflow, PDF previews, tags, and filterspacks/purchases- purchase flow, transactions, commissions, and balancepacks/notifications- mailers and notification jobspacks/analytics- all statistics and reportspacks/invoices- invoices and credit notespacks/payments- integration with the webhooks engine
graph TD
authentication --> users
catalog --> users
purchases --> catalog
purchases --> users
payments --> purchases
invoices --> purchases
analytics --> purchases
analytics --> catalog
notifications --> purchases
notifications --> invoices
Note: You may define different packs. The important part is that every boundary is intentional. Review your dependency graph with your tutor before moving code.
Exercise 2: Public APIs
Requirements
- Other packs only use what a pack exposes in
app/public - Public methods return simple data structures (e.g.,
Dataobjects), not Active Record models - No pack queries another pack's models directly (e.g.,
Ebook.findinsidepurchases) - Active Record associations across packs are minimal, and each one is justified
Example:
# packs/catalog/app/public/catalog.rb
module Catalog
def self.find_ebook(id) = ...
def self.live_ebooks_for(seller_id:) = ...
end
Exercise 3: Events Between Packs
After a purchase, analytics and invoices need to do their work, but purchases must not depend on them.
Requirements
- Packs publish domain events with
ActiveSupport::Notifications - Subscribers enqueue Sidekiq jobs instead of doing the work synchronously
- Event names and payloads are defined in the publisher's public API
- Events to publish:
purchase.completed,purchase.refunded,ebook.viewed,ebook.preview_downloaded,invoice.issued
Exercise 4: Analytics Pack
All statistics from the previous chapters (purchases, previews viewed, page visits, visitor information, daily statistics) move to the analytics pack. No other pack calculates statistics.
Requirements
- Statistics models, services, and jobs (e.g.,
DailyStatisticsJob) live in the pack - Analytics events are stored in the pack's own tables (event name, subject, visitor information, timestamp)
- Events are recorded by subscribing to
catalogandpurchasesevents, never called directly from their controllers - Daily metrics are aggregated in their own tables instead of being calculated on every request
- Public API, e.g.:
Analytics.ebook_stats(ebook_id)Analytics.seller_dashboard(seller_id, period:)Analytics.top_ebooks(period:, limit:)- Seller dashboard with sales, views, previews, and conversion rate
- The statistics emails use the analytics public API
Exercise 5: Invoices Pack
Every purchase generates an invoice. This is a new feature, built from the start as a pack.
Requirements
- An invoice is issued automatically for every completed purchase, in a Sidekiq job
- The same purchase never generates two invoices
- Sequential number per year with no gaps (e.g.,
2026/0001), even when two invoices are issued at the same time - Invoices store buyer and seller data, ebook title, price, VAT, and total at the moment of the purchase; later changes to the ebook or the users do not change existing invoices
- Invoices cannot be edited or deleted after being issued
- A refund (received through the webhooks engine) issues a credit note that references the original invoice
- PDF generated in a background job (e.g., Prawn or Grover)
- The
notificationspack emails the PDF to the buyer wheninvoice.issuedis published - Buyers can list and download their invoices; sellers can list the invoices of their sales
Note: A fixed VAT rate is enough for this exercise.
Exercise 6: Zero Violations
Requirements
- Existing violations are recorded in
package_todo.ymland fixed one by one - The chapter ends with zero recorded violations
- Dependencies are never added just to remove a violation; first ask whether the code is in the right pack
- Packwerk runs in CI and fails the build on new violations
- rubocop-packs enforces conventions inside packs
- The dependency graph is generated with visualize_packs and added to the README, with the responsibility of each pack
- End-to-end spec for the complete flow: purchase → invoice issued → analytics recorded → email sent
Checklist
Before finishing this chapter, ensure you can:
- [ ] Explain the difference between packs and engines, and when to use each
- [ ] Design a dependency graph without cycles
- [ ] Define and use a pack's public API
- [ ] Decouple packs with events
- [ ] Record and fix dependency and privacy violations
- [ ] Generate sequential, immutable invoices safely under concurrency
- [ ] Enforce pack boundaries in CI