Rails Encrypted Credentials: The Git Diff Feature You May Have Been Using Without Knowing

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.

Conceptually:

credentials.yml.enc
        │
        │ decrypt
        ▼
   Plain YAML
        │
        │ edit
        ▼
   Plain YAML
        │
        │ encrypt
        ▼
credentials.yml.enc

The plaintext credentials aren’t saved as a normal file.

But What Happens With git diff?

Here’s the interesting part.

If Git simply compared the encrypted files, we’d get something useless:

- 3d9Jx8...random-encrypted-data...
+ 7kP2mL...different-encrypted-data...

We wouldn’t know:

  • Which credential changed?
  • Was a key added?
  • Was a key removed?
  • Did the API key change?
  • Did somebody accidentally modify something?

This is where Rails’ credentials diff integration becomes useful.

Rails + Git textconv

Rails can configure Git to use a special diff driver:

[diff "rails_credentials"]
textconv = bin/rails credentials:diff

In my Rails 8.1 application, I found exactly this in:

.git/config

Git therefore doesn’t simply compare the encrypted contents.

Instead:

git diff
    │
    ▼
Git sees credentials.yml.enc
    │
    ▼
rails_credentials diff driver
    │
    ▼
bin/rails credentials:diff
    │
    ▼
Rails decrypts the credentials
    │
    ▼
Git displays a readable diff

Git itself doesn’t understand Rails encryption.

Rails is providing the text conversion command. Git simply knows how to invoke it.

See It Yourself

Suppose our credentials originally contain:

openai:
api_key: OLD_KEY

We change it to:

openai:
api_key: NEW_KEY

Now:

git diff

can show a useful diff such as:

+
+openai:
+ api_key: 'sdsdssdsdsdwewewddvcfgfgth'

That’s much more useful than comparing encrypted bytes.

The Experiment That Makes This Obvious

This is what made the behavior click for me.

Run:

git diff -- config/credentials.yml.enc

You get the human-readable credentials diff.

Now bypass Git’s text conversion:

git diff --no-textconv -- config/credentials.yml.enc

Now you see the encrypted content.

Something like:

3d9Jx8...encrypted-data...

That’s the proof.

The file itself is still encrypted.

It’s only the diff representation that’s being transformed.

So What Exactly Does Git Know?

Git doesn’t know anything about:

Rails
credentials
master.key
AES
encryption
decryption

Git knows:

diff driver
textconv

Rails configures:

rails_credentials

and tells Git:

When displaying a diff for this file,
run:
bin/rails credentials:diff

That’s a very nice example of two independent tools cooperating:

             Rails
               │
               │ provides
               ▼
       credentials:diff
               │
               ▼
             Git
               │
               │ uses
               ▼
           textconv

How Does Rails Configure It?

Rails provides:

bin/rails credentials:diff --enroll

This enrolls the project in credentials diffing.

The Git attributes include:

config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials

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)

Does This Make My Secrets Unsafe?

No – provided you protect the master key.

The important distinction is:

Git repository
│
├── credentials.yml.enc
│       ↓
│   encrypted
│
└── master.key
        ↓
     SECRET

The encrypted file can be committed.

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.

For example:

git diff

could display:

+
+openai:
+  api_key: 'sdsdssdsdsdwewewddvcfgfgth'

So don’t casually share terminal screenshots containing credential diffs.

Also be careful when copying terminal output into:

  • Slack
  • GitHub issues
  • Pull requests
  • screenshots
  • blog posts
  • AI assistants

NOTE: The encryption protects the file stored in Git, but a decrypted diff is plaintext.

Rails Developer Takeaway

There are three different things here:

1. Encrypted file

config/credentials.yml.enc

This is what is actually stored in Git.

2. Encryption key

config/master.key

This decrypts the credentials and must remain secret.

3. Git diff representation

bin/rails credentials:diff

This is what allows us to see meaningful changes locally.

So:

                 GitHub
                   │
                   │ encrypted
                   ▼
       credentials.yml.enc
                   ▲
                   │
             master.key
             stays secret


Local git diff:

credentials.yml.enc
        │
        ▼
credentials:diff
        │
        ▼
decrypted representation
        │
        ▼
human-readable diff

Try This Yourself

If you’re working on a Rails application, check:

git config --show-origin --get-regexp 'diff|textconv|filter'

You may find:

file:.git/config diff.rails_credentials.textconv bin/rails credentials:diff

Then:

git diff --no-textconv -- config/credentials.yml.enc

Compare that with:

git diff -- config/credentials.yml.enc

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)

📚 References

Happy Coding!

Rails Database Transactions: A Senior Developer’s Guide to Atomicity, Rollbacks, Savepoints and Transaction-Safe Design

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.transaction do
user.update!(name: "Abhilash")
Profile.create!(user: user)
end

This might look like a different mechanism from:

User.transaction do
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.transaction do
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.transaction do
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.transaction do
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.transaction do
create_user!
create_profile!
end
rescue StandardError => 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.transaction do
user.update!(status: "processing")
unless payment_valid?
raise ActiveRecord::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)

This makes it useful when you intentionally want:

rollback database changes
+
don't treat this as an application exception

For example:

Order.transaction do
order.update!(status: "processing")
raise ActiveRecord::Rollback unless inventory_available?
end

raise vs ActiveRecord::Rollback

Compare:

raise PaymentError

with:

raise ActiveRecord::Rollback

The semantic difference is significant.

Normal exception

raise PaymentError

Result:

ROLLBACK
exception propagates
caller can rescue it

ActiveRecord::Rollback

raise ActiveRecord::Rollback

Result:

ROLLBACK
rollback is internally handled
execution exits transaction

Therefore, don’t blindly replace business exceptions with ActiveRecord::Rollback.


Nested Transactions

Consider:

ApplicationRecord.transaction do
user.save!
ApplicationRecord.transaction do
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.transaction do
User.create!(name: "A")
User.transaction do
User.create!(name: "B")
raise ActiveRecord::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.transaction do
user = User.create!
ApplicationRecord.transaction(requires_new: true) do
AuditLog.create!
raise ActiveRecord::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.transaction do
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:

class AuditService
def self.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.transaction do
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_lock do
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)

Conceptually:

BEGIN
SELECT ... FOR UPDATE
modify row
COMMIT

This is useful for operations such as:

account.with_lock do
raise InsufficientFunds unless account.balance >= amount
account.update!(
balance: account.balance - amount
)
end

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.transaction do
account.update!(balance: ...)
end

with:

account.with_lock do
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:

class Order < ApplicationRecord
after_save :publish_order
def publish_order
EventBus.publish(id)
end
end

Suppose:

Order.transaction do
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:

class Order < ApplicationRecord
after_commit :publish_order
private
def publish_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.transaction do |transaction|
order.update!(status: "confirmed")
transaction.after_commit do
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_commit do
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_commit do
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:

class PublishArticle
def self.call(article)
article.update!(published: true)
Article.current_transaction.after_commit do
SearchIndexer.index(article)
end
end
end

Now:

PublishArticle.call(article)

works both:

outside transaction

and:

Article.transaction do
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.transaction do
begin
User.create!(email: "existing@example.com")
rescue ActiveRecord::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.transaction do
create_user!
create_profile!
end
rescue ActiveRecord::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.transaction do
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.transaction do
user.update!
AnalyticsEvent.transaction do
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.transaction do
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.transaction do
order.update!(status: "confirmed")
order.after_commit do
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.transaction do
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.transaction do
create_order!
reserve_inventory!
record_payment!
end

That’s a meaningful transaction.

But this:

User.transaction do
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)
}.to raise_error(PaymentError)
expect(Order.count).to eq(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.transaction do
end

Transaction APIs at a Glance

APIMain purposeTypical usage
Model.transactionTransaction around a unit of workService objects
instance.transactionTransaction associated with a model instanceModel-centric workflows
ApplicationRecord.transactionApplication-wide transaction boundaryShared models
transaction(requires_new: true)Independent nested savepointIsolating sub-operations
transaction(isolation: :serializable)Stronger concurrency guaranteesHighly concurrent workflows
with_lockTransaction + row lockBalance/inventory updates
after_commitRun code after commitExternal side effects
after_rollbackReact to rollbackCleanup/recovery logic
transaction.after_commitCallback attached to a specific transactionService/domain workflows
ActiveRecord.after_all_transactions_commitRun after outermost transaction chain commitsTransaction-aware reusable services
current_transaction.after_commitMake services transaction-awareReusable 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.transaction do
...
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_lock do
...
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.

Consider:

outbox
events
idempotency
retries
compensating actions

instead.


Common Transaction Mistakes

1. Putting HTTP calls inside transactions

Order.transaction do
order.save!
PaymentGateway.charge!
end

Avoid long-running external calls inside database transactions.

2. Assuming nested transactions are independent

transaction do
transaction do
end
end

The inner block normally participates in the outer transaction.

Use:

transaction(requires_new: true)

when you specifically need savepoint-based isolation.

3. Publishing events from after_save

Bad:

after_save :publish_event

for external systems that require committed data.

Prefer:

after_commit :publish_event

4. Rescuing database errors inside the transaction

Bad:

transaction do
begin
risky_database_operation
rescue ActiveRecord::StatementInvalid
end
another_database_operation
end

For PostgreSQL in particular, leave the failed transaction and retry/recover at a higher level.

5. Assuming transactions protect in-memory Ruby objects

Suppose:

user = User.find(1)
User.transaction do
user.update!(name: "New Name")
raise ActiveRecord::Rollback
end

The database row is rolled back.

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.


Rails Takeaways

The important lesson isn’t:

“Use .transaction when you have multiple saves.”

The deeper mental model is:

                 Transaction

┌───────────┴───────────┐
│ │
Atomicity Concurrency
│ │
commit/rollback locks/isolation
│ │
└───────────┬───────────┘

Application
boundary

┌───────────┴────────────┐
│ │
DB operations external systems
│ │
transaction after_commit

A senior Rails developer should decide transaction boundaries deliberately.

The questions I would ask during a code review are:

What exactly must be atomic?
Which database connection is involved?
Can this service be called inside another transaction?
Does this nested transaction really need requires_new?
Are we holding locks longer than necessary?
Could a database constraint failure leave the transaction unusable?
Are we calling an external service before commit?
Can a background job observe uncommitted data?
Are multiple databases involved?
Do we need a lock, or is a transaction alone sufficient?
What happens when this operation is executed concurrently?

That is where transaction knowledge moves from Rails syntax to system design.


Final Mental Model

Think about Rails transactions in five layers:

1. transaction
Atomic unit of database work
2. rollback
Undo database changes when work fails
3. requires_new / savepoints
Isolate nested database work
4. locks / isolation
Control concurrent behavior
5. after_commit
Safely interact with the world outside the DB

Mastering these five concepts gives you most of what you need to design transaction-safe Rails applications.

And the biggest senior-level principle is simple:

Transactions should protect business invariants, not merely surround database code.

That distinction is what separates knowing the Rails transaction API from designing reliable transactional systems.

References

  • Rails Active Record Transactions API and transaction semantics. (Ruby on Rails API)
  • Rails transaction callbacks, after_commit, after_rollback, per-transaction callbacks, and after_all_transactions_commit. (Ruby on Rails Guides)
  • Rails ActiveRecord::Transaction API, including transaction-aware callbacks and current_transaction. (Ruby on Rails API)
  • Rails pessimistic locking and with_lock. (Ruby on Rails API)

Happy Implementing!

Ruby’s Mysterious Symbols: The Syntax Every Ruby Developer Should Truly Understand

Ruby is famous for making code expressive.

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

Or shell commands:

command = <<~BASH
  echo "Starting deployment"
  bundle exec rails db:migrate
  echo "Deployment complete"
BASH

The senior-level takeaway

<<~ isn’t merely a formatting convenience.

It lets the Ruby source code remain properly indented without contaminating the resulting string with that indentation.


2. <<- vs <<~ vs <<

Ruby actually has several heredoc variants.

<<TEXT
...
TEXT

Strict terminator placement.

<<-TEXT
...
  TEXT

Allows the terminator to be indented.

<<~TEXT
...
  TEXT

Allows indentation and removes common indentation from the resulting string.

So in modern Ruby code, <<~ is generally the most readable choice for indented multiline strings.

Read more here: https://railsdrop.com/ruby-more-about-ruby-hearedoc-questions-and-answers/


3. %i[...] – Creating Arrays of Symbols

This:

%i[admin editor viewer]

creates:

[:admin, :editor, :viewer]

Similarly:

%w[admin editor viewer]

creates:

["admin", "editor", "viewer"]

The % syntax is Ruby’s percent literal syntax.

Common forms

%w[one two three]     # strings
%i[one two three]     # symbols
%W[hello #{name}]     # interpolated strings
%I[hello #{name}]     # interpolated symbols

This:

%i[read write delete]

is often cleaner than:

[:read, :write, :delete]

Especially when the list becomes long:

ALLOWED_ROLES = %i[
  admin
  manager
  editor
  viewer
].freeze

Read more here: https://railsdrop.com/ruby-more-about-rubys-percent-literal-syntax/


4. &. – The Safe Navigation Operator

One of the most recognizable Ruby operators:

user&.profile&.address&.city

It means:

Call the next method only if the receiver isn’t nil.

Instead of:

if user
  if user.profile
    if user.profile.address
      user.profile.address.city
    end
  end
end

Ruby lets you write:

user&.profile&.address&.city

But don’t blindly use it

This is an important senior-level distinction.

If the business logic says:

A user must have a profile.

then this:

user&.profile&.address

may hide a data integrity problem.

Sometimes you actually want:

user.profile.address

so that invalid state fails loudly.

Good use

Optional data:

current_user&.avatar&.url

Potentially bad use

Required relationships:

order&.customer&.account&.billing_address

If all those associations are supposed to exist, safe navigation may simply hide broken application state.

Use &. when nil is genuinely expected – not merely because it prevents exceptions.


5. &:method – Symbol-to-Proc Conversion

You’ve probably seen:

users.map(&:email)

It looks strange initially.

It’s effectively shorthand for:

users.map { |user| user.email }

Ruby converts:

:email

into a callable block using &.

So:

users.map(&:email)

is approximately:

users.map { |user| user.email }

Another example

numbers.select(&:even?)

is equivalent to:

numbers.select { |number| number.even? }

Important distinction

These are not the same:

users.map(:email)

and:

users.map(&:email)

The & tells Ruby:

Convert this object into a Proc and pass it as the block.


6. _1, _2, _3 – Numbered Parameters

Modern Ruby provides implicit block parameters.

Instead of:

users.map { |user| user.email }

you can write:

users.map { _1.email }

_1 means:

The first block argument.

Similarly:

array.map { |value, index| ... }

can conceptually be accessed using:

_1
_2

For example:

[10, 20, 30].map { _1 * 2 }

produces:

[20, 40, 60]

Where it works well

Small transformations:

users.map { _1.email }
orders.select { _1.total >> 1000 }
names.map { _1.upcase }

Where it becomes bad

Complex blocks:

users.map { _1.orders.select { _2.paid? }.map { _1.total } }

At this point, explicit names are much easier to understand.

users.map do |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:

def log(*args, **kwargs, &block)
puts "Calling method"
super
end

Modern Ruby allows forwarding arguments directly:

def log(...)
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

def sum(*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:

def connect(**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 =>>>>.

case response
in { status: 200, body: String =>> body }
puts body
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:

case value
in 1 | 2 | 3
puts "Small number"
end

means:

Match 1 OR 2 OR 3.

This makes pattern matching expressive:

case status
in 200 | 201 | 204
puts "Success"
in 400 | 401 | 403
puts "Client error"
end

14. =>>>> in Pattern Matching Can Bind Values

Consider:

case result
in Integer =>> value
puts value
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:

def hello!
"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:

def expensive_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:

if logger.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.then do |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.each do |user, _index|
puts user.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:

def full_name = "#{first_name} #{last_name}"

instead of:

def full_name
"#{first_name} #{last_name}"
end

This is called an endless method definition.

It’s excellent for very small methods:

def active? = status == "active"
def total = price * quantity

But don’t use it for complex logic.

This:

def process = 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:

if user && 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:

case response
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.

For example:

users.map { _1.orders.select(&:paid?).sum(&:total) }

is valid Ruby.

But:

users.map do |user|
  user.orders
      .select(&:paid?)
      .sum(&:total)
end

may be more readable.

And sometimes the best version is:

users.map do |user|
  paid_orders = user.orders.select(&:paid?)
  paid_orders.sum(&:total)
end

Ruby gives you enormous freedom.

Good Ruby isn’t the shortest Ruby.

Good Ruby is code where another experienced developer can understand the intent quickly.


Final Takeaway

Ruby 3.4 contains a rich collection of compact syntax:

<<~TEXT       # squiggly heredoc
%i[...]       # symbol array
%w[...]       # string array
&.            # safe navigation
&:method      # symbol-to-proc
_1            # numbered parameter
*args         # positional splat
**kwargs      # keyword splat
...           # argument forwarding
1...10        # exclusive range
x ||= value   # conditional assignment
def foo = ... # endless method
case x; in... # pattern matching
=>            # hash rocket / pattern binding / rightward assignment

These aren’t merely Ruby “shortcuts.”

They represent Ruby’s broader design philosophy:

Make the code express what the programmer means, while keeping the syntax close to natural language.

For a senior Ruby/Rails developer, the goal isn’t to remember every symbol.

The goal is to recognize when a piece of Ruby syntax improves the expression of intent – and when it merely makes the code clever.

That distinction is what separates knowing Ruby syntax from writing idiomatic, maintainable Ruby.

Happy Rubying!~

Integrate AI with Rails: AI bootcamp for Developers – Day 4 -Practical Course – Part 3

Great. Now we can make the first real LLM request.

We’ll keep this step deliberately small. Our goal is not to build the complete AI assistant yet.

The goal is simply:

Rails
Ai::Client
OpenAI API
LLM
Response

Once this works, we’ll build the Rails service layer around it.

Step 5.11 – Add the OpenAI Ruby SDK

Rather than manually constructing HTTP requests, we’ll start with the official Ruby SDK.

1. Add the gem

Open our Gemfile and add:

gem "openai"

Then run:

bundle install

Verify:

bundle info ruby-openai

You should see where Bundler installed the gem.

Why use an SDK?

We could use Ruby’s Net::HTTP ourselves:

Ruby
Net::HTTP
HTTP request
OpenAI

But then we’d have to manually handle:

  • authentication headers
  • JSON encoding
  • HTTP errors
  • response parsing
  • request formatting

The SDK gives us:

Ruby
OpenAI Ruby SDK
HTTP
OpenAI

Important point: An SDK doesn’t eliminate the HTTP API. It is an abstraction over it.

Step 5.12 – Verify the gem

Run:

bin/rails console

Then:

require "openai"

It should return:

=> true

or possibly:

=> "openai"

depending on the gem’s load behavior.

Then:

OpenAI

should resolve without a NameError.

Exit:

exit

Step 5.13 – Let’s inspect the SDK before using it

This is something I want you to develop as a senior Ruby developer habit.

Instead of blindly copying code from a blog, let’s see what API the installed gem exposes.

Run:

bundle info ruby-openai

Then:

bin/rails console

Inside console:

require "openai"

Then:

OpenAI::Client.instance_method(:initialize).parameters

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

Now we have:

Ai::Client
   │
   ├── reads Rails credentials
   │
   └── creates OpenAI SDK client

Step 5.15 – Test initialization

Run:

bin/rails console

Then:

client = Ai::Client.new

It should return something similar to:

#<Ai::Client:0x...>

No request has happened yet.

That’s important.

We’ve only done:

Rails credentials
API key
OpenAI::Client

Stop here

Don’t call the LLM yet.

I want you to complete these steps first:

1. Gemfile

gem "openai"

2. Install

bundle install

3. Verify

bundle info ruby-openai

4. Update

app/services/ai/client.rb

with the code above.

5. Test

bin/rails c
client = Ai::Client.new

One note

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.

8. One thing to notice

We’ve built:

app/services/ai/client.rb

and now:

Ai::Client.new.chat(...)

works.

That’s already a valuable architectural boundary:

Rails application
Ai::Client
OpenAI SDK
OpenAI API

Our controllers won’t need to know:

  • how authentication works,
  • how the SDK works,
  • which API endpoint is used,
  • how OpenAI responses are represented.

That’s why we created the abstraction.

Check our code in this repo: https://github.com/abhilashak/ai_assistant


Stop Here

Run these commands one by one:

bin/rails c
client = Ai::Client.new
response = client.chat(
message: "Explain Ruby blocks in simple terms."
)

Then inspect:

response.output_text
response.model
response.usage
response.usage.input_tokens
response.usage.output_tokens

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.


Integrate AI with Rails: AI bootcamp for Developers – Day 4 -Practical Course – Part 2

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:

SELECT role FROM 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:

class Message < ApplicationRecord
belongs_to :conversation
end

Change it to:

class Message < 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
WHERE role = '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:

user
assistant
system

Someone could execute:

INSERT INTO messages (conversation_id, role, content)
VALUES (1, 'invalid', '...');

directly against PostgreSQL.

The database would currently allow it.

This leads to an important senior-engineering principle:

Application-level validation and database-level integrity are complementary.

We’ll add a database constraint.

But don’t do that yet. First make sure the Rails enum works.

After that, we’re finally ready for the exciting part:

Rails
Ai::Client
LLM API
Real AI response

Now let’s strengthen the model at the database level.

You currently have Rails validation:

enum :role, {
user: "user",
assistant: "assistant",
system: "system"
}, validate: true

That’s good, but a senior Rails application shouldn’t rely only on model validation for important data integrity.

Step 4 – Add Database Constraints

We want PostgreSQL itself to enforce:

role MUST be:
user
assistant
system

and:

content MUST NOT be NULL
role MUST NOT be NULL

This gives us two layers:

Rails
Model validation
PostgreSQL
Database constraint

4.1 Why NULL matters

Currently this is possible at the database level:

role = NULL

But an AI message without a role doesn’t make sense.

Likewise:

content = NULL

doesn’t represent a meaningful message.

So we’ll make both required.

4.2 Create a new migration

Don’t modify the old migration because it has already been executed and committed.

Generate a new migration:

bin/rails generate migration AddMessageConstraints

Rails should create:

db/migrate/XXXXXXXXXXXXXX_add_message_constraints.rb

Open that file.

4.3 Add NOT NULL constraints

Put this inside change:

class AddMessageConstraints < ActiveRecord::Migration[8.1]
def change
change_column_null :messages, :role, false
change_column_null :messages, :content, false
end
end

So conceptually:

def change
change_column_null :messages, :role, false
change_column_null :messages, :content, false
end

4.4 Add PostgreSQL CHECK constraint

Now we want PostgreSQL to enforce:

role IN ('user', 'assistant', 'system')

Add:

add_check_constraint(
:messages,
"role IN ('user', 'assistant', 'system')",
name: "messages_role_check"
)

Our migration becomes:

class AddMessageConstraints < ActiveRecord::Migration[8.1]
def change
change_column_null :messages, :role, false
change_column_null :messages, :content, false
add_check_constraint(
:messages,
"role IN ('user', 'assistant', 'system')",
name: "messages_role_check"
)
end
end

4.5 Run the migration

Execute:

bin/rails db:migrate

You should see Rails successfully applying the migration.

4.6 Inspect PostgreSQL

This is worth doing because understand what’s actually happening underneath Rails.

Run:

bin/rails dbconsole

Then:

\d messages

Look toward the bottom.

You should see a check constraint similar to:

messages_role_check
CHECK ((role)::text = ANY (...))

The exact display can vary by PostgreSQL version.

Also check:

\d+ messages

4.7 Test the database constraint

Now let’s prove that PostgreSQL protects us even if Rails is bypassed.

Inside psql, try:

INSERT INTO messages
(conversation_id, role, content, created_at, updated_at)
VALUES
(1, 'invalid', 'This should fail', NOW(), NOW());

You should get an error similar to:

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.

4.9 Test a valid message

Exit psql:

\q

Then:

bin/rails c

Run:

conversation = Conversation.first

Then:

message = conversation.messages.create(
role: :user,
content: "What is Ruby?"
)

Check:

message.persisted?

You should get:

true

And:

message.role

should return:

"user"

Stop Here

Please do these in order:

bin/rails generate migration AddMessageConstraints

Edit the migration with the constraints above.

Then:

bin/rails db:migrate

Verify with:

bin/rails dbconsole
\d messages

Then test the invalid role directly in PostgreSQL.

Finally:

git add app/models/message.rb db/migrate
git commit -m "feat: validate message roles"
git push

NOW: “Message constraints are done.”

Then we move to the big milestone: Our First Real LLM API Call


Excellent. We now have a clean foundation:

Ruby 3.4.1
Rails 8.1
PostgreSQL
Conversation
└── Message
├── role
├── content
├── model
├── input_tokens
└── output_tokens
Ai::Client

Now we reach the first real AI step.

Step 5 – Make Our First LLM API Call

We’re going to do this in a deliberately controlled way.

Don’t build the Chat UI yet.

First, we need to understand:

Ruby
Ai::Client
HTTP request
LLM provider
HTTP response
Ruby

Once we understand this, we’ll wrap it nicely into Rails architecture.

5.1 First decision – which provider?

For this practical course, let’s start with OpenAI.

Not because you must use OpenAI in production, but because it gives us a straightforward API to understand the fundamentals.

Later we’ll discuss:

Rails
├── OpenAI
├── Anthropic
└── Gemini

and how to design our Ai::Client so that we’re not tightly coupled to one provider.

5.2 Before writing code – understand the request

Conceptually, we’re going to send something like:

POST /v1/responses
{
"model": "...",
"input": "Explain Ruby blocks in simple terms."
}

The provider’s server processes the request:

Rails
│ HTTPS
OpenAI API
LLM
Response

The important thing to understand is:

An LLM API is an HTTP API.

The Ruby SDK is just a convenient abstraction around HTTP.

5.3 Check our Ai::Client

You already created:

app/services/ai/client.rb

Open it.

If it currently contains nothing useful, that’s completely fine.

For now, make it:

# app/services/ai/client.rb

class Ai::Client
end

Don’t add API code yet.

5.4 Configure the API key securely

Do not put our API key in Ruby source code.

We have two common approaches:

Environment variables

or:

Rails encrypted credentials

For this project, I’m going to use Rails encrypted credentials because it’s a good opportunity to understand how Rails handles secrets.

5.5 Create Rails encrypted credentials

Run:

➜  ai_assistant git:(main) ✗ VISUAL="code --wait" rails credentials:edit

Rails will open our configured editor.

Add:

openai:
api_key: OUR_OPENAI_API_KEY

For example:

openai:
api_key: sk-xxxxxxxxxxxxxxxx

Use our actual API key locally, but never paste it into this conversation or commit it to GitHub.

Save and close the editor

What’s actually happening?

Rails creates/uses:

config/credentials.yml.enc

This file is encrypted.

Our encryption key is stored separately in:

config/master.key

The important rule is:

config/credentials.yml.enc
COMMIT
GitHub

is okay.

But:

config/master.key

should never be committed to GitHub.

Check:

git status

You should not see:

config/master.key

as a file to commit.

5.6 Verify Rails can read the key

Run:

bin/rails console

Then:

Rails.application.credentials.dig(:openai, :api_key)

You should get our key back, just verify that it returns a string rather than nil.

Then:

exit

5.7 Why use dig?

Our credentials structure is:

openai:
api_key: ...

which Rails exposes approximately as:

{
openai: {
api_key: "..."
}
}

So:

Rails.application.credentials.dig(:openai, :api_key)

means:

credentials
openai
api_key

This is cleaner than accessing nested values manually.

5.8 Now configure Ai::Client

Open:

app/services/ai/client.rb

Change it to:

class Ai::Client
def initialize
@api_key = Rails.application.credentials.dig(:openai, :api_key)
end
end

Now the client knows how to retrieve its secret.

5.9 Add a safety check

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:

#<Ai::Client:0x...>

No API request has happened yet.

We’re only testing:

Rails credentials
Ai::Client

Check our code in this repo: https://github.com/abhilashak/ai_assistant


Stop Here

Don’t make the API request yet.

complete only these steps first:

1. Configure credentials

bin/rails credentials:edit

with:

openai:
api_key: OUR_KEY

2. Verify:

bin/rails console
Rails.application.credentials.dig(:openai, :api_key)

Don’t show me the key.

3. Update:

app/services/ai/client.rb

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?
end
end

4. Test:

client = Ai::Client.new

Now: “Ai::Client credentials is done.”

Next topic: Step 5.11: install/configure the OpenAI Ruby client and make the first actual LLM request.

to be continued ..

Understanding Enums: Why They Exist, How They Work, and How Rails Implements Them

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:

enum status: { 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:

enum status: {
pending: 0,
processing: 1,
completed: 2
}

Now Rails only allows known states.

2. Improve Readability

Compare:

if order.status == 2

vs

if order.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

enum Status {
Pending,
Processing,
Completed
}

Example: Java Enum

enum Status {
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:

class Order < ApplicationRecord
enum status: {
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

enum status: [: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:

enum status: {
pending: 0,
processing: 1,
completed: 2
}

String-Based Enums in Rails

Rails also supports string-backed enums:

enum status: {
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:

define_method("completed?") do
status == "completed"
end

and:

scope :completed, -> { where(status: 2) }

This is why enums feel “magical.”

🚨 Limitations of Rails Enums

Enums are useful, but not perfect.

1. Hard to evolve complex workflows

If states become complicated:

pending -> approved -> shipped -> refunded -> disputed

you may need:

  • state machines
  • workflow engines

Examples:

  • aasm
  • state_machines

2. Integer values can become opaque

DB shows:

status = 2

Harder to debug directly.

3. No DB-level validation by default

Rails validates at app layer, but DB still accepts:

status = 999

unless constrained.

🛡️ Best Practices for Rails Enums

Use explicit mappings

enum status: {
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.

Happy Implementing! 🚀

Sidekiq & Redis Optimization: Reducing Overhead and Scaling Worker Jobs

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:

# ❌ Bad: 10,000 Redis writes, 10,000 scheduled entries
user_ids.each do |id|
WeeklyEmailWorker.perform_async(id)
end

Each perform_async does:

  • A Redis LPUSH (or ZADD for scheduled jobs)
  • Serialization of job payload
  • Network round-trip

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:

# ✅ Good: 100 Redis writes, 100 scheduled entries
BATCH_SIZE = 100
BATCH_DELAY = 0.2 # seconds
pending_user_ids.each_slice(BATCH_SIZE).with_index do |batch_ids, batch_index|
delay_seconds = batch_index * BATCH_DELAY
WeeklyEmailByWorker.perform_in(delay_seconds, batch_ids)
end

What this achieves:

MetricBefore (1 per user)After (batched)
Redis ops10,000100
Scheduled jobs10,000100
Scheduled jobs at 1M users1,000,00010,000

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.

4. Rate Limiting (Sidekiq Enterprise)

Throttle jobs that hit external APIs:

class EmailWorker
include Sidekiq::Worker
sidekiq_options throttle: { threshold: 100, period: 1.minute }
end

Prevents hitting rate limits and keeps Redis usage predictable.

5. Unique Jobs (sidekiq-unique-jobs)

Avoid duplicate jobs for the same work:

sidekiq_options lock: :until_executed, on_conflict: :log

Reduces redundant work and Redis load when jobs are retried or triggered multiple times.

6. Dead Job Cleanup

Dead jobs accumulate in Redis. Set retention and cleanup:

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.death_handlers << ->(job, ex) {
# Log, alert, or move to DLQ
}
end

Use dead_max_jobs and periodic cleanup so Redis doesn’t grow unbounded.

7. Job Size Limits

Large payloads increase Redis memory and serialization cost:

# Keep payloads small; pass IDs, not full objects
WeeklyEmailWorker.perform_async(user_id) # ✅
WeeklyEmailWorker.perform_async(user.to_json) # ❌

8. Connection Pooling

Ensure each worker process has a bounded Redis connection pool:

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.redis = { url: ENV['REDIS_URL'], size: 25 }
end

Prevents connection exhaustion under load.

9. Scheduled Job Limits

Scheduled jobs live in Redis. If you schedule millions of jobs, you may need to cap or paginate:

# Avoid scheduling 1M jobs at once
# Use batch + perform_in with reasonable batch sizes

10. Redis Memory and Eviction

Configure Redis for Sidekiq:

maxmemory 2gb
maxmemory-policy noeviction # or volatile-lru for cache-only keys

Monitor memory and eviction to avoid unexpected data loss.


Summary

StrategyWhen to Use
Batch + perform_inMany similar jobs at a specific time; reduces Redis ops by ~100x
push_bulkLarge batches of jobs without per-job delays
Dedicated queuesDifferent priority levels for job types
Rate limitingExternal APIs or rate-limited services
Unique jobsIdempotent or duplicate-prone jobs
Small payloadsAlways; pass IDs instead of full objects
Connection poolingHigh concurrency or many processes

The batch + perform_in pattern is especially effective for time-sensitive jobs that must run in a narrow window while keeping Redis overhead low.

Happy Coding with Sidekiq!


Understanding Core Computer Language Concepts: Design Patterns, Polymorphism, and Object Relationships

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

2. Strategy Pattern: Encapsulating Interchangeable Algorithms

What is Strategy Pattern?

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

AspectTemplate MethodStrategy
MechanismInheritanceComposition
When to useRelated algorithms sharing common structureInterchangeable algorithms
ImplementationSubclasses override methodsDifferent classes implement interface
Change timingCompile-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

AspectAggregationAcquaintance
RelationshipPart-of, ownsUses, knows-about
LifetimeContainer controlsIndependent
CreationContainer createsExternal creation
Use CaseCar-Engine, House-RoomsCustomer-Order, Client-Service
DependencyStrong couplingLoose 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
@shelves = {
fiction: Shelf.new("Fiction", 100),
science: Shelf.new("Science", 100),
history: Shelf.new("History", 100)
}
@all_books = []
end
def add_book(book, section)
@all_books << book
@shelves[section].add_book(book)
end
def find_book(title)
@all_books.find { |book| book.title == title }
end
end
# STRATEGY: Different checkout strategies
module CheckoutStrategy
def apply_fee(days_borrowed)
raise NotImplementedError
end
end
class StudentCheckoutStrategy
include CheckoutStrategy
def apply_fee(days_borrowed)
days_borrowed > 14 ? days_borrowed - 14 * 0.25 : 0
end
end
class FacultyCheckoutStrategy
include CheckoutStrategy
def apply_fee(days_borrowed)
days_borrowed > 30 ? (days_borrowed - 30) * 0.10 : 0
end
end
class LateFeesCalculator
def initialize(strategy)
@strategy = strategy
end
def calculate(days_borrowed)
@strategy.apply_fee(days_borrowed)
end
def change_strategy(strategy)
@strategy = strategy
end
end
# ACQUAINTANCE: Loan connects User and Book temporarily
class Loan
def initialize(loan_id)
@loan_id = loan_id
@user = nil
@book = nil
@checkout_date = nil
end
def create_loan(user, book)
@user = user
@book = book
@checkout_date = Date.today
puts "Loan #{@loan_id}: #{user.class} borrowed '#{book.title}'"
end
def return_book
days = (Date.today - @checkout_date).to_i
fee_calculator = LateFeesCalculator.new(StudentCheckoutStrategy.new)
fee = fee_calculator.calculate(days)
puts "Book returned. Days borrowed: #{days}, Late fee: $#{fee}"
end
end
# Usage demonstration
puts "=== Library Management System ==="
# Setup library (aggregation)
library = Library.new("City Public Library")
book1 = Book.new("The Ruby Way", "Hal Fulton", "ISBN001")
book2 = Book.new("Design Patterns", "Gang of Four", "ISBN002")
library.add_book(book1, :science)
library.add_book(book2, :fiction)
# User checkout with template method
student = Student.new("John Doe")
student.process_checkout(book1)
faculty = Faculty.new("Dr. Smith")
faculty.process_checkout(book2)
# Loan with acquaintance
loan1 = Loan.new("LOAN001")
loan1.create_loan(student, book1)
loan1.return_book

Conclusion

These four concepts represent essential tools in the software architect’s toolkit:

  1. Template Method – Use inheritance to define algorithm structure
  2. Strategy – Use composition to swap algorithms at runtime
  3. Parameterized Types – Write generic code for multiple data types
  4. Aggregation/Acquaintance – Structure object relationships appropriately

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.

Happy Coding! 🚀

The Evolution of Stripe’s Payment APIs: From Charges to Payment Intents

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:

requires_payment_method → requires_confirmation → requires_action → succeeded
                       ↓                      ↓                 ↓
                   canceled              canceled          requires_capture
                                                               ↓
                                                           succeeded

This model elegantly handles scenarios that would break the Charges API:

3D Secure Authentication:

# Payment requires additional authentication
if payment_intent.status == 'requires_action'
  # Frontend handles 3D Secure challenge
  # Webhook confirms completion asynchronously
end

Delayed Capture:

# Authorize now, capture later
payment_intent = Stripe::PaymentIntent.create({
  amount: 2000,
  currency: 'usd',
  payment_method: 'pm_card_visa',
  capture_method: 'manual'  # Authorize only
})

# Later, when ready to fulfill
payment_intent.capture({ amount_to_capture: 1500 })

Key Architectural Changes

1. Separation of Concerns

Payment Intents represent the intent to collect payment and track the payment lifecycle.

Charges become implementation details—the actual movement of money that happens within a Payment Intent.

# A successful Payment Intent contains charges
payment_intent = Stripe::PaymentIntent.retrieve('pi_1234567890')
puts payment_intent.charges.data.first.id  # => "ch_0987654321"

2. Enhanced Webhook Events

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:

// Modern client-side payment confirmation
const {error} = await stripe.confirmCardPayment(clientSecret, {
  payment_method: {
    card: cardElement,
    billing_details: {name: 'Jenny Rosen'}
  }
});

if (error) {
  // Handle error
} else {
  // Payment succeeded, redirect to success page
}

Migration Challenges and Solutions

The ID Problem: A Real-World Example

One of the most common migration issues developers face is the ID confusion between Payment Intents and Charges. Here’s a real scenario:

# Legacy refund code expecting charge IDs
def process_refund(charge_id, amount)
  Stripe::Refund.create({
    charge: charge_id,  # Expects ch_xxx
    amount: amount
  })
end

# But Payment Intents return pi_xxx IDs
payment_intent = create_payment_intent(...)
process_refund(payment_intent.id, 500)  # ❌ Fails!

The Solution: Extract the actual charge ID from successful Payment Intents:

def get_charge_id_for_refund(payment_intent)
  if payment_intent.status == 'succeeded'
    payment_intent.charges.data.first.id  # Returns ch_xxx
  else
    raise "Cannot refund unsuccessful payment"
  end
end

# Correct usage
payment_intent = Stripe::PaymentIntent.retrieve('pi_1234567890')
charge_id = get_charge_id_for_refund(payment_intent)
process_refund(charge_id, 500)  # ✅ Works!

Database Schema Evolution

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:

def create_flexible_payment(amount, payment_method_types = ['card'])
  Stripe::PaymentIntent.create({
    amount: amount,
    currency: 'usd',
    payment_method_types: payment_method_types,
    metadata: {
      order_id: @order.id,
      customer_email: @customer.email
    }
  })
end

# Support multiple payment methods
payment_intent = create_flexible_payment(2000, ['card', 'klarna', 'afterpay_clearpay'])

4. Implement Proper Error Handling

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:

# Embedded checkout with Payment Intents
payment_intent = Stripe::PaymentIntent.create({
  amount: 2000,
  currency: 'usd',
  automatic_payment_methods: { enabled: true },
  metadata: { integration_check: 'accept_a_payment' }
})

2. Real-Time Payments

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

  1. Payment Intents are the future: If you’re building new payment functionality, start with Payment Intents, not Charges.
  2. Embrace asynchronous patterns: Don’t expect payments to complete immediately. Design your system around webhooks and state management.
  3. Handle the ID confusion: Remember that Payment Intents (pi_) contain Charges (ch_). Refunds and some other operations still work on charge IDs.
  4. Implement robust webhook handling: With complex payment flows, webhooks become critical infrastructure, not nice-to-have features.
  5. Test thoroughly: The increased complexity of Payment Intents requires more comprehensive testing, especially around authentication flows and edge cases.
  6. 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.

Understanding Ruby’s Singleton Class: Why We Open the Eigenclass at the Class Level – Advanced

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 ]

For a class object like Payment:

[ Payment ] ---> [ Payment's Eigenclass ] ---> [ 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.


Happy Ruby coding!