All posts

Ruining systems with good principles: abstraction

Writing

Most codebases don’t suffer from too few abstractions. They suffer from abstractions that aren’t carrying their weight.

You can see it in code that looks clean at a glance. Long methods split up. Conditionals pushed into helpers. Everything neatly named: services, policies, builders, strategies, adapters.

Then you try to answer a simple question:

What actually happens when this order is placed?

You start in CheckoutService:

CheckoutService
  └─▶ OrderProcessor
        └─▶ PaymentHandler
              └─▶ PaymentGatewayResolver
                    └─▶ PaymentExecutionService

Somewhere in that descent, inventory gets reserved, a discount is applied, tax is calculated, a shipment may be scheduled, and three methods named some variation of valid? decide whether any of it can proceed.

No single piece is bad in isolation. Each one is small, named, testable. And the system is still harder to understand than it should be.

That’s the part we don’t talk about enough. Breaking code into smaller pieces is not the same as creating useful abstractions. Sometimes it just creates more places to look.


A good abstraction

A good abstraction isn’t just a smaller thing. It’s an intentional, useful boundary that localizes complexity.

It gives you the option to stop digging for more context about an area of complexity - not because the complexity is gone, not because nobody will ever need to understand it, but because for the current situation or task, those details aren’t relevant.

That option is the entire value.

If I write this in the checkout flow:

payment_authorization = Payments.authorize(order)

I don’t need that line to explain fraud checks, idempotency keys, gateway failover, retries, or provider-specific error mapping. In the checkout flow, I only need to know two things: did we attempt authorization and what does the result mean for the order?

But, if at some point in the future, authorization latency doubles, or a provider starts returning a new error code, or a customer gets charged twice - someone should be able to go through the abstraction and reach the real machinery, with the relevant complexity all in one place. A good abstraction makes that easier, not harder.


Hiding vs. containing

This is the distinction that does the work:

Hiding complexity says: you should never have to know about this.

Containing complexity says: you shouldn’t have to know about this unless it becomes relevant.

The second is more honest and acknowledges the reality of managing real-world systems. Things break, and when they do, the abstraction should make it easy to open up the hood and dig into the details.


A tidy flow that abstracts nothing

class Checkout
  def complete(order)
    validate_order(order)
    apply_discounts(order)
    calculate_tax(order)
    charge_customer(order)
    reserve_inventory(order)
    create_shipment(order)
    send_confirmation(order)
    order.complete!
  end

  private

  def validate_order(order)
    CheckoutValidator.new(order).validate
  end

  def calculate_tax(order)
    TaxCalculator.new(order).calculate
  end

  def reserve_inventory(order)
    InventoryService.new(order).reserve
  end

  # etc...
  # every other private method has this same shape:
  # instantiate one object, call one method.
end

This reads cleanly. But the private methods mostly rename one-line calls. To understand the behavior you still have to understand every downstream object. The simplicity is cosmetic - complexity moved out of sight without ever forming a useful boundary.

Instead of creating an abstraction around each individual step, a more useful seam might be between checkout orchestration and the external systems checkout leans on. Each of those systems is complex for its own reasons:

  • Payments: idempotency, retries, partial failures, async settlement, reconciliation.
  • Inventory: availability, warehouses, overselling rules, race conditions.
  • Tax: jurisdictions, exemptions, and rule changes the business discovers later.

Those are areas of complexity worth naming.


Drawn along the real seams

class Checkout
  def complete(order)
    Order.transaction do
      order.ensure_ready_for_checkout!

      tax_quote = Taxes.quote(order)
      payment   = Payments.authorize(order, amount: tax_quote.total)
      inventory = Inventory.reserve(order)

      order.complete!(
        tax_quote: tax_quote,
        payment_authorization: payment,
        inventory_reservation: inventory
      )
    end

    Fulfillment.request_for(order)
    CustomerNotifications.order_confirmed(order)
  end
end

These boundaries earn their keep because they keep different kinds of complexity from mixing.

Systems usually get hard to change not because complexity exists, but because related complexity is scattered and unrelated complexity starts bleeding together:

  • Payment code starts checking shipping rules.
  • Inventory code starts knowing about promotional discounts.
  • Checkout starts branching on gateway error strings.
  • A controller passes a flag that only exists because of a vendor edge case three layers down.

When that happens, the abstractions have already failed.


A litmus test for your next PR

Before you pull something into its own service, handler, or strategy, run it through three questions:

  1. What can the caller stop knowing once this exists? If the answer is “nothing,” you’ve renamed code, not abstracted it.
  2. Does this keep one kind of complexity from leaking into another - or is it about to start carrying details that belong somewhere else?
  3. Could I delete the wrapper and lose only keystrokes, not understanding? If so, it isn’t carrying its weight yet.

Good abstractions don’t pretend the complexity is gone. They translate messy details into the language of the system around them, shrink the context you need to make a change, and leave a clear way down when you need it.