Spot the Tax · Card 10 of 20
Business logic shouldn't know which payment provider you use
Why BillingService calling Stripe::Charge.create directly makes the service nearly impossible to test or evolve.
The code
What will this cost you in six months?
class BillingService
def charge(user, amount_cents)
result = Stripe::Charge.create(
amount: amount_cents,
currency: "usd",
customer: user.stripe_customer_id
)
Payment.create!(user: user, stripe_id: result.id, amount_cents: amount_cents)
end
end The problem
BillingService is supposed to hold the high-level business logic for charging users and recording payments. By calling Stripe::Charge.create directly, it gets coupled to one specific payment vendor's SDK. Every test that touches the service has to stub Stripe at the SDK level, every dev environment needs Stripe API keys, and the day someone wants to add PayPal for European customers, you end up rewriting BillingService instead of just adding a new gateway.
Take a moment. Before revealing, think about how you'd make BillingService testable without stubbing the Stripe SDK, and how you'd add a second provider without rewriting the service.
The solution
Have BillingService depend on a small interface for "charge a customer some amount", and inject the actual implementation. In production you inject a StripeGateway. In tests you inject a FakeGateway with predictable behavior. To add PayPal, you write a PayPalGateway and inject that — and BillingService never has to learn about the difference.
- Tests run without WebMock or stripe-mock
- A second provider is a new gateway class, not a service rewrite
- The business logic doesn't know which vendor it's using
class BillingService
def initialize(gateway: StripeGateway.new)
@gateway = gateway
end
def charge(user, amount_cents)
result = @gateway.charge(amount: amount_cents, customer: user.stripe_customer_id)
Payment.create!(user: user, gateway_id: result.id, amount_cents: amount_cents)
end
end The principle at play — Dependency inversion
The Dependency Inversion Principle says that high-level modules — the ones that hold your business policies — should not depend directly on low-level modules like specific APIs or vendor SDKs. Both should depend on a shared abstraction that sits between them.
When your business logic reaches directly into a vendor's SDK, the policy and the implementation get welded together. You can't test the policy without bringing the implementation along, which is why everyone ends up stubbing Stripe in their tests. You can't change the implementation without touching the policy. And the moment a second implementation enters the picture, the policy has to be aware of which one is in play.
The fix is to define what the high-level code actually needs from the low-level world (in this case, "something that can charge a customer"), and to make both the high-level code and the low-level implementation agree on that small interface. The high-level code now depends on the interface, not on Stripe specifically. Stripe just becomes one possible implementation, and adding another is straightforward.