Billing from outside it, grouped by consumer. Payments code and reporting
code reach disjoint halves, and no consumer reaches both: two packages sharing one name.
The problem
A namespace is a promise that its contents belong together. The common reuse principle says the test of that is its consumers: if you depend on one class in a package, you should need the rest of it too. Otherwise you’re taking on changes, reviews and releases for code you never call.
Billing started as one idea and grew into two:
app/billing/
gateway.rb Billing::Gateway charges and refunds
receipt.rb Billing::Receipt one per charge
card.rb Billing::Card tokenised cards
proration.rb Billing::Proration partial periods
tax_table.rb Billing::TaxTable rates by region
Checkout and Refunds use the first three. Reporting::Report uses the last two. Nobody uses
both halves. But every change to tax rates shows up in the payments team’s review queue, a packwerk
package boundary around Billing would force both sides through it, and extracting payments into a
gem means taking proration along for the ride. The split is real, and it’s only visible from the
callers’ side.
The fix
Ask how a namespace is actually consumed:
$ ra split_package Billing
Billing · 2 consumer groups · 0 consumers reach both
Checkout, Refunds → Gateway, Receipt, Card
Reporting::Report → Proration, TaxTable
split it in two along these lines CRP
Split it where its consumers already have:
app/billing/gateway.rb → app/billing/payments/gateway.rb Billing::Payments::Gateway
app/billing/receipt.rb → app/billing/payments/receipt.rb Billing::Payments::Receipt
app/billing/card.rb → app/billing/payments/card.rb Billing::Payments::Card
app/billing/proration.rb → app/billing/invoicing/proration.rb Billing::Invoicing::Proration
app/billing/tax_table.rb → app/billing/invoicing/tax_table.rb Billing::Invoicing::TaxTable
Now a consumer depends on exactly the half it uses, and a change to tax rates is invisible to checkout. With packwerk, each half becomes its own package with its own public API.
How it works
- Consumers, not callers. From every recorded call edge, it keeps the ones that cross into the
namespace from outside it. Calls within
Billingare its own business and don’t count. - Who uses what. Each outside namespace gets the set of classes it reached inside. That’s a map from consumers to the parts of the package they depend on.
- Find the seams. Consumers whose sets never overlap fall into separate groups, and each group’s
classes are a candidate package. It’s
split_interfaceone level up: roles for a namespace instead of a class.
Limits
- Shared cores show as overlap. If both halves use
Billing::Money, the groups aren’t disjoint. The report names the shared classes as a common core rather than forcing them into one side. - Names are yours. It finds the groups. What to call them, and where the files go, is a decision about your domain it can’t make.
- Only recorded consumers count. A consumer that never ran isn’t in the grouping, so check it against the code before you split.
Related tools
split_interface
Split a fat interface. A class whose callers each use a different slice of it. It names the role interfaces to split it into, measured from who actually calls what.
See more →stabilize_dep
Point dependencies at stability. A stable module, with many callers and few dependencies, reaching into a less stable one. Measured from real call traffic, so you can depend toward stability or put an abstraction between them.
See more →relocate_file
Which file should move where. Two files that lean on each other constantly but sit directories apart. The graph names the folder they belong in, weighed by real call traffic and the distance between them.
See more →