Friendly Attributes Pattern
I run RailsBilling, a paid gem for fast Stripe subscription integrations with Rails.
During development I manually create a lot of subscription plans. Here's what creating standard, pro and enterprise plans with monthly and yearly intervals looks like:
Billing::Plan::Factory.find_or_create_by!(
name: :standard,
interval: 1.month,
amount: 10
)
Billing::Plan::Factory.find_or_create_by!(
name: :pro,
interval: 1.month,
amount: 50
)
Billing::Plan::Factory.find_or_create_by!(
name: :enterprise,
interval: 1.month,
amount: 100
)
Billing::Plan::Factory.find_or_create_by!(
name: :standard,
interval: 1.year,
amount: 100
)
Billing::Plan::Factory.find_or_create_by!(
name: :pro,
interval: 1.year,
amount: 500
)
Billing::Plan::Factory.find_or_create_by!(
name: :enterprise,
interval: 1.year,
amount: 1000
)
This code works well and has some nice properties, like being idempotent both locally and in Stripe. But there are some issues:
- It feels bulky and eats more than half my laptop screen.
- My customers usually start by creating plans, and kicking off with this doesn't exactly feel premium.
- After typing, copying, and tweaking this dozens of times a day, day after day, it began to wear me down.
One day I snapped and decided this is the new way it should be done:
Billing::Plan.find_or_create_all_by_attrs!(
1.month => {standard: 10, pro: 50, enterprise: 100},
1.year => {standard: 100, pro: 500, enterprise: 1000}
)
This snippet does exactly the same thing as the previous example.
The new attribute schema is easier to type, easier to read, and uses far fewer lines of code. All attribute keys like :name, :interval, and :amount are considered redundant and were removed.
I'm calling this Friendly Attributes Pattern.
The new attributes also clearly model a mental image of a standard pricing page:
- Interval toggle at the top.
- Plan columns contain names, followed by prices.
Here's a screenshot of a standard pricing page for reference.
This post uses real, tested examples from RailsBilling. That said, the pattern isn't limited to billing subscriptions domain, and I share another example toward the end.
Use cases
After I got my Friendly Attributes example to work, new use cases popped up immediately.
I use it in tests to fetch existing plans:
billing_plans(1.month => [:standard, :pro, :enterprise])
I use it with minitest assertions, and although I'm an RSpec user, I must concede this reads really nice:
assert_billing_pricing_plans 1.month => [:standard, :pro, :enterprise]
Fetching a single plan in Rails console:
Billing::Plan.find_sole_by_attrs(:pro, 1.month)
Notice that the order of :pro and 1.month args follows the way it's said out loud: "pro monthly plan". It simply sounds more natural than "monthly pro plan".
However, I learned that in French they flip the order of "pro" and "monthly", and say "Mensuel Pro". Luckily, Friendly Attributes allows passing args in any order, so now I pride myself for knowing to code in French:
Billing::Plan.find_sole_by_attrs(1.month, :pro) # très bien 👌
And lastly, you can use just a single attribute. Here's the example test helper:
billing_plan :pro
It may seem unusual to put a standalone value :pro in the same bucket as an elaborate hash 1.month => {standard: 10, pro: 50, enterprise: 100}, and claim it's the same thing, same pattern. The next section clarifies this, and explains how it all works under the hood.
Implementation
Conversion
Friendly Attributes' job is to convert various structures (arrays, hashes, values) into standard, key-value attributes.
You pass it an input [:pro, 50, 1.month], and you get standard attrs on the output: {name: :pro, amount: 50, interval: 1.month}. This is all it does.
This output can conveniently be passed to various finder, query, or factory methods. But what you do with the output is a separate concern, and outside the scope of Friendly Attributes as a concept.
Here's the example interface:
FriendlyAttrs.new(attrs).resolve # => standard_attrs
Types
The main idea behind Friendly Attributes is to use types to convert standalone values into regular attributes.
Each domain has its own rules. Here are the ones used for plan attributes from this post:
- Integers are amounts
50converts to{amount: 50}- Symbols or strings are plan names
:proconverts to{name: :pro}- ActiveSupport::Duration objects are intervals
1.monthconverts to{interval: 1.month}
Value lookup
You can go a step further and parse standalone strings or symbols for further distinction. For example, :usd can be recognized as a currency and converted to {currency: :usd}.
For my specific case, I decided not to use this. In 99% cases currency is configured globally and does not need to be specified.
So the value :usd resolves to {name: :usd} and becomes a plan name.
Mixing
You can mix Friendly Attributes with standard attributes.
[1.month, {name: :pro, amount: 50}]
This resolves to {interval: 1.month, name: :pro, amount: 50}.
For this to work you have to keep a list of known attribute names so it's clear that :amount is an attribute key, not a symbol representing plan name.
Superset
Friendly Attributes are a superset of standard attributes.
This hash {interval: 1.month, name: :pro, amount: 50} is a valid input, and it just returns the same value on the output.
This property keeps everything backward compatible. If Friendly Attributes approach doesn't click with you, you can still use all helper methods with standard attributes.
Object tree
At one point I had this code working:
[1.month, :standard, 10],
[1.month, :pro, 50],
[1.month, :enterprise, 100]
You see how 1.month keeps repeating? In order to reduce repetition I decided to use an object tree. The above set of arrays can now be written like this:
{1.month => {standard: 10, pro: 50, enterprise: 100}}
Here's a visualization of this hash as a tree:
10 50 100 # leaves
| | |
| | |
:standard :pro :enterprise
| | |
`---------+---------/
|
1.month # root
The key to understanding how the hash relates to the tree is to read the hash from left to right, and the tree from the bottom up.
The next steps are:
- Start from the leaf nodes.
- For each leaf, follow its path up to the root and collect the values into an array.
Here's the result of that operation:
[10, :standard, 1.month],
[50, :pro, 1.month],
[100, :enterprise, 1.month]
So, we're back to a set of arrays, but this is now an internal state. The arrays are easily converted to standard attributes using previous guidelines.
Flexibility
Friendly Attributes allow for very flexible inputs. Here's an example where each input line produces the same output:
{1.month => {pro: 50}}
{50 => {pro: 1.month}
[1.month, :pro, 50]
[:pro, {1.month => 50}]
The choice of which line you use comes down to readability. And as we've seen, what you find most readable depends on whether you "think" in English, French, or maybe Greek.
Additionally, you don't always have to specify all the attributes. For example, input :pro converts to {name: :pro}. This is not "just theoretical", I use it actively, and I'm delighted every time I get to use billing_plan :pro in tests.
Going too far
You can also do some silly things:
billing_plan 50
This fetches a plan with attrs {amount: 50}. It's a valid example, but it's unusual and not recommended.
Here's another working example that only Yoda would find readable:
{{name: :standard} => {1.month => 10}}
Is flexibility good or bad?
In Ruby you can write beautiful code, and convoluted
I believe the flexibility benefits outweigh the potential downsides. So let's follow Ruby's lead and aim to write elegant code.
Other use cases
Look, I genuinely like, and feel excited about this idea. But to be honest, I can't find many good examples that reap the benefits I'm describing here.
Friendly Attributes is a nice idea, but let's not force it into every app or model.
Example
This example is just a thought exercise. While all other snippets in this post are real, tested, and working, this one is purely conceptual.
I worked at an IoT company that builds smart door locks. The main part of their app handled who could access which door or floor, and when.
Here's how Friendly Attributes could be applied to this domain:
{
"alice@example.com": :entrance, # single door access
"bob@example.com": [1, 2], # integers are floors
"carol@example.com": :all, # Carol's the boss
"dave@example.com": [1, :mailroom] # Dave's at reception
"cleaning@company.com": {"9am-5pm": {[:mon, :tue, :wed, :thu, :fri] => :entrance}}
}
Here are the rough implementation guidelines:
- Strings that match email regexp become user records
"alice@example.com"converts to{user: User.find_or_create_by!(email: "alice@example.com")}- Strings that match interval regexp become intervals
"9am-5pm"converts to{start_time: 9, end_time: 17}- Integers perform a floor lookup
1converts to{access: account.floors.find_by!(number: 1)}- Symbol :all is a special case
:allconverts to{access: account.accesses.all}- Symbols representing days are also special cases
:monconverts to{day: 1}- Non-special symbols perform a door lookup
:entranceconverts to{door: account.doors.find_by!(name: :entrance)}
Use with JSON, YAML?
Building an API or storing data in a way that's based on Friendly Attributes Pattern is a bad idea.
Friendly Attributes are made primarily for humans. This pattern shines when you have to manually type in attributes, or you want to make a specific part of the code succinct and pretty.
The repetition of attributes and slight verbosity in popular data formats, like JSON or YAML is not a real problem for computers. If you really need something faster go for existing binary data formats like Protocol Buffers.
Conclusion
Friendly Attributes took me a couple hours to implement, and the results have been great! Hopefully this post gives you pointers and ideas if you encounter a similar problem in your work.
The idea is extracted from RailsBilling. Check it out, it's full of gems like this.
Friendly Attributes embodies the spirit of Ruby. It's about reading and writing joyful code - made for humans, typed by humans! If you ever get to use it, I hope you enjoy it as much as I do.
Happy hacking with Friendly Attributes!