If you have been developing Rails applications for years, there’s a good chance you’ve used:
bin/rails credentials:edit
hundreds of times.
You probably know that Rails stores encrypted credentials in:
config/credentials.yml.enc
and keeps the encryption key separately in:
config/master.key
But did you know that Rails can make:
git diff
show the decrypted, human-readable changes to credentials.yml.enc?
I recently discovered this while working on a Rails 8.1.3.1 application and it was one of those:
“I’ve been using Rails every day for years, and I didn’t know Rails could do this!”
moments.
Let’s see how it works.
First: What is credentials.yml.enc?
Rails encrypted credentials allow us to keep secrets such as:
openai:
api_key: ...
or:
aws:
access_key_id: ...
secret_access_key: ...
inside:
config/credentials.yml.enc
The file is encrypted.
The encryption key is stored separately in:
config/master.key
Rails documentation explicitly states that the encrypted credentials file can be stored in version control as long as the master key remains secure. (Ruby on Rails Guides)
So our repository can contain:
config/
├── credentials.yml.enc ← encrypted, safe to commit
└── master.key ← secret, NEVER commit
Editing Rails Credentials
Normally we edit credentials with:
bin/rails credentials:edit
Rails decrypts the credentials, opens them in your configured editor, and encrypts them again when you save.
Rails then ensures the Git diff driver is configured to use:
bin/rails credentials:diff
Rails’ application generator includes this credentials diff enrollment as part of application setup and Rails 7.0 already contained the credentials diffing implementation. (Gem)
So this isn’t actually an 8.1-only feature.
That’s an important distinction.
Is This New in Rails 8.1?
No – and this is an important correction.
The encrypted credentials diff functionality existed before Rails 8.1.
For example, Rails 7.0 already had the credentials:diff implementation, and Rails 7.2’s application generator also enrolled projects in credentials diffing. (Gem)
Rails has supported decrypted Git diffs for encrypted credentials for several versions and Rails 8.x continues to build on the credentials tooling.
Rails 8.1 does introduce other useful credentials functionality. For example, Rails 8.1 added command-line credential fetching, which can be useful for deployment tooling such as Kamal. (Ruby on Rails Guides)
The master key should not be committed. Rails’ security guide explicitly recommends keeping the master key safe and out of version control. (Ruby on Rails Guides)
One Thing to Remember
The decrypted content can appear in your local terminal output.
The difference is a great way to understand what’s really happening.
Quick Reference
# Edit credentials
bin/rails credentials:edit
# Enroll project in credential diffing
bin/rails credentials:diff --enroll
# Normal readable diff
git diff
# Show the actual encrypted file diff
git diff --no-textconv -- config/credentials.yml.enc
# Inspect Git's configuration
git config --show-origin --get-regexp 'diff|textconv|filter'
# Check Git attributes
git check-attr diff -- config/credentials.yml.enc
Security rule:
Y config/credentials.yml.enc → commit it
X config/master.key → NEVER commit it
Rails’ official security guide confirms that encrypted credentials can be stored in version control while the master key must remain protected. (Ruby on Rails Guides)
Database transactions are one of those Rails features that appear simple:
User.transaction do
# database operations
end
But at a senior Rails engineering level, transactions are much more than wrapping a few save! calls in a block.
They affect data consistency, concurrency, failure handling, callbacks, database locking, nested service objects, multiple databases and even how external systems should be triggered.
A strong Rails developer should understand not only how to start a transaction, but also:
which transaction API to use
how class-level and instance-level transactions differ
what actually gets rolled back
how nested transactions work
when requires_new is necessary
how savepoints work
how save and destroy already use transactions internally
how after_commit differs from after_save
how to handle database exceptions safely
how transactions interact with locks and isolation levels
what Rails transactions cannot protect
how to design transaction boundaries in service objects
This article approaches transactions from that perspective.
What Is a Database Transaction?
A transaction groups multiple database operations into a single unit of work.
Conceptually:
BEGIN
operation 1
operation 2
operation 3
COMMIT
If something fails:
BEGIN
operation 1
operation 2
operation 3 <-- failure
ROLLBACK
The important property is atomicity:
Either all database changes become permanent, or none of them do.
Rails uses the database transaction facilities provided by the underlying database connection. Active Record describes transactions as protective blocks where SQL statements become permanent only when the complete operation succeeds. (Ruby on Rails API)
A classic example is transferring money:
Account.transaction do
sender.withdraw!(100)
receiver.deposit!(100)
end
We don’t want this situation:
sender: -100
receiver: 0
If the deposit fails, the withdrawal must also disappear.
The Basic Rails Transaction Block
The most common syntax is:
ActiveRecord::Base.transaction do
user.save!
profile.save!
audit_log.save!
end
If any operation raises an exception, Rails rolls back the transaction. (Ruby on Rails API)
In a modern Rails application, however, I generally prefer:
ApplicationRecord.transaction do
user.save!
profile.save!
audit_log.save!
end
Why?
ApplicationRecord represents the application’s Active Record hierarchy and is generally a clearer boundary than directly referencing ActiveRecord::Base.
Why Do We Need Transactions?
Imagine an order creation workflow:
order = Order.create!
payment = Payment.create!
order.update!(status: "paid")
InventoryItem.create!(...)
Without a transaction, failure halfway through could leave:
Order ✅
Payment ✅
Order paid ✅
Inventory ❌
The application is now inconsistent.
Instead:
ApplicationRecord.transaction do
order = Order.create!
payment = Payment.create!
order.update!(status: "paid")
InventoryItem.create!
end
Now the desired guarantee is:
Everything succeeds -> COMMIT
Anything fails -> ROLLBACK
Class-Level Transaction Methods
The class-level form is the most common Rails API.
User.transaction do
user = User.create!
profile = Profile.create!(user: user)
end
You can also use:
ApplicationRecord.transaction do
user.update!
profile.update!
end
An important senior-level detail is that the class calling .transaction doesn’t restrict which models can participate.
For example:
Account.transaction do
account.update!(balance: 900)
TransactionLog.create!(
account: account,
amount: 100
)
end
TransactionLog participates in the same transaction.
Why?
Because a Rails transaction is fundamentally associated with a database connection, not with a particular model class. (Ruby on Rails API)
This is an important distinction:
Transaction
↓
Database connection
↓
SQL statements
↓
Multiple ActiveRecord models
Not:
Transaction
↓
One ActiveRecord model only
Instance-Level Transactions
Rails also supports transactions on model instances.
For example:
user.transactiondo
user.update!(name:"Abhilash")
Profile.create!(user:user)
end
This might look like a different mechanism from:
User.transactiondo
user.update!(name:"Abhilash")
Profile.create!(user:user)
end
For normal Active Record usage, they operate against the same underlying database connection.
Rails explicitly provides transaction as both a class-level and model-instance API. (Ruby on Rails API)
When is instance-level useful?
It can communicate intent:
order.transactiondo
order.update!(status:"processing")
order.create_payment!
end
This reads naturally as:
Perform these operations as one transaction around this order.
However, I would usually use the class-level transaction in service objects, because the service boundary is about a unit of work rather than a specific model.
Service Object Transaction Boundary
This is usually my preferred architecture for complex workflows.
class CheckoutService
def call(user:, cart:)
Order.transaction do
order = create_order(user, cart)
charge_payment(order)
reserve_inventory(order)
order.update!(status: "confirmed")
order
end
end
private
def create_order(user, cart)
Order.create!(
user: user,
total: cart.total
)
end
def charge_payment(order)
Payment.create!(
order: order,
amount: order.total
)
end
def reserve_inventory(order)
# ...
end
end
This gives the workflow one explicit transaction boundary.
That is much easier to reason about than having every individual method start its own transaction.
save and destroy Are Already Transactional
This is an area that surprises many developers.
Rails automatically wraps save and destroy operations in transactions. This ensures validations and callbacks execute under transactional protection. (Ruby on Rails API)
For example:
user.save!
is already transaction-protected at the database-operation level.
But that doesn’t mean you don’t need explicit transactions.
Consider:
user.save!
profile.save!
Each operation is protected individually.
That does not give you:
user + profile = atomic unit
You need:
User.transactiondo
user.save!
profile.save!
end
So the distinction is:
save!
↓
protect this persistence operation
transaction do
↓
protect this entire workflow
Transaction and Exceptions
The normal rollback mechanism is an exception.
User.transactiondo
user.save!
profile.save!
raise"Something failed"
end
The transaction rolls back.
Rails then propagates the exception to the caller.
That means this pattern is common:
begin
User.transactiondo
create_user!
create_profile!
end
rescueStandardError=>e
Rails.logger.error(e.message)
raise
end
The important design principle is:
Don’t use transactions as a replacement for error handling.
A transaction determines what happens to database state.
Your application code still needs to determine what happens to the failure.
ActiveRecord::Rollback
Rails provides a special exception:
ActiveRecord::Rollback
Example:
User.transactiondo
user.update!(status:"processing")
unlesspayment_valid?
raiseActiveRecord::Rollback
end
user.update!(status:"confirmed")
end
The transaction rolls back, but ActiveRecord::Rollback is specifically handled by Rails and isn’t propagated like a normal exception. (Ruby on Rails API)
Therefore, don’t blindly replace business exceptions with ActiveRecord::Rollback.
Nested Transactions
Consider:
ApplicationRecord.transactiondo
user.save!
ApplicationRecord.transactiondo
profile.save!
end
end
You might assume there are two independent transactions:
BEGIN
user
BEGIN
profile
COMMIT
COMMIT
That’s not generally how Rails works.
By default, a nested transaction joins the existing transaction. Most databases don’t provide true nested transactions, so Rails emulates subtransactions with savepoints where necessary. (Ruby on Rails API)
Conceptually:
BEGIN
user
profile
COMMIT
The Nested Rollback Surprise
Consider this:
User.transactiondo
User.create!(name:"A")
User.transactiondo
User.create!(name:"B")
raiseActiveRecord::Rollback
end
end
Many developers expect:
A -> committed
B -> rolled back
But because the nested transaction joins the parent transaction, the rollback exception is handled by the inner transaction boundary and the outer transaction can still commit.
The result can be:
A -> committed
B -> committed
This behavior is documented by Rails and is one of the most important transaction gotchas to understand. (Ruby on Rails API)
requires_new: true
When you need an actual nested transactional boundary, use:
requires_new:true
Example:
ApplicationRecord.transactiondo
user=User.create!
ApplicationRecord.transaction(requires_new:true) do
AuditLog.create!
raiseActiveRecord::Rollback
end
end
Now the inner transaction gets its own savepoint.
Conceptually:
BEGIN
User
SAVEPOINT
AuditLog
ROLLBACK TO SAVEPOINT
COMMIT
Result:
User ✅
AuditLog ❌
Rails uses database save points to emulate nested transactions on databases that do not support true nested transactions.
When Should You Use requires_new?
It is especially useful when you have an inner operation that should be isolated from the outer workflow.
For example:
Order.transactiondo
create_order!
Order.transaction(requires_new:true) do
create_optional_audit_record!
end
finalize_order!
end
The inner operation can fail without necessarily destroying the outer work.
This is particularly useful in reusable service objects.
Suppose:
classAuditService
defself.record!(event)
AuditLog.transaction(requires_new:true) do
AuditLog.create!(event:event)
end
end
end
That service can be called either:
AuditService.record!("user_created")
or inside another transaction:
User.transactiondo
user.save!
AuditService.record!("user_created")
end
The requires_new boundary gives the service an explicit savepoint when called inside an existing transaction.
Transaction Isolation Levels
Transactions don’t only provide atomicity.
They also influence how concurrent transactions can see and modify data.
Rails allows:
Account.transaction(isolation::serializable) do
# critical operation
end
Supported isolation levels include:
:read_uncommitted
:read_committed
:repeatable_read
:serializable
Support and semantics depend on the database adapter. Rails documents these options and notes that isolation cannot generally be changed while joining an existing transaction or creating a nested savepoint transaction.
For example:
Order.transaction(isolation::serializable) do
order=Order.find(order_id)
order.update!(
status:"confirmed"
)
end
This can be appropriate for highly concurrent business operations, but it isn’t something I would enable casually.
Higher isolation can increase contention and retry requirements.
A senior engineer should ask:
What consistency guarantee does this business operation actually require?
rather than:
Which isolation level sounds safest?
Transactions + Row Locking
Transactions become particularly powerful when combined with pessimistic locking.
Suppose two requests attempt to update the same account simultaneously.
account.with_lockdo
account.update!(
balance:account.balance-100
)
end
with_lock is a useful Rails shortcut: it starts a transaction, reloads the record using a row lock, and then executes the block. Rails also allows transaction options such as requires_new, isolation, and joinable to be passed to with_lock. (Ruby on Rails API)
The important point is that transaction + locking solves a different class of problem from transaction alone.
A transaction provides atomicity.
A lock helps control concurrent access.
with_lock vs transaction
Compare:
account.transactiondo
account.update!(balance:...)
end
with:
account.with_lockdo
account.update!(balance:...)
end
The first provides transactional atomicity.
The second provides:
transaction
+
record reload
+
row-level locking
Use with_lock when concurrency around a particular row is part of the problem.
after_save vs after_commit
This distinction becomes extremely important when transactions are involved.
Consider:
classOrder<ApplicationRecord
after_save:publish_order
defpublish_order
EventBus.publish(id)
end
end
Suppose:
Order.transactiondo
order.update!(status:"paid")
raise"Something failed"
end
The after_save callback can run while the transaction is still in progress.
The database transaction can subsequently roll back.
Now your external system might have received:
Order paid
while your database says:
Order unpaid
That’s dangerous.
Use after_commit for External Side Effects
Rails provides:
classOrder<ApplicationRecord
after_commit:publish_order
private
defpublish_order
EventBus.publish(id)
end
end
Now the external operation happens only after the database transaction has successfully committed.
Rails explicitly recommends transaction callbacks such as after_commit when interacting with systems outside the database transaction. (Ruby on Rails Guides)
For narrower cases:
after_create_commit:publish_order
after_update_commit:publish_order
after_destroy_commit:remove_from_search
These are convenient aliases provided by Rails.
Per-Transaction Callbacks
Modern Rails also allows callbacks to be registered directly against a transaction.
For example:
Order.transactiondo |transaction|
order.update!(status:"confirmed")
transaction.after_commitdo
NotificationService.notify_order_confirmed(order)
end
end
This is interesting because the callback is associated with the unit of work, rather than with the model lifecycle.
Rails supports transaction-level callbacks such as:
transaction.before_commit
transaction.after_commit
transaction.after_rollback
This can be cleaner for domain/service-oriented workflows where you don’t want the model itself to know about notification behavior.
ActiveRecord.after_all_transactions_commit
Another useful modern Rails API is:
ActiveRecord.after_all_transactions_commitdo
NotificationService.notify(...)
end
This is useful when code may be invoked from either inside or outside a transaction.
Rails guarantees that the callback runs after all currently open transactions have successfully committed. If any transaction rolls back, the callback isn’t executed.
This can be particularly useful in reusable application services.
Article.current_transaction
Modern Rails exposes transaction state through:
Article.current_transaction
You can register an operation:
Article.current_transaction.after_commitdo
SearchIndexer.index(article)
end
This makes a service transaction-aware without requiring it to know whether its caller has opened a transaction.
Rails documents this API as a representation of the current transaction, savepoint, or lack of an active transaction. (Ruby on Rails API)
This is particularly interesting for reusable service objects.
For example:
classPublishArticle
defself.call(article)
article.update!(published:true)
Article.current_transaction.after_commitdo
SearchIndexer.index(article)
end
end
end
Now:
PublishArticle.call(article)
works both:
outside transaction
and:
Article.transactiondo
PublishArticle.call(article)
end
The external action can correctly follow the transaction boundary.
Don’t Rescue StatementInvalid Inside a Transaction
This is one of the most important PostgreSQL-specific transaction rules.
Bad:
User.transactiondo
begin
User.create!(email:"existing@example.com")
rescueActiveRecord::StatementInvalid
# ignore
end
User.create!(email:"new@example.com")
end
A database error such as a unique constraint violation can leave the PostgreSQL transaction in an aborted state.
After that, subsequent SQL statements can fail with an error similar to:
current transaction is aborted,
commands ignored until end of transaction block
Rails explicitly recommends restarting the entire transaction after ActiveRecord::StatementInvalid, rather than continuing within the damaged transaction.
Better:
begin
User.transactiondo
create_user!
create_profile!
end
rescueActiveRecord::RecordNotUnique
# retry or handle outside the transaction
end
The key idea is:
Database failure
↓
Transaction may be unusable
↓
Exit transaction
↓
Handle/retry outside it
This is especially important when building retry logic for concurrency errors.
Transactions Are Not Distributed Transactions
A Rails transaction normally operates on one database connection.
Therefore:
User.transactiondo
user.save!
AuditLog.create!
end
works when those models participate in the same database connection.
But imagine:
Primary DB
User
Analytics DB
AnalyticsEvent
A transaction on the primary database cannot automatically roll back a transaction on another database connection.
Rails explicitly documents that transactions are not distributed across database connections.
This becomes especially important with Rails multiple-database applications.
Multiple Databases: Don’t Assume One Transaction
Imagine:
User.transactiondo
user.update!
AnalyticsEvent.transactiondo
analytics_event.save!
end
end
These are potentially separate database transactions.
You don’t suddenly have:
BEGIN DB1
BEGIN DB2
COMMIT DB1
COMMIT DB2
with a globally atomic guarantee.
Instead, you have two independent database resources.
This is where architectural patterns such as:
transactional outbox
event-driven processing
retries
idempotency
compensating actions
become more appropriate than trying to force a distributed transaction.
Transactions and Background Jobs
Consider:
Order.transactiondo
order.update!(status:"confirmed")
OrderConfirmationJob.perform_later(order.id)
end
This can be dangerous.
Depending on timing, the job could execute before the surrounding transaction has committed.
Then the worker might query:
Order.find(order_id)
and not observe the expected committed state.
Instead:
Order.transactiondo
order.update!(status:"confirmed")
order.after_commitdo
OrderConfirmationJob.perform_later(order.id)
end
end
Or use the appropriate transactional callback mechanisms.
The principle is:
Don’t allow asynchronous consumers to depend on database state that hasn’t committed yet.
Rails’ transaction callbacks are specifically designed for such post-commit work.
Keep Transactions Small
A transaction should generally cover the minimum amount of work necessary.
Avoid:
Order.transactiondo
order.update!
HTTP.get(payment_api)
HTTP.get(shipping_api)
expensive_calculation
sleep(5)
order.update!
end
Now the database transaction stays open while waiting on external systems.
That can mean:
transaction open
↓
database connection occupied
↓
locks potentially held
↓
other requests wait
↓
throughput decreases
A better architecture is often:
external preparation
↓
short DB transaction
↓
commit
↓
after_commit / job
↓
external side effect
Transaction Boundary vs Business Operation
A useful senior-level rule is:
A transaction boundary should normally correspond to a business operation that must be atomic.
For example:
Order.transactiondo
create_order!
reserve_inventory!
record_payment!
end
That’s a meaningful transaction.
But this:
User.transactiondo
user.update!
end
may be unnecessary if you’re only performing one persistence operation.
Remember:
user.update!
already has transactional protection around the persistence operation.
Testing Transaction Behavior
Transactions are especially valuable to test explicitly.
Example:
it"rolls back the order when payment fails"do
expect {
CheckoutService.call(user, cart)
}.toraise_error(PaymentError)
expect(Order.count).toeq(0)
end
Test the business guarantee, not the implementation detail.
Good transaction tests answer questions such as:
Does failed payment rollback the order?
Does failed inventory reservation rollback the payment?
Does an after_commit job run only after successful commit?
Does a nested requires_new operation rollback independently?
A Practical Senior-Level Example
Let’s build a realistic checkout flow.
class CheckoutService
def call(user:, cart:)
order = nil
Order.transaction do
order = Order.create!(
user: user,
total: cart.total,
status: "pending"
)
reserve_inventory!(cart)
Payment.create!(
order: order,
amount: cart.total,
status: "paid"
)
order.update!(status: "confirmed")
ActiveRecord::after_all_transactions_commit do
OrderConfirmationJob.perform_later(order.id)
end
end
order
end
private
def reserve_inventory!(cart)
cart.items.each do |item|
item.product.with_lock do
raise OutOfStock if item.product.stock < item.quantity
item.product.update!(
stock: item.product.stock - item.quantity
)
end
end
end
end
There are several senior-level ideas here.
Atomicity
Order.transaction
ensures the order, payment and inventory changes form one unit.
Concurrency control
with_lock
protects inventory from concurrent updates.
Post-commit processing
ActiveRecord.after_all_transactions_commit
prevents the job from being dispatched before the transaction chain is complete.
This is much closer to production-grade transaction design than simply knowing:
Model.transactiondo
end
Transaction APIs at a Glance
API
Main purpose
Typical usage
Model.transaction
Transaction around a unit of work
Service objects
instance.transaction
Transaction associated with a model instance
Model-centric workflows
ApplicationRecord.transaction
Application-wide transaction boundary
Shared models
transaction(requires_new: true)
Independent nested savepoint
Isolating sub-operations
transaction(isolation: :serializable)
Stronger concurrency guarantees
Highly concurrent workflows
with_lock
Transaction + row lock
Balance/inventory updates
after_commit
Run code after commit
External side effects
after_rollback
React to rollback
Cleanup/recovery logic
transaction.after_commit
Callback attached to a specific transaction
Service/domain workflows
ActiveRecord.after_all_transactions_commit
Run after outermost transaction chain commits
Transaction-aware reusable services
current_transaction.after_commit
Make services transaction-aware
Reusable domain services
Rails provides all of these around the same fundamental transaction system.
How I Decide Which API to Use
As a practical decision tree:
One database operation
Usually:
user.update!
No explicit transaction required.
Several operations must succeed together
Use:
User.transactiondo
...
end
Reusable service may be called inside another transaction
Consider:
transaction(requires_new:true)
when independent rollback semantics are actually required.
Concurrent modification of one row
Use:
record.with_lockdo
...
end
External system must run only after DB success
Use:
after_commit
or a transaction-aware post-commit mechanism.
Multiple database connections
Don’t assume a single transaction protects everything.
But don’t assume your Ruby object has magically reverted every piece of in-memory state to its pre-transaction state. Rails explicitly notes that database rollback doesn’t restore Active Record objects to their original in-memory state.
But that expressiveness comes with a side effect: Ruby contains quite a few symbols and syntax constructs that can look almost cryptic – even to experienced developers coming from other languages.
Consider this:
message = <<~TEXT
Hello #{user.name},
Your order has been shipped.
Thanks!
TEXT
What exactly does <<~TEXT mean?
Or:
users.filter_map { _1.email if _1.active? }
What is _1?
Or:
case response
in { status: 200, body: String => body }
puts body
end
Why does Ruby allow String =>>>> body inside a pattern?
These aren’t random pieces of syntax. They are examples of Ruby’s philosophy: make common programming operations concise without sacrificing readability.
This article explores some of Ruby 3.4’s most interesting “mysterious” syntax and more importantly explains what each construct means, why it exists, and when a senior developer should use it – or avoid it.
1. <<~ – The Squiggly Heredoc
Let’s start with one of the most useful Ruby syntax features.
message = <<~TEXT
Hello World
This is Ruby
Goodbye
TEXT
The <<~ syntax is called a squiggly heredoc.
What is a heredoc?
A heredoc allows you to define a multiline string:
message = <<TEXT
Hello
World
TEXT
Ruby keeps the newlines inside the string.
The problem is indentation.
In real Ruby code, especially Rails applications, multiline strings are usually nested inside methods, classes, conditionals, etc.
Without squiggly heredoc:
def email_body
<<TEXT
Hello,
Welcome to our application.
Thank you.
TEXT
end
The heredoc terminator often needs awkward indentation.
<<~ solves that
def email_body
<<~TEXT
Hello,
Welcome to our application.
Thank you.
TEXT
end
Ruby removes the common leading indentation.
Conceptually:
source indentation
↓
Hello
Welcome
Thank you
becomes:
Hello
Welcome
Thank you
Why is this useful in Rails?
Extremely useful for SQL:
sql = <<~SQL
SELECT users.*
FROM users
INNER JOIN orders ON orders.user_id = users.id
WHERE users.active = TRUE
SQL
Or HTML:
html = <<~HTML
<div class="user">
<h2>#{user.name}</h2>
</div>
HTML
At this point, explicit names are much easier to understand.
users.mapdo |user|
user.orders.select { |order| order.paid? }
.map { |order| order.total }
end
Senior Ruby code optimizes for comprehension, not character count.
7. ... – The Argument Forwarding Operator
Ruby’s ... is particularly useful when wrapping methods.
Consider:
deflog(*args, **kwargs, &block)
puts"Calling method"
super
end
Modern Ruby allows forwarding arguments directly:
deflog(...)
puts"Calling method"
super
end
The ... means:
Forward all positional arguments, keyword arguments, and the block.
For example:
def instrument(...)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = super
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
puts "Took #{duration}s"
result
end
This is particularly valuable for decorators, wrappers, instrumentation and delegation.
8. * – The Splat Operator
Ruby’s * has several important meanings.
Array expansion
numbers= [1, 2, 3]
puts(*numbers)
is effectively:
puts(1, 2, 3)
Collecting arguments
defsum(*numbers)
numbers.sum
end
Now:
sum(1, 2, 3, 4)
works because numbers becomes:
[1, 2, 3, 4]
Array destructuring
first, *middle, last= [1, 2, 3, 4, 5]
results in:
first# 1
middle# [2, 3, 4]
last# 5
This makes * one of Ruby’s most versatile operators.
9. ** – Keyword Argument Splat
The double splat is the keyword-argument equivalent.
options= {
timeout:10,
retries:3
}
client.call(**options)
This expands the hash into keyword arguments.
And:
defconnect(**options)
options
end
collects arbitrary keyword arguments.
connect(timeout:10, retries:3)
gives:
{
timeout:10,
retries:3
}
This becomes particularly important when building APIs, service objects and forwarding methods in modern Ruby.
10. =>>>> Is More Than Hash Syntax
Most Ruby developers first encounter:
{ name:"Abhilash" }
But =>>>> has several meanings.
Hash rockets
{ "name"=>>"Abhilash" }
Pattern matching
Ruby pattern matching also uses =>>>>.
caseresponse
in { status:200, body:String=>>body }
putsbody
end
Here:
String=>>body
means roughly:
Match a String and bind the matched value to body.
This is part of Ruby’s increasingly powerful pattern matching system.
11. Ruby Pattern Matching with in
Ruby’s case statement can do structural matching.
case user
in { name:, role: "admin" }
puts "#{name} is an admin"
else
puts "Not an admin"
end
The pattern:
{ name:, role:"admin" }
means:
the object should have a name
role must equal "admin"
bind the name value to the local variable name
This is considerably more powerful than a traditional case comparison.
Array patterns
case coordinates
in [x, y]
puts "Point: #{x}, #{y}"
end
Why senior developers should care
Pattern matching becomes useful when processing:
API responses
parsed JSON
AST structures
event payloads
command results
structured domain objects
Instead of writing nested conditionals, you can express the expected structure directly.
12. in vs if
Traditional Ruby:
if response.is_a?(Hash) &&
response[:status] == 200
...
end
Pattern matching:
case response
in { status: 200 }
...
end
The second version communicates the shape of the data rather than manually checking each property.
That is the deeper value of pattern matching.
13. | – Destructuring and Pattern Alternatives
Ruby’s | isn’t only the bitwise OR operator.
In pattern matching:
casevalue
in1|2|3
puts"Small number"
end
means:
Match 1 OR 2 OR 3.
This makes pattern matching expressive:
casestatus
in200|201|204
puts"Success"
in400|401|403
puts"Client error"
end
14. =>>>> in Pattern Matching Can Bind Values
Consider:
caseresult
inInteger=>>value
putsvalue
end
This performs a type match and binds the value.
For example:
result=42
matches:
Integer=>>value
and:
value
# =>> 42
This becomes powerful when patterns become more complex.
15. ... in Ranges
Ruby’s range syntax has two forms:
1..10
and:
1...10
The difference:
1..10
includes 10.
1...10
excludes 10.
Therefore:
(1..10).to_a
gives:
[1,2,3,4,5,6,7,8,9,10]
while:
(1...10).to_a
gives:
[1,2,3,4,5,6,7,8,9]
This is especially useful for array slicing:
numbers[0...3]
returns the first three elements.
16. .. Can Be Used in Conditions
Ruby has another interesting use of ranges.
case number
when 1..10
puts "Small"
when 11..100
puts "Medium"
end
This is one reason Ruby ranges are more than simply “start/end values.”
17. =>>>> vs : in Hashes
These are both valid:
{ name:"Ruby" }
and:
{ :name=>>"Ruby" }
But modern Ruby generally prefers:
{ name:"Ruby" }
The hash rocket remains useful when keys aren’t symbols:
{
"Content-Type"=>>"application/json",
"X-Request-ID"=>>request_id
}
This is a good example of Ruby syntax evolving toward readability while retaining backwards compatibility.
18. ? and ! Are Part of Ruby’s API Design
Ruby method names can end with ?:
user.active?
This convention means:
The method answers a yes/no question.
Examples:
empty?
nil?
valid?
persisted?
published?
The ! convention usually communicates:
This method performs a more dangerous, mutating, or exceptional version of an operation.
Examples:
save!
update!
destroy!
compact!
But an important senior-level detail:
Ruby does not enforce the semantic meaning of !.
You can technically write:
defhello!
"hello"
end
The meaning is a convention established by Ruby developers.
19. :: – Constant Lookup and Method Calls
Most developers know:
User::NAME
But :: can also invoke methods:
object::method
although the . form is overwhelmingly more idiomatic for method calls.
The primary modern use is constant/module navigation:
ActiveRecord::Base
Rails::Application
JSON::ParserError
It communicates namespace traversal.
20. @, @@ and $
Ruby has several variable scopes represented visually.
Local variable
name="Ruby"
Instance variable
@name="Ruby"
belongs to an object instance.
Class variable
@@name="Ruby"
is shared across a class hierarchy.
Global variable
$name="Ruby"
is globally accessible.
From a senior Rails perspective:
Prefer local and instance variables. Be extremely cautious with class variables and globals.
For example, Rails applications rarely need:
@@configuration
or:
$global_state
because they introduce difficult-to-control shared state.
21. ||= – Lazy Initialization
This is everywhere in Ruby:
@client||=Client.new
It means roughly:
@client=@client||Client.new
If @client is already truthy, Ruby keeps it.
Otherwise, it creates the object.
This is commonly used for memoization:
defexpensive_service
@expensive_service||=ExpensiveService.new
end
But remember
||= checks truthiness, not whether the variable has ever been assigned.
So if:
@value=false
then:
@value||=calculate_value
will call calculate_value.
That distinction matters when memoizing boolean values.
22. &&= and ||= Are Assignment Operators
Ruby also supports:
value &&=other
and:
value||=other
For example:
user.active &&=user.verified?
means approximately:
user.active=user.active && user.verified?
These are concise, but they should be used only when the resulting expression remains obvious.
23. +=, -=, *=, /=
Ruby supports compound assignment:
counter+=1
Conceptually:
counter=counter+1
For object attributes:
user.score+=10
is conceptually equivalent to:
user.score=user.score+10
Ruby’s expressive assignment syntax is one of the reasons its code can remain compact without introducing a separate statement syntax.
24. defined? – Ask Ruby Whether Something Exists
Ruby provides:
defined?(variable)
For example:
defined?(@user)
may return:
"instance-variable"
You can also inspect constants:
defined?(Rails)
This can be useful for metaprogramming and conditional loading, although it should not be used as a substitute for proper application design.
25. respond_to? – Duck Typing in Action
Ruby’s duck typing philosophy often appears as:
object.respond_to?(:call)
Instead of asking:
object.is_a?(SomeSpecificClass)
you ask:
Can this object perform the operation I need?
For example:
iflogger.respond_to?(:info)
logger.info("Processing started")
end
This is particularly useful when designing flexible Ruby APIs.
26. method(:foo) – Turn a Method into an Object
Ruby treats methods as objects through Method:
method=user.method(:email)
Then:
method.call
invokes it.
This is useful in metaprogramming and dynamic dispatch.
For example:
operation=object.method(:calculate)
operation.call
Ruby’s object model makes this possible without requiring a separate function-pointer concept.
27. public_send vs send
Ruby allows dynamic method invocation:
user.send(:email)
But send can invoke private methods.
For user-controlled or externally supplied method names, this can be dangerous.
Prefer:
user.public_send(:email)
when you intentionally want to restrict invocation to public methods.
This distinction becomes important when building generic service layers or DSLs.
28. then / yield_self – Pipeline-Style Ruby
Ruby provides:
object.thendo |value|
...
end
For example:
result=
User.new
.then { |user| user.save! }
.then { |user| user.email }
then passes the receiver into the block and returns the block’s result.
It can be useful when constructing transformations without introducing temporary variables.
But don’t turn everything into a pipeline merely because Ruby allows it.
29. _ – The Intentionally Ignored Variable
You’ll frequently see:
users.eachdo |user, _index|
putsuser.name
end
The _ communicates:
This value exists, but I intentionally don’t care about it.
Ruby also allows:
_=expensive_result
although explicit naming is generally preferable unless you’re intentionally ignoring something.
30. Endless Method Definitions
Ruby allows:
deffull_name="#{first_name} #{last_name}"
instead of:
deffull_name
"#{first_name} #{last_name}"
end
This is called an endless method definition.
It’s excellent for very small methods:
defactive?=status=="active"
deftotal=price*quantity
But don’t use it for complex logic.
This:
defprocess=validate && save && notify && publish
may be syntactically elegant but is much harder to maintain.
31. =>>>> – Rightward Assignment
Modern Ruby also supports rightward assignment:
value=>>variable
For example:
"hello"=>>message
Now:
message
# =>> "hello"
This becomes particularly interesting with pattern matching:
response=>> { status:, body: }
It allows destructuring and binding in a visually different direction.
The feature is useful, but like many Ruby syntactic conveniences, it should be used when it improves readability—not simply because it is available.
32. The Bigger Picture: Ruby Syntax Is a Language of Intent
After seeing all these operators, it is tempting to memorize them as a collection of Ruby tricks.
That would miss the important point.
Ruby’s syntax frequently tries to encode intent.
Compare:
users.map { |user| user.email }
with:
users.map(&:email)
The second says:
Transform each user using its email method.
Compare:
ifuser && user.profile && user.profile.avatar
with:
user&.profile&.avatar
The second says:
Traverse this optional object graph.
Compare:
message=<<~TEXT
...
TEXT
with manually concatenating strings.
The first says:
This is a multiline piece of text.
And:
caseresponse
in { status:200, body:String=>>body }
says:
I expect this particular structure.
That is the real power behind Ruby’s “mysterious symbols.”
33. Senior Ruby Developer Rule: Don’t Optimize for Cleverness
A senior Ruby developer should know all of these constructs.
But knowing them doesn’t mean using them everywhere.
This tells us what the client’s constructor expects.
Also try:
OpenAI::Client.instance_methods(false)
We’re learning to inspect a Ruby library rather than treating it as magic.
Step 5.14 – Create the OpenAI client
Now let’s modify:
app/services/ai/client.rb
We’ll start with:
class Ai::Client
def initialize
@api_key = Rails.application.credentials.dig(:openai, :api_key)
raise "OpenAI API key is missing" if @api_key.blank?
@client = OpenAI::Client.new(api_key: @api_key)
end
end
The Ruby OpenAI SDK’s API can change between versions, so don’t blindly copy the exact request syntax from older tutorials. That’s why we’re checking the version we’ve actually installed before writing the API call.
Now we’ve:
“OpenAI client initialized.”
We’ll make our first actual LLM request and inspect the complete response, including:
response
model
output
usage
input tokens
output tokens
That will lead directly into why we added those fields to our Message model.
@client=OpenAI::Client.new(api_key:@api_key)
We’ll use our installed SDK’s API, not older ruby-openai examples. The current official openai Ruby SDK documents OpenAI::Client.new(api_key: ...) and the Responses API as the current interface. (GitHub)
Step 5.16 – Make the First Real LLM Request
For this step, we’ll do one simple request and inspect the response.
We are not integrating it with Conversation or Message yet.
Our goal is:
Rails console
↓
Ai::Client
↓
OpenAI Responses API
↓
LLM
↓
Response
1. Add a chat method
Open:
app/services/ai/client.rb
Change it to:
class Ai::Client
def initialize
@api_key = Rails.application.credentials.dig(:openai, :api_key)
raise "OpenAI API key is missing" if @api_key.blank?
@client = OpenAI::Client.new(api_key: @api_key)
end
def chat(message)
@client.responses.create(
model: "gpt-5.2",
input: message
)
end
end
The SDK’s current Responses API accepts model and input for creating a response. (GitHub)
Why input: message?
We’re deliberately starting with the simplest possible request:
input:"Explain Ruby blocks in simple terms"
Later we’ll send structured conversation history:
input: [
{ role::system, content:"..." },
{ role::user, content:"..." }
]
The Responses API supports both simple input and structured message input. (GitHub)
2. Start Rails console
bin/rails c
Create the client:
client=Ai::Client.new
Now make the request:
response=client.chat(
message:"Explain Ruby blocks in simple terms."
)
This is the moment our application makes an actual network request.
3. Inspect the response
First:
response.class
Then:
response
Don’t worry if the output is large.
The current official Ruby SDK returns typed response objects and the response contains the generated output plus metadata such as usage. (GitHub)
But if you get the following output, we can change the model which has free API calls:
ai-assistant(dev):013> client = Ai::Client.new
ai-assistant(dev):003> res = ai.chat('I want to be a expert in Ruby language')
app/services/ai/client.rb:11:in 'Ai::Client#chat': {url: "https://api.openai.com/v1/responses", status: 429, body: {error: {message: "You have no credits remaining. Add credits to continue using the API at https://platform.openai.com/settings/organization/billing/.", type: "insufficient_quota", param: nil, code: "credit_balance_exhausted"}}} (OpenAI::Errors::RateLimitError)
from (ai-assistant):3:in '<compiled>'
Yes – the error makes sense and there is an important distinction here:
Our ChatGPT subscription and OpenAI API billing are separate.
So even if you can use ChatGPT normally, that does not give your Ruby application free API calls. OpenAI explicitly says ChatGPT and API billing are managed separately. (OpenAI Help Center)
Why you’re seeing You have no credits remaining
Your Rails code is calling the OpenAI API, not ChatGPT:
Rails app
↓
OpenAI API
↓
API billing / credits
The API account associated with your key currently has no usable credits. OpenAI’s current prepaid-billing documentation says API requests stop once the available credit balance is exhausted. (OpenAI Help Center)
“But aren’t basic models free?”
Not generally for the API.
There may be specific free/trial allocations or products with included usage, but you should not assume that a model being available in ChatGPT means the API is free.
For our Rails application, we’re using:
OpenAI::Client
which consumes API usage and is metered separately.
What I recommend for our course
I don’t think we should spend money just to continue learning unless you’re comfortable doing so.
We have three practical paths:
Option 1 – Add a small API balance
Open your OpenAI API billing overview and check your balance. New API users currently use prepaid billing and the documented minimum purchase is $5, with $10 as the default purchase amount. (OpenAI Help Center)
For this course a small balance should be plenty for experimentation because our prompts will be tiny.
Option 2 – Use another provider with a free tier
We could temporarily use a provider that offers some free API usage, while keeping the same architecture:
Ai::Client
↓
Provider
↓
LLM
This is actually useful because later we’ll make our architecture provider-agnostic.
Option 3 – Run a local model
We can install something like Ollama and run an LLM locally:
Rails
↓
Ai::Client
↓
localhost
↓
Local LLM
Advantages:
no API credits
no network dependency
no per-token cost
great for development
The downside is that the model quality may differ from hosted models, and local inference requires reasonable hardware.
One important thing for our architecture
Don’t change this:
Ai::Client
The fact that OpenAI isn’t currently usable doesn’t mean we should redesign the application.
We specifically created:
Rails
↓
Ai::Client
↓
Provider
so that later we can switch:
Ai::Client
↓
OpenAI
to:
Ai::Client
↓
Anthropic
or:
Ai::Client
↓
Ollama
without rewriting our Rails application.
That’s actually an important senior-level design lesson.
What we can do now?
Since our objective is learning AI engineering, not spending money on API calls, first check your API billing page.
If it shows:
Free trial credit remaining: $0.00
then the error is fully explained. OpenAI’s billing documentation uses exactly this sort of balance indicator. (OpenAI Help Center)
We can then decide between a small API credit or a local/free-tier provider.
For this course, I slightly prefer keeping OpenAI as the first provider so you learn the real production API flow, then later we’ll add a second provider/local model to demonstrate the abstraction properly.
4. Get the generated text
Try:
response.output_text
You should get a normal answer such as:
A Ruby block is a chunk of code that can be passed to a method...
This is the first important distinction:
response
↓
entire API response
response.output_text
↓
just the model's text
Don’t immediately throw away the full response. We need the metadata later.
5. Inspect the model
Try:
response.model
This tells you which model actually generated the response.
That’s relevant to our messages.model column.
6. Inspect usage
Now:
response.usage
You should see token-related information.
Inspect it:
response.usage.input_tokens
and:
response.usage.output_tokens
These are directly related to the fields we added earlier:
messages
-------------------
input_tokens
output_tokens
So our database design is now connected to a real API response.
LLM response
│
├── model
├── output text
└── usage
├── input_tokens
└── output_tokens
The SDK’s response models expose usage information as part of the response. (GitHub)
7. One very important experiment
Ask a second question:
response2=client.chat(
message:"What is my name?"
)
You’ll probably notice the model doesn’t know your name from the previous request.
That’s intentional.
We made two independent requests:
Request 1
"Explain Ruby blocks"
Request 2
"What is my name?"
The LLM does not automatically receive our previous request.
This is going to become extremely important when we implement:
Conversation
↓
Messages
↓
Prompt Builder
↓
LLM
Our Rails application will be responsible for providing the appropriate conversation context.
Don’t paste our API key or any sensitive output anywhere.
Now: “Our First LLM request works.”
Then we’ll do the next important step: inspect the raw response structure and improve Ai::Client so it returns a clean Ruby object to the rest of our Rails application.
Now let’s move to the next step: make the Message model production-friendly.
We’ll start with the most important field: role.
Step 3 – Design Message.role
Currently our database allows:
role = anything
For example:
"user"
"assistant"
"system"
"foo"
"hello"
"something-invalid"
That’s not what we want.
Our AI application has a defined set of roles:
user
assistant
system
Later, when we introduce tool calling, we may also need to represent tool messages depending on the provider/API design. But for our current application, we’ll keep the persisted roles to these three.
Why use a string instead of an integer?
You may remember our previous discussion about Rails enums.
We could store:
0 = user
1 = assistant
2 = system
But for an AI application, I prefer a string-backed enum.
Database:
role
---------
user
assistant
system
instead of:
role
---------
0
1
2
Why?
1. Database is self-describing
When you run:
SELECTroleFROM messages;
you immediately see:
user
assistant
assistant
user
system
2. Easier debugging
When you’re debugging an AI conversation, the actual value is obvious.
3. Safer for external APIs
LLM APIs already use strings such as:
{
"role":"user"
}
So our database representation matches the domain.
Step 3A – Add the Rails enum
Open:
app/models/message.rb
Currently you should have something like:
classMessage<ApplicationRecord
belongs_to:conversation
end
Change it to:
classMessage<ApplicationRecord
belongs_to:conversation
enum:role, {
user:"user",
assistant:"assistant",
system:"system"
}, validate:true
end
Understand this carefully
This:
enum:role, {
user:"user",
assistant:"assistant",
system:"system"
}, validate:true
doesn’t mean PostgreSQL has an enum type. We’re using a Rails enum backed by a string column.
PostgreSQL still has:
role character varying
Rails gives us a domain API on top of it.
Step 3B – Test the enum
Start Rails console:
bin/rails console
Find our message:
message=Message.first
Check:
message.role
You should get:
"user"
Now:
message.user?
Expected:
true
And:
message.assistant?
Expected:
false
Step 3C – Test the scopes
Rails also gives us useful scopes.
Try:
Message.user
and:
Message.assistant
and:
Message.system
For example:
Message.user
roughly translates to:
SELECT*
FROM messages
WHERErole='user';
This is one of the benefits of using an enum.
Step 3D – Test invalid values
Now try:
Message.new(
conversation:Conversation.first,
role:"something_else",
content:"test"
)
Because we specified:
validate:true
Rails should treat the role as invalid.
Check:
message=Message.new(
conversation:Conversation.first,
role:"something_else",
content:"test"
)
message.valid?
Expected:
false
Then:
message.errors.full_messages
You should see an error indicating that the role is not included in the allowed values.
Why validate: true?
This is worth understanding: Without validation, Rails enum behavior can raise an ArgumentError when assigning an invalid value.
With:
validate:true
we get normal ActiveRecord validation behavior:
message.valid?
→false
and:
message.errors
contains the validation error.
That’s generally more convenient when the model is receiving user/application input.
Step 3E – One more important layer: Database constraint
There is a subtle issue here.
Rails validation protects you when data enters through Rails.
But PostgreSQL doesn’t know that only these values are valid:
ERROR: new row for relation "messages" violates check constraint "messages_role_check"
That’s exactly what we want.
The database is now protecting the data.
Why is this important?
Suppose an int. asks:
“Why do you have both Rails validation and a PostgreSQL constraint?”
A strong senior-level answer would be:
“Rails validations provide application-level feedback and are useful for normal model operations, but they’re not a database integrity guarantee because data can enter through other paths. For important invariants such as message roles, I also enforce the constraint at the PostgreSQL level.”
That’s a much stronger answer than:
“Because Rails has validations.”
4.8 One more design question: content
We’re making:
change_column_null:messages, :content, false
But should an AI message be allowed to contain an empty string?
For example:
content:""
NOT NULL allows that.
So:
NULL NO
"" technically allowed
"Hello" YES
Whether empty content should be allowed is an application-level business rule.
We can later decide whether to add:
validates:content, presence:true
But don’t add that yet.
There are legitimate AI API situations where a message may not have ordinary text content – for example, tool-related or structured content. We’ll revisit our message representation when we implement tool calling.
We don’t want the application to fail mysteriously later.
Add:
class Ai::Client
def initialize
@api_key = Rails.application.credentials.dig(:openai, :api_key)
raise "OpenAI API key is missing" if @api_key.blank?
end
end
Now:
Ai::Client.new
will fail immediately if the key isn’t configured. This is called fail-fast configuration.
5.10 Test the client
Run:
bin/rails console
Then:
client=Ai::Client.new
If everything is configured correctly, it should return:
Enums are one of those features developers use frequently – especially in frameworks like Rails – but many developers never fully understand why enums exist, what problem they solve, or how they are implemented internally. In Rails, enums appear deceptively simple:
enumstatus: { pending:0, paid:1, failed:2 }
But behind this tiny line lies an important software design concept used across programming languages, databases, compilers, APIs, operating systems, and application architecture.
This article explains the complete picture of enums:
Why enums exist
How they differ from other data structures
How Rails maps enums to integers internally
Whether enums are tied to SQL/databases
How ActiveRecord::Enum works under the hood
Real-world benefits and tradeoffs developers should know
What Is an Enum?
An Enum (Enumeration) is a restricted set of named values representing a finite group of states or options.
Example:
status=:pending
Possible statuses may be:
:pending
:processing
:completed
:failed
Instead of allowing any arbitrary value, enums constrain the system to a known set of valid states.
Why Do Enums Exist?
Enums solve several important problems in software systems.
1. Prevent Invalid States
Without enums:
order.status="asdfgh"
This may accidentally enter the database and corrupt business logic.
Enums restrict allowed values:
enumstatus: {
pending:0,
processing:1,
completed:2
}
Now Rails only allows known states.
2. Improve Readability
Compare:
iforder.status==2
vs
iforder.completed?
Enums convert meaningless numbers into expressive business language.
3. Save Storage Space
Integers are smaller and faster than strings.
Instead of storing:
"processing"
the DB stores:
1
This improves:
indexing
query performance
storage efficiency
4. Standardize State Management
Enums centralize valid states:
Order.statuses
returns:
{
"pending"=>0,
"processing"=>1,
"completed"=>2
}
This becomes a single source of truth.
5. Enable Better APIs & DSLs
Rails automatically generates methods:
order.pending?
order.completed!
Order.processing
Enums create expressive domain APIs.
How Enums Differ From Other Data Structures
Enums are NOT collections like arrays or hashes.
They represent a finite state system.
🔹 Enum vs Array
Array:
statuses= ["pending", "paid", "failed"]
Problem:
no constraints
no semantic meaning
no mapping behavior
no helper methods
🔹 Enum vs Hash
Hash:
STATUSES= {
pending:0,
paid:1
}
Closer, but still missing:
validations
query scopes
state predicates
DSL methods
Rails enums internally use hashes, but add behavior around them.
🔹 Enum vs Constants
Constants:
PENDING=0
PAID=1
Problem:
scattered
harder to manage
no grouped state semantics
Enums organize states cohesively.
🌍 Are Enums Related Only to SQL or Databases?
❌ Absolutely not.
Enums exist in:
C
Java
Rust
Swift
TypeScript
GraphQL
Operating systems
Compilers
APIs
State machines
Enums are a general programming concept, not a database feature.
Example: TypeScript Enum
enumStatus{
Pending,
Processing,
Completed
}
Example: Java Enum
enumStatus{
PENDING,
PROCESSING,
COMPLETED
}
Example: PostgreSQL Native Enum
CREATE TYPE status AS ENUM (
'pending',
'processing',
'completed'
);
This is database-level enum support.
🏗️ How Rails Implements Enums
Rails provides:
ActiveRecord::Enum
located in:
activerecord/lib/active_record/enum.rb
When you write:
classOrder<ApplicationRecord
enumstatus: {
pending:0,
processing:1,
completed:2
}
end
Rails dynamically generates:
1️⃣ Attribute Mapping
order.status
# => "pending"
Internally stored as:
0
in the database.
2️⃣ Predicate Methods
order.pending?
order.completed?
3️⃣ Bang Methods
order.completed!
Equivalent to:
order.update!(status::completed)
4️⃣ Query Scopes
Order.pending
Order.completed
Generated automatically.
5️⃣ Mapping Helpers
Order.statuses
Returns:
{
"pending"=>0,
"processing"=>1,
"completed"=>2
}
How Rails Maps Enum Values to Integers
Internally Rails stores:
{
pending:0,
processing:1,
completed:2
}
When assigning:
order.status=:processing
Rails converts:
:processing->1
before writing to DB.
When reading:
1->"processing"
This conversion is handled through ActiveRecord attribute type casting.
Database Example
Ruby:
order.status
# => "completed"
Actual DB value:
status =2
Why Integers Are Commonly Used
Integers:
are compact
index efficiently
compare faster
are DB-friendly
This is why Rails originally used integer-backed enums.
Important Enum Pitfall: Order Matters
This is VERY important.
Dangerous
enumstatus: [:pending, :processing, :completed]
Rails maps automatically:
pending->0
processing->1
completed->2
If you later insert:
[:pending, :draft, :processing, :completed]
Everything shifts:
processing becomes 2
completed becomes 3
💥 Existing DB data breaks.
Correct (recommended)
Always use explicit mapping:
enumstatus: {
pending:0,
processing:1,
completed:2
}
String-Based Enums in Rails
Rails also supports string-backed enums:
enumstatus: {
pending:"pending",
completed:"completed"
}
Benefits:
human-readable DB values
safer migrations
easier debugging
Tradeoff:
slightly larger storage
slightly slower indexing
🧪 Real SQL Generated by Rails Enum Queries
Order.completed
Generates:
SELECT*
FROM orders
WHERE status =2;
Even though Ruby code uses names, SQL uses integers.
🔬 Internals: How ActiveRecord::Enum Works
Internally Rails:
stores mappings in a class hash
defines methods dynamically using metaprogramming
hooks into ActiveRecord attribute casting
builds scopes automatically
Rails essentially does something conceptually like:
Rails validates at app layer, but DB still accepts:
status =999
unless constrained.
🛡️ Best Practices for Rails Enums
Use explicit mappings
enumstatus: {
pending:0,
processing:1,
completed:2
}
Add DB constraints if critical
Example PostgreSQL constraint:
CHECK(status IN(0,1,2))
Keep enums focused
Good:
status
payment_state
visibility
Bad:
everything_state
Prefer string enums when readability matters
Especially in:
analytics-heavy apps
debugging-heavy systems
APIs
Consider state machines for complex transitions
Enums represent states. State machines represent transitions.
Very different concepts.
Mental Model Every Developer Should Remember
Think of enums as:
“A controlled vocabulary for state.”
Enums are:
not collections
not just DB mappings
not Rails-specific
They are a way to model finite, meaningful states safely and expressively.
Final Takeaway
Enums exist because software systems constantly need to represent a limited set of valid states in a way that is:
efficient
readable
maintainable
safe
Rails’ ActiveRecord::Enum builds a powerful abstraction on top of simple integer (or string) mappings, generating expressive APIs, query scopes, and validations automatically through Ruby metaprogramming.
Understanding enums deeply helps developers:
design better domain models
avoid fragile state systems
write safer queries
reason about application workflows more clearly
Enums may look small, but they are one of the foundational building blocks of robust application design.
When you run thousands of background jobs through Sidekiq, Redis becomes the bottleneck. Every job enqueue adds Redis writes, network round-trips, and memory pressure. This post covers a real-world optimization we applied and a broader toolkit for keeping Sidekiq lean.
The Problem: One Job Per Item
Imagine sending weekly emails to 10,000 users. The naive approach:
At 10,000 users, that’s 10,000 Redis operations and 10,000 scheduled entries. At 1M users, that’s 1M scheduled jobs in Redis. That’s expensive and slow.
The Fix: Batch + Staggered Scheduling
Instead of one job per user, we batch users and schedule each batch with a small delay:
Each worker still processes one user at a time internally, but we only enqueue one job per batch. Redis overhead drops by roughly 100x.
Why perform_in instead of chaining?
perform_in(delay, batch_ids) — all jobs are scheduled immediately with their future timestamps. Sidekiq moves them into the ready queue at the right time regardless of other queue traffic.
Chaining (each job enqueues the next) — the next batch only enters the queue after the current one finishes. If other jobs are busy, your email chain sits behind them and can be delayed significantly.
For time-sensitive jobs like “send at 8:46 AM local time,” upfront scheduling is the right choice.
Other Sidekiq Optimization Strategies
1. Bulk Enqueue (Sidekiq Pro/Enterprise)
Sidekiq::Client.push_bulk pushes many jobs in one Redis call:
# Single Redis call instead of N
Sidekiq::Client.push_bulk(
'class'=>WeeklyEmailWorker,
'args'=>user_ids.map { |id| [id] }
)
Useful when you don’t need per-job delays and want to minimize Redis round-trips.
2. Adjust Concurrency
Default is 10 threads per process. More threads = more concurrency but more memory:
# config/sidekiq.yml
:concurrency:25# Tune based on CPU/memory
Higher concurrency helps if jobs are I/O-bound (HTTP, DB, email). For CPU-bound jobs, lower concurrency is usually better.
3. Use Dedicated Queues
Separate heavy jobs from light ones:
# config/sidekiq.yml
:queues:
-[critical, 3]# 3x weight
-[default, 2]
-[low, 1]
Critical jobs get more CPU time. Low-priority jobs don’t block the rest.
In this comprehensive guide, we’ll explore four fundamental concepts in computer science and object-oriented programming: the Template Method pattern, Strategy patterns, parameterized types, and object relationships through aggregation and acquaintance. These concepts form the backbone of modern software design and appear across virtually every programming language.
1. Template Method Pattern: Defining the Skeleton of an Algorithm
What is Template Method?
The Template Method is a behavioral design pattern that defines the skeleton of an algorithm in a base class but lets subclasses override specific steps without changing the algorithm’s structure. Think of it as a recipe where the overall cooking process is fixed, but individual chefs can customize certain steps.
The Core Idea
Instead of having multiple classes each implement the complete algorithm, you create:
A base/parent class that outlines the overall process
Subclasses that override specific “hook” methods to customize behavior
This follows the “Hollywood Principle”: “Don’t call us, we’ll call you.” The parent class controls the flow and calls the methods that subclasses provide.
Ruby Example
Let’s create a beverage brewing system:
# Base class defining the template method
class BeverageMaker
def brew
gather_ingredients
heat_water
add_ingredients
steep
serve
end
def gather_ingredients
puts "Gathering ingredients..."
end
def heat_water
puts "Heating water to appropriate temperature..."
end
# These are hook methods that subclasses will override
def add_ingredients
raise NotImplementedError, "Subclasses must implement add_ingredients"
end
def steep
raise NotImplementedError, "Subclasses must implement steep"
end
def serve
puts "Pouring into a cup..."
end
end
# Tea subclass
class TeaMaker < BeverageMaker
def add_ingredients
puts "Adding tea leaves to the infuser..."
end
def steep
puts "Steeping for 3-5 minutes..."
end
end
# Coffee subclass
class CoffeeMaker < BeverageMaker
def add_ingredients
puts "Adding ground coffee to the filter..."
end
def steep
puts "Brewing for 4-6 minutes..."
end
def serve
puts "Adding milk and sugar as desired, then pouring..."
end
end
# Usage
puts "=== Making Tea ==="
tea = TeaMaker.new
tea.brew
puts "\n=== Making Coffee ==="
coffee = CoffeeMaker.new
coffee.brew
Output:
=== Making Tea ===
Gathering ingredients...
Heating water to appropriate temperature...
Adding tea leaves to the infuser...
Steeping for 3-5 minutes...
Pouring into a cup...
=== Making Coffee ===
Gathering ingredients...
Heating water to appropriate temperature...
Adding ground coffee to the filter...
Brewing for 4-6 minutes...
Adding milk and sugar as desired, then pouring...
Real-World Application: Data Processing
class DataProcessor
def process(file_path)
data = read_file(file_path)
data = validate(data)
data = transform(data)
data = enrich(data)
save_output(data)
end
def read_file(file_path)
raise NotImplementedError
end
def validate(data)
puts "Validating data..."
data
end
def transform(data)
raise NotImplementedError
end
def enrich(data)
puts "Enriching data with metadata..."
data
end
def save_output(data)
raise NotImplementedError
end
end
class CSVProcessor < DataProcessor
def read_file(file_path)
puts "Reading CSV file: #{file_path}"
[["Name", "Age"], ["Alice", 30], ["Bob", 25]]
end
def transform(data)
puts "Transforming CSV data to hash format..."
data
end
def save_output(data)
puts "Saving processed data to database..."
end
end
class JSONProcessor < DataProcessor
def read_file(file_path)
puts "Reading JSON file: #{file_path}"
{"users" => [{"name" => "Alice", "age" => 30}]}
end
def transform(data)
puts "Transforming JSON data to standardized format..."
data
end
def save_output(data)
puts "Saving to API endpoint..."
end
end
Benefits
Code Reuse: Common logic is written once in the parent class
Consistency: Ensures all subclasses follow the same algorithm structure
Flexibility: Subclasses can customize only what they need
Maintainability: Changes to the overall algorithm are made in one place
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it. Unlike Template Method, where variations happen through inheritance, Strategy uses composition to swap algorithms at runtime.
The Core Idea
You create:
A Strategy interface that defines the algorithm contract
Concrete strategy classes that implement different variants
A context class that uses a strategy object
This allows you to change the algorithm used without modifying the client code.
Ruby Example
Let’s create a payment processing system:
# Strategy interface (in Ruby, we use duck typing or modules)
module PaymentStrategy
def pay(amount)
raise NotImplementedError
end
end
# Concrete strategies
class CreditCardPayment
include PaymentStrategy
def initialize(card_number, cvv)
@card_number = card_number
@cvv = cvv
end
def pay(amount)
puts "Processing credit card payment of $#{amount}"
puts "Card: #{@card_number[-4..-1]}"
validate_cvv
puts "Payment approved!"
end
private
def validate_cvv
puts "Validating CVV..."
end
end
class PayPalPayment
include PaymentStrategy
def initialize(email)
@email = email
end
def pay(amount)
puts "Sending $#{amount} via PayPal to #{@email}"
authenticate
puts "PayPal payment processed!"
end
private
def authenticate
puts "Authenticating with PayPal..."
end
end
class CryptocurrencyPayment
include PaymentStrategy
def initialize(wallet_address, crypto_type = "Bitcoin")
@wallet_address = wallet_address
@crypto_type = crypto_type
end
def pay(amount)
puts "Sending #{amount} satoshis to wallet #{@wallet_address}"
puts "Cryptocurrency: #{@crypto_type}"
confirm_blockchain
puts "Transaction confirmed on blockchain!"
end
private
def confirm_blockchain
puts "Confirming on blockchain..."
end
end
# Context class
class ShoppingCart
def initialize(payment_strategy)
@payment_strategy = payment_strategy
@total = 0
end
def add_item(price)
@total += price
end
def checkout
@payment_strategy.pay(@total)
end
# Strategy can be changed at runtime
def change_payment_method(new_strategy)
@payment_strategy = new_strategy
end
end
# Usage
puts "=== Customer 1: Credit Card Payment ==="
cart1 = ShoppingCart.new(CreditCardPayment.new("4532-1234-5678-9010", "123"))
cart1.add_item(50)
cart1.add_item(30)
cart1.checkout
puts "\n=== Customer 2: PayPal Payment ==="
cart2 = ShoppingCart.new(PayPalPayment.new("user@example.com"))
cart2.add_item(100)
cart2.checkout
puts "\n=== Customer 3: Changes mind about payment ==="
cart3 = ShoppingCart.new(CreditCardPayment.new("5412-9876-5432-1098", "456"))
cart3.add_item(75)
puts "Initial strategy: Credit Card"
cart3.change_payment_method(CryptocurrencyPayment.new("1A1z7agoat4WYvtQy06YnYs73m7nEChoCM", "Bitcoin"))
puts "Changed strategy: Cryptocurrency"
cart3.checkout
Real-World Application: Sorting Algorithms
module SortStrategy
def sort(array)
raise NotImplementedError
end
end
class BubbleSort
include SortStrategy
def sort(array)
puts "Sorting using Bubble Sort..."
n = array.length
(0...n).each do |i|
(0...n - i - 1).each do |j|
array[j], array[j + 1] = array[j + 1], array[j] if array[j] > array[j + 1]
end
end
array
end
end
class QuickSort
include SortStrategy
def sort(array)
puts "Sorting using Quick Sort..."
return array if array.length <= 1
pivot = array[0]
left = array[1..-1].select { |x| x < pivot }
right = array[1..-1].select { |x| x >= pivot }
sort(left) + [pivot] + sort(right)
end
end
class DataSorter
def initialize(strategy)
@strategy = strategy
end
def execute(data)
@strategy.sort(data)
end
def change_strategy(strategy)
@strategy = strategy
end
end
# Usage
data = [64, 34, 25, 12, 22, 11, 90]
sorter = DataSorter.new(BubbleSort.new)
puts sorter.execute(data.dup).inspect
sorter.change_strategy(QuickSort.new)
puts sorter.execute(data.dup).inspect
Benefits
Runtime Flexibility: Algorithms can be selected at runtime
Code Isolation: Each algorithm is encapsulated in its own class
Easy to Extend: New strategies can be added without modifying existing code
Testability: Each strategy can be tested independently
Template Method vs. Strategy
Aspect
Template Method
Strategy
Mechanism
Inheritance
Composition
When to use
Related algorithms sharing common structure
Interchangeable algorithms
Implementation
Subclasses override methods
Different classes implement interface
Change timing
Compile-time (class selection)
Runtime (object swap)
3. Parameterized Types: Generic Programming
What are Parameterized Types?
Parameterized types (also called generics) allow you to write code that works with different data types while maintaining type safety. They enable you to create classes and functions that operate on various types specified as parameters.
C++ Templates
C++ uses templates to implement generics at compile-time:
#include <iostream>
#include <vector>
// Generic function template
template <typename T>
T max_value(T a, T b) {
return (a > b) ? a : b;
}
// Generic class template
template <typename T>
class Stack {
private:
std::vector<T> elements;
public:
void push(T value) {
elements.push_back(value);
}
T pop() {
T value = elements.back();
elements.pop_back();
return value;
}
bool is_empty() const {
return elements.empty();
}
};
int main() {
// Using template functions with different types
std::cout << "Max of 5 and 10: " << max_value(5, 10) << std::endl;
std::cout << "Max of 3.5 and 2.1: " << max_value(3.5, 2.1) << std::endl;
// Using template classes
Stack<int> intStack;
intStack.push(10);
intStack.push(20);
std::cout << "Popped: " << intStack.pop() << std::endl;
Stack<std::string> stringStack;
stringStack.push("Hello");
stringStack.push("World");
std::cout << "Popped: " << stringStack.pop() << std::endl;
return 0;
}
Key Features:
Compile-time code generation: Compiler generates specific code for each type used
Type safety: Type checking happens at compile time
Zero runtime overhead: Generic code is instantiated for each type
Template specialization: Can provide specific implementations for certain types
Ada Generics
Ada’s generics provide a similar mechanism but with a different syntax:
generic
type Item_Type is private;
Max_Length : Integer;
package Stacks is
type Stack_Type is limited private;
procedure Push(S : in out Stack_Type; Item : Item_Type);
procedure Pop(S : in out Stack_Type; Item : out Item_Type);
function Is_Empty(S : Stack_Type) return Boolean;
private
type Item_Array is array (1..Max_Length) of Item_Type;
type Stack_Type is record
Items : Item_Array;
Top : Integer := 0;
end record;
end Stacks;
Usage:
with Stacks;
procedure Use_Integer_Stack is
package Int_Stacks is new Stacks(Item_Type => Integer, Max_Length => 100);
My_Stack : Int_Stacks.Stack_Type;
begin
Int_Stacks.Push(My_Stack, 42);
-- ...
end Use_Integer_Stack;
Ruby Generics (Runtime Polymorphism)
Ruby doesn’t have compile-time generics, but uses duck typing and metaprogramming:
# Ruby approach: Using blocks and duck typing
class Container
def initialize
@items = []
end
def add(item)
@items << item
end
def process(&block)
@items.each { |item| block.call(item) }
end
def map(&block)
@items.map { |item| block.call(item) }
end
def select(&block)
@items.select { |item| block.call(item) }
end
end
# Using with different types
int_container = Container.new
int_container.add(1)
int_container.add(2)
int_container.add(3)
puts "Original integers:"
int_container.process { |x| puts x }
puts "\nDoubled integers:"
doubled = int_container.map { |x| x * 2 }
puts doubled.inspect
string_container = Container.new
string_container.add("Hello")
string_container.add("World")
string_container.add("Ruby")
puts "\nOriginal strings:"
string_container.process { |s| puts s }
puts "\nUppercased strings:"
uppercased = string_container.map { |s| s.upcase }
puts uppercased.inspect
Using Generic Patterns
# A more sophisticated generic-like pattern using modules
module Enumerable
def filter_map(&block)
map(&block).select { |item| !item.nil? }
end
def partition_by(&block)
Hash.new { |h, k| h[k] = [] }.tap do |hash|
each { |item| hash[block.call(item)] << item }
end
end
end
class MyList
include Enumerable
def initialize(items)
@items = items
end
def each(&block)
@items.each(&block)
end
def map(&block)
@items.map(&block)
end
def select(&block)
@items.select(&block)
end
end
# Usage
numbers = MyList.new([1, 2, 3, 4, 5, 6])
evens = numbers.partition_by { |n| n.even? ? :even : :odd }
puts evens.inspect
Benefits
Type Safety: Errors caught at compile-time (in typed languages)
Code Reuse: Write once for multiple types
Performance: No runtime type checking overhead in compiled languages
Expressiveness: Can write sophisticated data structures and algorithms
4. Object Aggregation and Acquaintance: Structuring Relationships
Understanding the Difference
Aggregation and acquaintance are two ways objects relate to each other in object-oriented design:
Aggregation (has-a relationship): An object contains another object as a part of its structure. The contained object is a permanent part of the container.
Acquaintance (uses-a relationship): An object temporarily knows about another object, typically passed as a parameter or obtained through a method call. The relationship is less permanent.
Aggregation Examples
Aggregation represents a “part-of” relationship where an object owns or contains other objects:
# Strong aggregation: Car owns its parts
class Engine
def initialize(horsepower)
@horsepower = horsepower
end
def start
puts "Engine with #{@horsepower}hp starting..."
end
def stop
puts "Engine stopping..."
end
end
class Wheel
def initialize(size)
@size = size
end
def rotate
puts "#{@size}\" wheel rotating..."
end
end
class Car
def initialize(make, model)
@make = make
@model = model
# Aggregation: Car contains Engine and Wheels
@engine = Engine.new(200)
@wheels = [
Wheel.new(18),
Wheel.new(18),
Wheel.new(18),
Wheel.new(18)
]
end
def start
puts "Starting #{@make} #{@model}..."
@engine.start
end
def drive
@wheels.each(&:rotate)
puts "Car is moving!"
end
def stop
@engine.stop
puts "Car stopped."
end
end
# Usage
car = Car.new("Toyota", "Camry")
car.start
car.drive
car.stop
Key characteristics of aggregation:
The container creates/owns the contained objects
The contained objects are part of the container’s structure
Destroying the container may destroy the contained objects
The relationship is relatively permanent
Acquaintance Examples
Acquaintance represents a “knows-about” relationship where objects interact but don’t own each other:
# Acquaintance: Order knows about Customer and Product
class Customer
def initialize(name, email)
@name = name
@email = email
end
def name
@name
end
def email
@email
end
end
class Product
def initialize(name, price)
@name = name
@price = price
end
def name
@name
end
def price
@price
end
end
class Order
def initialize(order_id)
@order_id = order_id
@customer = nil # Acquaintance: will know about a customer
@products = [] # Acquaintance: will know about products
@total = 0
end
# Receives a customer as a parameter
def assign_customer(customer)
@customer = customer
puts "Order #{@order_id} assigned to #{customer.name}"
end
# Receives products as parameters
def add_product(product, quantity = 1)
@products << { product: product, quantity: quantity }
@total += product.price * quantity
end
def display_summary
puts "\n=== Order Summary ==="
puts "Order ID: #{@order_id}"
puts "Customer: #{@customer.name}"
puts "Items:"
@products.each do |item|
puts " - #{item[:product].name}: $#{item[:product].price} x #{item[:quantity]}"
end
puts "Total: $#{@total}"
end
end
# Usage
customer = Customer.new("Alice Johnson", "alice@example.com")
product1 = Product.new("Laptop", 999)
product2 = Product.new("Mouse", 25)
order = Order.new("ORD-001")
order.assign_customer(customer)
order.add_product(product1)
order.add_product(product2, 2)
order.display_summary
Key characteristics of acquaintance:
Objects are passed as parameters or obtained through method calls
The relationship is temporary and context-dependent
Objects don’t create or own each other
Objects can exist independently
Real-World Comparison: Restaurant System
# AGGREGATION: Restaurant owns its Menu and Tables
class MenuItem
def initialize(name, price)
@name = name
@price = price
end
def description
"#{@name}: $#{@price}"
end
end
class Table
def initialize(table_number, capacity)
@table_number = table_number
@capacity = capacity
@is_occupied = false
end
def occupy
@is_occupied = true
end
def free
@is_occupied = false
end
def available?
!@is_occupied
end
end
class Menu
def initialize(cuisine_type)
@cuisine_type = cuisine_type
@items = []
end
def add_item(item)
@items << item
end
def list_items
@items.map(&:description)
end
end
class Restaurant
def initialize(name)
@name = name
# AGGREGATION: Restaurant owns these objects
@menu = Menu.new("Italian")
@tables = [
Table.new(1, 4),
Table.new(2, 6),
Table.new(3, 2)
]
end
def setup_menu
@menu.add_item(MenuItem.new("Pasta Carbonara", 15))
@menu.add_item(MenuItem.new("Lasagna", 18))
@menu.add_item(MenuItem.new("Tiramisu", 8))
end
def show_menu
puts "=== #{@name} Menu ==="
@menu.list_items.each { |item| puts item }
end
def reserve_table(party_size)
available_table = @tables.find { |t| t.available? && t.capacity >= party_size }
if available_table
available_table.occupy
"Table reserved!"
else
"No suitable tables available"
end
end
end
# ACQUAINTANCE: Reservation knows about Customer and Restaurant
class Reservation
def initialize(reservation_id)
@reservation_id = reservation_id
@customer = nil
@restaurant = nil
@time = nil
@party_size = nil
end
def make_reservation(customer, restaurant, time, party_size)
@customer = customer
@restaurant = restaurant
@time = time
@party_size = party_size
puts "Reservation #{@reservation_id} made for #{customer.name} at #{time} for #{party_size} people"
end
def confirm
puts "Confirming reservation for #{@customer.name}..."
result = @restaurant.reserve_table(@party_size)
puts result
end
end
# Usage
restaurant = Restaurant.new("Luigi's Italian Kitchen")
restaurant.setup_menu
restaurant.show_menu
customer = Customer.new("Bob Smith", "bob@example.com")
reservation = Reservation.new("RES-001")
reservation.make_reservation(customer, restaurant, "7:00 PM", 4)
reservation.confirm
When to Use Each
Aspect
Aggregation
Acquaintance
Relationship
Part-of, owns
Uses, knows-about
Lifetime
Container controls
Independent
Creation
Container creates
External creation
Use Case
Car-Engine, House-Rooms
Customer-Order, Client-Service
Dependency
Strong coupling
Loose coupling
Benefits
Aggregation:
Clear ownership and lifecycle management
Encapsulation of related components
Simplified understanding of object structure
Acquaintance:
Loose coupling between objects
Better testability and modularity
More flexible object interactions
Easier to extend and modify
Putting It All Together: A Complete Example
Let’s create a library system that demonstrates all four concepts:
# TEMPLATE METHOD: Base class for different user types
class LibraryUser
def initialize(name)
@name = name
@borrowed_books = []
end
def process_checkout(book)
check_eligibility
check_availability(book)
checkout_book(book)
send_confirmation(book)
end
protected
def check_eligibility
raise NotImplementedError
end
def check_availability(book)
puts "Checking if #{book.title} is available..."
end
def checkout_book(book)
@borrowed_books << book
puts "Book checked out successfully"
end
def send_confirmation(book)
puts "Sending confirmation to #{@name}"
end
end
class Student < LibraryUser
def check_eligibility
puts "Checking student ID and membership status..."
end
def send_confirmation(book)
puts "Emailing confirmation to student: #{@name}"
end
end
class Faculty < LibraryUser
def check_eligibility
puts "Checking faculty status..."
end
def checkout_book(book)
@borrowed_books << book
puts "Faculty member can borrow up to 20 items"
end
end
# AGGREGATION: Library owns Books and has Shelves
class Book
attr_reader :title, :author
def initialize(title, author, isbn)
@title = title
@author = author
@isbn = isbn
@is_available = true
end
def available?
@is_available
end
def checkout
@is_available = false
end
def return_book
@is_available = true
end
end
class Shelf
def initialize(section, capacity)
@section = section
@capacity = capacity
@books = []
end
def add_book(book)
@books << book if @books.length < @capacity
end
def list_books
@books.map(&:title)
end
end
class Library
def initialize(name)
@name = name
# AGGREGATION: Library owns shelves and manages books
Understanding when and how to apply each concept leads to more flexible, maintainable, and scalable software. Ruby’s flexibility makes these patterns particularly elegant to implement, though the principles apply across all modern programming languages.
The key is choosing the right tool for the right problem: use Template Method when you have variations of a fixed process, use Strategy for interchangeable algorithms, use generics for type-flexible code, and use appropriate aggregation/acquaintance patterns to structure your object relationships cleanly.
A developer’s guide to understanding Stripe’s API transformation and avoiding common migration pitfalls
The payment processing landscape has evolved dramatically over the past decade, and Stripe has been at the forefront of this transformation. One of the most significant changes in Stripe’s ecosystem was the transition from the Charges API to the Payment Intents API. This shift wasn’t just a cosmetic update – it represented a fundamental reimagining of how online payments should work in an increasingly complex global marketplace.
The Old World: Charges API (2011-2019)
The Simple Days
When Stripe first launched, online payments were relatively straightforward. The Charges API reflected this simplicity:
# The old way - direct charge creation
charge = Stripe::Charge.create({
amount: 2000,
currency: 'usd',
source: 'tok_visa', # Token from Stripe.js
description: 'Example charge'
})
if charge.paid
# Payment succeeded, fulfill order
fulfill_order(charge.id)
else
# Payment failed, show error
handle_error(charge.failure_message)
end
This approach was beautifully simple: create a charge, check if it succeeded, done. The API returned a charge object with an ID like ch_1234567890, and that was your payment.
What Made It Work
The Charges API thrived in an era when:
Card payments dominated – Most transactions were simple credit/debit cards
3D Secure was optional – Strong customer authentication wasn’t mandated
Regulations were simpler – PCI DSS was the main compliance concern
Payment methods were limited – Mostly cards, with PayPal as the main alternative
Mobile payments were nascent – Most transactions happened on desktop browsers
The Cracks Begin to Show
As the payments ecosystem evolved, the limitations of the Charges API became apparent:
Authentication Challenges: When 3D Secure authentication was required, the simple charge-and-done model broke down. Developers had to handle redirects, callbacks, and asynchronous completion manually.
Mobile Payment Integration: Apple Pay and Google Pay required more complex flows that didn’t map well to direct charge creation.
Regulatory Compliance: European PSD2 regulations introduced Strong Customer Authentication (SCA) requirements that the Charges API couldn’t elegantly handle.
Webhook Reliability: With complex payment flows, relying on synchronous responses became insufficient. Webhooks were critical, but the Charges API didn’t provide a cohesive event model.
The Catalyst: PSD2 and Strong Customer Authentication
The European Union’s Revised Payment Services Directive (PSD2), which came into effect in 2019, was the final nail in the coffin for simple payment flows. PSD2 mandated Strong Customer Authentication (SCA) for most online transactions, requiring:
Two-factor authentication for customers
Dynamic linking between payment and authentication
Exemption handling for low-risk transactions
The Charges API, with its synchronous create-and-complete model, simply couldn’t handle these requirements elegantly.
The New Era: Payment Intents API (2019-Present)
A Paradigm Shift
Stripe’s response was revolutionary: instead of treating payments as simple charge operations, they reconceptualized them as intents that could evolve through multiple states:
# The modern way - intent-based payments
payment_intent = Stripe::PaymentIntent.create({
amount: 2000,
currency: 'usd',
payment_method: 'pm_card_visa',
confirmation_method: 'manual',
capture_method: 'automatic'
})
case payment_intent.status
when 'requires_confirmation'
# Confirm the payment intent
payment_intent.confirm
when 'requires_action'
# Handle 3D Secure or other authentication
handle_authentication(payment_intent.client_secret)
when 'succeeded'
# Payment completed, fulfill order
fulfill_order(payment_intent.id)
when 'requires_payment_method'
# Payment failed, request new payment method
handle_payment_failure
end
The Intent Lifecycle
Payment Intents introduced a state machine that could handle complex payment flows:
Payment Intents provide richer webhook events that track the entire payment lifecycle:
# webhook_endpoints.rb
case event.type
when 'payment_intent.succeeded'
handle_successful_payment(event.data.object)
when 'payment_intent.payment_failed'
handle_failed_payment(event.data.object)
when 'payment_intent.requires_action'
notify_customer_action_required(event.data.object)
end
3. Client-Side Integration
The Payment Intents API encouraged better client-side integration through Stripe Elements and mobile SDKs:
Many applications need to update their database schemas to accommodate both old and new payment types:
# Migration to support both charge and payment intent IDs
class AddPaymentIntentSupport < ActiveRecord::Migration[6.0]
def change
add_column :payments, :stripe_payment_intent_id, :string
add_column :payments, :payment_type, :string, default: 'charge'
add_index :payments, :stripe_payment_intent_id
add_index :payments, :payment_type
end
end
# Updated model to handle both
class Payment < ApplicationRecord
def stripe_id
case payment_type
when 'payment_intent'
stripe_payment_intent_id
when 'charge'
stripe_charge_id
end
end
def refundable_charge_id
if payment_type == 'payment_intent'
# Fetch the actual charge ID from the payment intent
pi = Stripe::PaymentIntent.retrieve(stripe_payment_intent_id)
pi.charges.data.first.id
else
stripe_charge_id
end
end
end
Webhook Handler Updates
Webhook handling becomes more sophisticated with Payment Intents:
# Legacy charge webhook handling
def handle_charge_webhook(event)
charge = event.data.object
case event.type
when 'charge.succeeded'
mark_payment_successful(charge.id)
when 'charge.failed'
mark_payment_failed(charge.id)
end
end
# Modern payment intent webhook handling
def handle_payment_intent_webhook(event)
payment_intent = event.data.object
case event.type
when 'payment_intent.succeeded'
# Payment completed successfully
complete_order(payment_intent.id)
when 'payment_intent.payment_failed'
# All payment attempts have failed
cancel_order(payment_intent.id)
when 'payment_intent.requires_action'
# Customer needs to complete authentication
notify_action_required(payment_intent.id, payment_intent.client_secret)
when 'payment_intent.amount_capturable_updated'
# Partial capture scenarios
handle_partial_authorization(payment_intent.id)
end
end
Best Practices for Modern Stripe Integration
1. Embrace Asynchronous Patterns
With Payment Intents, assume payments are asynchronous:
class PaymentProcessor
def create_payment(amount, customer_id, payment_method_id)
payment_intent = Stripe::PaymentIntent.create({
amount: amount,
currency: 'usd',
customer: customer_id,
payment_method: payment_method_id,
confirmation_method: 'automatic',
return_url: success_url
})
# Don't assume immediate success
case payment_intent.status
when 'succeeded'
complete_payment_immediately(payment_intent)
when 'requires_action'
# Send client_secret to frontend for authentication
{ status: 'requires_action', client_secret: payment_intent.client_secret }
when 'requires_payment_method'
{ status: 'failed', error: 'Payment method declined' }
else
# Wait for webhook confirmation
{ status: 'processing', payment_intent_id: payment_intent.id }
end
end
end
2. Implement Robust Webhook Handling
Webhooks are critical for Payment Intents—implement them defensively:
class StripeWebhookController < ApplicationController
protect_from_forgery except: :handle
def handle
payload = request.body.read
sig_header = request.env['HTTP_STRIPE_SIGNATURE']
begin
event = Stripe::Webhook.construct_event(
payload, sig_header, ENV['STRIPE_WEBHOOK_SECRET']
)
rescue JSON::ParserError, Stripe::SignatureVerificationError
head :bad_request and return
end
# Handle idempotently
return head :ok if processed_event?(event.id)
case event.type
when 'payment_intent.succeeded'
PaymentSuccessJob.perform_later(event.data.object.id)
when 'payment_intent.payment_failed'
PaymentFailureJob.perform_later(event.data.object.id)
end
mark_event_processed(event.id)
head :ok
end
private
def processed_event?(event_id)
Rails.cache.exist?("stripe_event_#{event_id}")
end
def mark_event_processed(event_id)
Rails.cache.write("stripe_event_#{event_id}", true, expires_in: 24.hours)
end
end
3. Handle Multiple Payment Methods Gracefully
Payment Intents excel at handling diverse payment methods:
Payment Intents provide detailed error information:
def handle_payment_error(payment_intent)
last_payment_error = payment_intent.last_payment_error
case last_payment_error&.code
when 'authentication_required'
# Redirect to 3D Secure
redirect_to_authentication(payment_intent.client_secret)
when 'card_declined'
decline_code = last_payment_error.decline_code
case decline_code
when 'insufficient_funds'
show_error("Insufficient funds on your card")
when 'expired_card'
show_error("Your card has expired")
else
show_error("Your card was declined")
end
when 'processing_error'
show_error("A processing error occurred. Please try again.")
else
show_error("An unexpected error occurred")
end
end
The Future: What’s Next?
1. Embedded Payments
Stripe continues to innovate with embedded payment solutions that make Payment Intents even more powerful:
As real-time payment networks like FedNow and Open Banking expand, Payment Intents provide the flexibility to support these new methods seamlessly.
3. Cross-Border Optimization
Payment Intents are evolving to better handle multi-currency and cross-border transactions with improved routing and local payment method support.
Key Takeaways for Developers
Payment Intents are the future: If you’re building new payment functionality, start with Payment Intents, not Charges.
Embrace asynchronous patterns: Don’t expect payments to complete immediately. Design your system around webhooks and state management.
Handle the ID confusion: Remember that Payment Intents (pi_) contain Charges (ch_). Refunds and some other operations still work on charge IDs.
Implement robust webhook handling: With complex payment flows, webhooks become critical infrastructure, not nice-to-have features.
Test thoroughly: The increased complexity of Payment Intents requires more comprehensive testing, especially around authentication flows and edge cases.
Monitor proactively: Use Stripe’s dashboard and logs extensively during development and deployment to understand payment flow behavior.
Conclusion
The evolution from Stripe’s Charges API to Payment Intents represents more than just a technical upgrade—it’s a fundamental shift toward a more flexible, regulation-compliant, and globally-aware payment processing model. While the migration requires thoughtful planning and careful implementation, the benefits in terms of supported payment methods, authentication handling, and regulatory compliance make it essential for any serious payment processing application.
The key is to approach the migration systematically: understand the differences, plan for the ID confusion, implement robust webhook handling, and test extensively. With these foundations in place, Payment Intents unlock capabilities that simply weren’t possible with the older Charges API.
As global payment regulations continue to evolve and new payment methods emerge, Payment Intents provide the architectural flexibility to adapt and grow. The initial complexity investment pays dividends in long-term maintainability and feature capability.
For developers still using the Charges API, the writing is on the wall: it’s time to embrace the future of payment processing with Payment Intents.
Have you encountered similar challenges migrating from Charges to Payment Intents? What patterns have worked best in your applications? Share your experiences in the comments below.
Ruby is one of the few languages where classes are objects, capable of holding both instance behavior and class-level behavior. This flexibility comes from a powerful internal structure: the singleton class, also known as the eigenclass. Every Ruby object has one — including classes themselves.
When developers write class << self, they are opening a special, hidden class that Ruby uses to store methods that belong to the class object, not its instances. This technique is the backbone of Ruby’s expressive meta-programming features and is used heavily in Rails, Sidekiq, ActiveRecord, RSpec, and nearly every major Ruby framework.
This article explains why Ruby has singleton classes, what they enable, and when you should use class << self instead of def self.method for defining class-level behavior.
In Ruby, writing:
class Payment; end
creates an object:
Payment.instance_of?(Class) # => true
Since Payment is an object, it can have:
Its own methods
Its own attributes
Its own included modules
Just like any other object.
Ruby stores these class-specific methods in a special internal structure: the singleton class of Payment.
When you define a class method:
def self.process
end
Ruby is actually doing this under the hood:
Open the singleton class of Payment
Define process inside it
So:
class << Payment
def process; end
end
and:
def Payment.process; end
and:
def self.process; end
All do the same thing.
But class << self unlocks far more power.
Each Ruby object has:
[ Object ] ---> [ Singleton Class ] ---> [ Its Class ]
Instance methods live in Payment. Class methods live in Payment's eigenclass.
The eigenclass is where Ruby stores:
Class methods
Per-object overrides
Class-specific attributes
DSL behaviors
class << self
def load; end
def export; end
def sync; end
end
Cleaner than:
def self.load; end
def self.export; end
def self.sync; end
This is a huge advantage.
class << self
private
def connection_pool
@pool ||= ConnectionPool.new
end
end
Using def self.method cannot make the method private — Ruby doesn’t allow it.
class << self
include CacheHelpers
end
This modifies class-level behavior, not instance behavior.
Rails uses this technique everywhere.
You must open the eigenclass:
class << self
def new(*args)
puts "Creating a new Payment!"
super
end
end
This cannot be done properly with def self.new.
class << self
attr_accessor :config
end
Usage:
Payment.config = { currency: "USD" }
This config belongs to the class itself.
Example from ActiveRecord:
class << self
def has_many(name)
# defines association
end
end
Or RSpec:
class << self
def describe(text, &block)
# builds DSL structure
end
end
When you write:
class Order < ApplicationRecord
has_many :line_items
end
Internally Rails does:
class Order
class << self
def has_many(name)
# logic here
end
end
end
This is how Rails builds its elegant DSL.
class << self
def before_save(method_name)
set_callback(:save, :before, method_name)
end
end
Again, these DSL methods live in the singleton class.
✅ Use def self.method_name when:
Only defining 1–2 methods
Simpler readability is preferred
✅ Use class << self when:
You have many class methods
You require private class methods
You need to include modules at class level
You are building DSLs or metaprogramming-heavy components
You need to override class-level behavior (new, allocate)
Opening a class’s singleton class (class << self) is not just a stylistic choice — it is a powerful meta-programming technique that lets you modify the behavior of the class object itself. Because Ruby treats classes as first-class objects, their singleton classes hold the key to defining class methods, private class-level utilities, DSLs, and dynamic meta-behavior.
Understanding how and why Ruby uses the eigenclass gives you deeper insight into the design of Rails, Sidekiq, ActiveRecord, and virtually all major Ruby libraries.
It’s one of the most elegant aspects of Ruby’s object model — and one of its most powerful once mastered.