Why "What Something Is" Beats "What Something Is Called"

Shopify’s Summer 2022 Edition introduced pre-orders and “try before you buy” (TBYB), expanding the Purchase Options APIs beyond the existing subscriptions. When the team started adding these features, they inherited subscription code that made the new work harder than it should have been.

The problem wasn’t a particular bug or a missing feature. It was an assumption baked into the codebase: the presence of a selling plan object was treated as equivalent to the domain concept of a subscription. That assumption showed up in countless control-flow checks and data-derivation logic, where code would inspect a selling plan’s internals to decide what to do next. Adding pre-orders and TBYB meant revisiting every one of those spots.

You could patch this by adding more conditionals — checking for pre-order or TBYB flags wherever subscription logic lived — but that only postpones the inevitable. The next purchase option, say installments or layaway, would trigger another large refactor because those features share recurring billing but not the same delivery semantics. A deeper problem emerged when code that assumed a selling plan meant a subscription tried to derive information from it. Pre-orders don't have delivery intervals, so this logic would break, forcing the refactor outward to the caller. Left unchecked, the internal logic of selling plans leaks into parts of the codebase that have no business knowing how a selling plan works.

Defining by Doing

What is a subscription, really? From a product perspective, it’s an agreement to pay an amount once or multiple times to receive products on a cadence. A pre-order means ordering something now with partial payment now, later, or both, and receiving the product at a future date. Those definitions work for product copy, but they’re too vague to encode directly.

You could try: subscriptions have recurring billing. But layaway — pay over time, receive at the end — and installments — receive now, pay over time — both have recurring billing. What about recurring delivery? A year-long subscription with monthly deliveries but no renewal is a single billing cycle with multiple deliveries, so that fails too. Multiple deliveries for one order is better described as deferred rather than recurring, and that distinction matters more than any label.

The pragmatic shift: think in terms of an order. A subscription is a recurring order. Each delivery is its own order with its own shipping, tracking, and possibly billing. Recurring delivery is guaranteed; recurring billing is possible but not required. This framing covers the current features and doesn't force premature abstractions for hypothetical future use cases.

Encapsulation Over Inspection

Encapsulation — bundling methods and data together in a class — isn’t just a buzzword here. It directly addresses the leaky selling plan details. Why should unrelated components care about a selling plan’s internal structure? A focus on behavior over state means objects answer questions about what they do, not expose their parts for others to decode.

Pre-orders introduced new policies like FixedDeliveryPolicy and FixedBillingPolicy, which contain details that wouldn’t exist on a subscription. To avoid spraying these details across the codebase, the team wrapped the selling plan in a class that exposes top-level methods for the behaviors other components need — whether something is deferred, whether it’s recurring, and how to derive the data for those answers. Everywhere outside the component where active record objects are defined, this wrapper replaced direct selling plan references. It has more methods, but as far as the rest of the system is concerned, it still behaves like a selling plan.

Behavior in Every Layer

Encapsulation has its limits. Shopify handles many commerce domains — payments, inventory reservation, pricing with subscription discounts, and delivery — and not every part needs the same information. Delivery components don’t care if something is a subscription, but they must know if there are recurring deliveries and what the interval is. Payment code needs to distinguish a single upfront yearly charge from 20% down at checkout with 80% at shipment. The caller orchestrating a delivery has to know precisely which payment and inventory steps to trigger.

The wrapper’s methods make that possible. Instead of delivery code asking "is this a subscription?", it asks "do you have recurring deliveries?" That removes the need for delivery code to hold a domain model of subscriptions — it only cares about delivery frequency. It even opens the door to a wrapper that treats any order, with or without a selling plan, as a series of deliveries. A one-time purchase simply becomes an order with a single upfront delivery, no interval, anchored to today. Conditions disappear entirely.

Bridging Back to the Data Team

This behavioral decomposition creates a new problem. Somewhere, someone has to reconstruct the meaning for reporting. Merchants want to know if pre-orders lift sales or if TBYB products perform better. The data team can’t easily derive that from a deck of behavior traits scattered across multiple schemas, legacy column values, and renamed fields. They patch together meaning from tables built for other purposes, and any schema change risks breaking the reports merchants rely on.

The solution is a translation table that stores the traits the platform has identified: upfront, deferred, or recurring billing and delivery. When an order is created, the system populates this table. A reporting label can be generated by straightforward code that inspects the wrapper — the trait becomes the source of truth, not the implementation details. Merchants can even supply their own label through the API, and the system can distinguish between a calculated label and a provided one by storing the label’s kind.

This extra table is a deliberate cost: it requires upkeep and must stay in sync with the wrapper’s logic. But it serves as a clean translation layer between the platform’s internal representations and the domain concepts merchants understand. The table’s entire purpose is to be that shim, keeping both the platform code and the data code free of the other’s idiosyncrasies — no matter how messy that translation becomes, it’s contained where it belongs.

Better Abstractions on Purpose

"We should’ve done this" is easy to say in hindsight, and "I won’t have this problem next time" is easy to claim in advance. Neither is guaranteed. But the approach of wrapping a domain object behind a behavioral interface encourages precisely the kind of thinking that leads to stronger abstractions. Instead of asking what something is called, the code can ask what later code needs it to do — an ordering that surfaces design issues early, when they're cheaper to fix.

Digging Deeper Into the Rails Financial Engine

To see how the financial engine meets those goals, we first need to look at how a model's external interface is built. The main entity is a class, which we'll call MoneyMovement, that wires together all internal records via Rails' has_many associations. This class is the only thing an engineer touches when adding money movement features:

  1. Start with a state field. Create decimal attributes on the record for the applicable amounts and fees.
  2. Use a state_machine. Attach a state machine to the record to manage lifecycle and transitions. Note the engine enforces a threshold limit of 10,000 records per day per money movement type in a MoneyMovementType callback.
  3. Pull in finance-specific code with concerns. Rails concerns help add common functionality: one can handle the safe conversion of alternative currencies to CAD using the money gem, while another pulls in code to generate ledger entries. When these mixins are brought into a money movement model, they use a class_attribute and a monkey-patched included hook to configure behaviors.

The result is a structure whose public surface is free of service objects and contains almost no pure Ruby code. From the outside, engineers only see regular has_many relations exposed on the model, which makes orchestration explicit at the controller level. In practice, this means a money movement record acts as an aggregate root and doubles as the sole gateway into the entire domain.

Designing the Move From Amount-Dependent to Event-Driven Flows

Older code serving preorders shared the same general shape, but was heavily amount-dependent. Instead of composing operations around events already recorded in the system, much of the existing sync logic would query ledger entries, look up a state, and compare thresholds before deciding how to aggregate those entries. That style tends to hide business intent and couples each piece to another model's specifics, making a routine change ripple through other records. The event-driven approach avoids those problems by relying on actions recorded on the source itself.

For creating money movements, a given money movement model is still paired 1:1 with source records. The same external API and behavior applies here—one daily aggregation process reads the state field on the source record and identifies which money movements need to be generated against it. This isn't a technical shift: it is a project framing shift. By breaking down the process through the looking glass of “what events already happened” versus “what state is such an amount in,” the shoehorning behavior into a class disappears and orchestration moves to the controller boundary.

The Payout, Fees, and Adjustments in a Real Event

Part of this work included adding a new type of records for tracking money movements against a low-level transaction ledger. A ledger entry concerns an external source—we'll call it the Transfer—and carries a positive credit amount, a payout description, and a sequence number. While most money movements use triggers within the code flow already used by the primary entity, cases like ad hoc refunds have a few quirks.

One is that an adjustment can only be considered a true refund once the ledger entry arrives. Another is that event records have to be idempotent: retries get skipped. That required a guard method that, for each ledger entry, only creates a given money movement when the current state and amount combination isn't already accounted for.

def (field: :detail, eligible_status: :approved)
      return correct_adjustment(...) if withdrawal?
      self.payout          = payout_amount(...)
      self.total_fee       = -total_fees_paid_by_account(...)
      self.net_payout      = net_payout_amount(...), likely negative?
      self.financial_owner = ...
      unless in_flow? ...
    end

There are several edge cases worth naming: if the source record is voided, the balance no longer applies to net payouts on that item; this particular flow also needs to consider the payout itself, not just the ledger balances individually. In these cases, you revert the balance only if a payout statement has not been generated. Furthermore, when listing or updating a payout, those records need to appear to keep downstream consumers consistent. The sync for these is active between an external transfer and internal ledger entries.

def self.to_ledger(payout)
      id          = payout.id      ...
            number     = payout.number    ...
      payout.purchase_operations.maintenance.find_each do |maintenance|
 end

Providing an end-to-end view of anything occurring at the account level means some new views are still being assembled. A particular ongoing saga is the rendering of payouts at scale as they relate to multiple buyers or multiple old payouts stuck in an unsettled state. But for being useful early, the adjustment behaviors and the ledger rows in this flow do cover most merchants.

The Ruby on Rails Reality

The financial engine is additionally a bit of a gem, and its development has shown us to never trust the default lifecycle hooks. In Rails applications, when working with state machines, an event transitions can fire many create callbacks only if the record is valid, which often bypasses your conditional early returns. That implies ensuring calls for a reason, but the pattern of centralizing logic in the model can push you to reach for the model internals with send or write if defined? conditionals which is quite invasive to the test suite. Another lesson is that coupling to a database can compound these issues; most of a Rails app complexity exists in relationship to ActiveRecord, not to the core domain logic. This is why it's important to test the behavior and not duck-type your way into specific gems.

The arrangement sounds lofty, but we've started small: a gem named Packwerk to enforce boundaries. The Ruby on Rails community sees a lot of cross-model approach to domain orchestration, and boundaries being this explicit help us avoid falling into those traps.

Putting Behavioral Metrics Into Practice

Create repositories is common but beware of an anemic model: a huge pile of records attached to a service object presents another maintainability strategy of its own. Before you create yet another model to gate domain operations, ask that team whether the new model, when created, truly exposes only the desired behavior, not the current state. If the custom class is a way to map complex state spread over several core models, then you probably have an event-driven design problem, not a class-design problem. Focus first on making richer events on the source model. If on the other hand those events already exist, do not aggregate at the service layer.

Behavior and state are so entangled in existing code that changing them has a huge cost. In our code, our future addition of global payouts will be easier when state defaults to behavior from day one.