You know that moment when you realize your business logic is having intimate conversations with your database? Mine happened during a code review when I watched a teammate’s face contort as they tried to follow a payment processing flow that somehow knew about PostgreSQL column names.
In this issue, I’ll take you through how adapters transformed our messy, tightly-coupled codebase into something maintainable. No theory dumps – just real code, real problems, and real solutions.
🚀 What you’ll learn:
How to isolate external dependencies from your core business logic
The adapter pattern that saved us from integration hell
Why your domain shouldn’t know about databases or APIs
Practical techniques for building bulletproof boundaries
The Problem
Our payment processing system had grown organically over two years. What started as a simple feature had become a tangled mess where business rules, database queries, and API calls were all best friends living in the same class.
Here’s what we were dealing with:
class PaymentProcessor
def process_payment(user_id, amount, card_details)
# Database query mixed with business logic
user = User.find(user_id)
return false if user.account_balance < amount
# Direct API call embedded in the flow
response = HTTParty.post("https://payment-gateway.com/charge", {
card_number: card_details[:number],
amount: amount,
user_email: user.email
})
# More database operations
if response.code == 200
Transaction.create!(
user_id: user_id,
amount: amount,
status: 'completed',
gateway_response: response.body
)
user.update!(account_balance: user.account_balance - amount)
true
else
false
end
end
end
💡 Warning Signs:
Business logic scattered across database concerns
Direct HTTP calls embedded in core workflows
Impossible to test without hitting real external services
Changes to payment gateway required touching business logic
The Journey: From Problem to Solution
Step 1: Extract the Business Logic
The first breakthrough was realizing our domain logic was drowning in infrastructure concerns. We needed to separate what the business cared about from how we stored or retrieved data.
Before:
def process_payment(user_id, amount, card_details)
user = User.find(user_id) # Database concern
return false if user.account_balance < amount # Business rule buried
# ... rest mixed together
end
After:
module Payments
class ProcessPayment
def self.call(request)
new.call(request)
end
Request = Struct.new(:user_id, :amount, :card_details, keyword_init: true)
Response = Struct.new(:success, :transaction_id, :errors, keyword_init: true)
def call(request)
# Pure business logic - no database or API knowledge
return insufficient_funds_response unless user_has_funds?(request)
charge_result = charge_card(request)
return failed_charge_response(charge_result) unless charge_result.success
transaction = record_transaction(request, charge_result)
deduct_balance(request)
Response.new(success: true, transaction_id: transaction.id)
end
private
# Business logic methods here...
end
end
🎯 Impact:
Business rules became crystal clear and testable
Zero external dependencies in core logic
10x faster test suite (no more database hits)
Step 2: Build the Translation Layer
Now we had clean business logic, but it needed to talk to the outside world. This is where adapters became our heroes – they translate between our pristine domain and the messy real world.
The Adapter Pattern:
module Payments
module Adapters
class PaymentGatewayAdapter
def initialize(gateway_service: PaymentGateway::Service.new)
@gateway_service = gateway_service
end
Request = Struct.new(:amount, :card_details, :user_email, keyword_init: true)
Response = Struct.new(:success, :transaction_id, :error_message, keyword_init: true)
def charge_card(request)
result = gateway_service.charge(
amount: request.amount,
card_number: request.card_details[:number],
email: request.user_email
)
# Transform external response to domain language
Response.new(
success: result.status == 'approved',
transaction_id: result.id,
error_message: result.error
)
rescue StandardError => e
Response.new(success: false, error_message: "Gateway unavailable")
end
private
attr_reader :gateway_service
end
end
end
The Aha Moment
The breakthrough came when I realized adapters aren’t just about dependency injection – they’re translators. They speak “domain language” on one side and “external system language” on the other. Our business logic could finally focus on business rules while adapters handled the messy details of talking to databases, APIs, and external services.
Real Numbers From This Experience
Before: 45-second test suite (hitting database and external APIs)
After: 3-second test suite (pure unit tests)
Bug reports decreased by 60% (cleaner separation of concerns)
Feature delivery time improved by 40% (easier to reason about and modify)
The Final Result
module Payments
class ProcessPayment
def initialize(
user_repository: Adapters::UserRepository.new,
payment_gateway: Adapters::PaymentGatewayAdapter.new,
transaction_repository: Adapters::TransactionRepository.new
)
@user_repository = user_repository
@payment_gateway = payment_gateway
@transaction_repository = transaction_repository
end
def call(request)
user = user_repository.find(request.user_id)
return insufficient_funds_response unless user.balance >= request.amount
charge_request = payment_gateway_request(request, user)
charge_result = payment_gateway.charge_card(charge_request)
return failed_charge_response unless charge_result.success
transaction = transaction_repository.create(
user_id: user.id,
amount: request.amount,
gateway_transaction_id: charge_result.transaction_id
)
user_repository.deduct_balance(user.id, request.amount)
Response.new(success: true, transaction_id: transaction.id)
end
private
attr_reader :user_repository, :payment_gateway, :transaction_repository
end
end
🎉 Key Improvements:
Business logic completely isolated from external concerns
Every external interaction goes through a dedicated adapter
Testable without any external dependencies
Easy to swap implementations (different payment gateways, databases, etc.)
Monday Morning Action Items
Quick Wins (5-Minute Changes)
Identify one class that talks directly to external services
Extract the external call into a separate method
Add a simple wrapper around that external dependency
Next Steps
Create your first adapter for the most problematic external integration
Define clear request/response objects for your adapter interface
Write tests for your business logic without any external dependencies
Your Turn!
The Adapter Challenge
Look at this code and identify where adapters would help:
class OrderFulfillment
def fulfill_order(order_id)
order = Order.find(order_id)
return false if order.status != 'pending'
# Email service call
Mailer.deliver_now(OrderConfirmation.new(order.user.email, order))
# Inventory update
HTTParty.put("https://inventory.com/api/reserve", {
items: order.line_items.map(&:product_id),
quantities: order.line_items.map(&:quantity)
})
# Payment processing
Stripe.charge(
amount: order.total,
customer: order.user.stripe_customer_id
)
order.update!(status: 'fulfilled')
end
end
💬 Discussion Prompts:
Which external dependencies would you extract first?
How would you structure the adapter interfaces?
What would the business logic look like after refactoring?
What’s Next?
Next week: “Repository Pattern: When Your Database Becomes a Detail” - We’ll dive deep into how to make your data persistence strategy completely swappable.
🔧 Useful Resources:
Clean Architecture by Robert Martin
Hexagonal Architecture (Ports and Adapters) by Alistair Cockburn
Domain-Driven Design by Eric Evans
Found this useful? Share it with a fellow developer! And don’t forget to try refactoring one class in your current project using the adapter pattern.
Happy coding!
Pro Tip: Start with the external dependency that causes the most testing pain – usually databases or HTTP calls
Remember: Adapters should be thin translation layers, not business logic containers