If you haven’t checked out Shopify Editions (which you should), you may have missed the release of pre-orders and “try before you buy” (TBYB) as new ways of selling on Shopify. These two features join subscriptions as part of our Purchase Options APIs. While building them, we encountered previous work on subscriptions that made our new work difficult.
The eventual solution here isn’t in the code we write, but in how we write the code. This post is about how we moved from focusing on state (what an object is) towards behavior (what an object does) as a way to create a more maintainable codebase. But first, let’s take a closer look at the code.
When we began the project to add pre-orders and TBYB, subscriptions were already in place. As we began to add our new features, we ran into a lot of code that looked like this:
In many places, we tightly coupled the presence of a selling plan (an object in our purchase options API) with the domain idea of a subscription. Now, from a control flow perspective, this isn’t that difficult to fix. We could simply add some extra conditionals:
While this works, it’s not particularly robust. If one day we want to support installments or layaway, both of which have recurring billing but different types of delivery, we would again be in place for a large refactor.
Or take another example, where we’re in part of the code that knows there’s a selling plan and subscription and tries to derive data from it:
In the case of pre-orders, this won’t work, because pre-orders don’t have a delivery interval. Now we have a larger refactor because we have a selling plan, but shouldn’t be in this part of code. We need to move our refactor to the caller and refactor there. And if we continue to need to move around where we need to refactor code, we run the risk of leaking the internal logic of selling plans (subscriptions, pre-orders, etc.) all over the codebase in places that have no business knowing about it.
“What Would You Say You Do Here?”
I mean, what’s a subscription, really? And what’s a pre-order? From a product perspective, we have a sense. Subscriptions are when people pay an amount once or multiple times to receive products on a given cadence. Pre-orders would mean I’m ordering some product, maybe paying some amount of money now, paying some later, or both, and I’ll receive the product at a later date.
That works for product descriptions, but that’s a lot of words that we can’t really add to code. Let’s consider some options. We could say:
That could work. But what if we eventually want to offer layaway, where someone pays off something over time and receives it at the end? Or installments, where you get something and pay it off over time? Both of those have recurring billing components. What about this:
If we have multiple deliveries, does that constitute a subscription? Not quite, but we’re getting a lot closer. What if we were to have an order that was broken up into multiple deliveries? Are those deliveries recurring? The solution here isn’ in code, but in thought. We’re getting dangerously close to YAGNI (You Ain’t Gonna Need It), but at the same time, we don’t want to end up in a similar situation as this one later.
The solution here is to think of an order. A subscription is a recurring order. Each delivery is a new order with new shipping and tracking and (maybe) new billing. A subscription will for sure have recurring delivery and may have recurring billing. For example, I could sign up for a year-long subscription that delivers once a month, but choose not to renew. That would be just one billing cycle, but a monthly delivery cycle. Additionally, we could consider multiple deliveries for the same order more as deferred instead of recurring. Our current implementation would allow that, so let’s go with it!
Now what about cases where we know we had a selling plan and wanted to do calculations?
Encapsulation Station
It can be easy to throw around computer science buzzwords like “encapsulation” to solve problems…so let’s do that!
Encapsulation is the idea that methods modifying and operating on data should be bundled with that data, like a class. And if we’re to do good service to the title of this post, we should focus on behavior, not state!
In many places we would have code like:
But now we’re leaking so many details of the selling plan to various parts of the code. Why do they care about the structure? There’s a lot of room for judgment when writing code. The Rails Way may say that there’s nothing wrong with accessing the database methods on our active record objects, but we also warn against models that have too much logic. Additionally at Shopify, we use Packwerk to prevent components from knowing too much about each other’s internal work.
The new pre-orders also added different kinds of policies that can be attached to selling plans. The FixedDeliveryPolicy
and FixedBillingPolicy
have details on pre-orders or TBYB that wouldn’t be present on a subscription.
In order to address this, and not leak all of this data to other components, we wrapped it!
Above is a sample of what we wrote. We have a class that wrapped a selling plan and added the necessary top-level methods for diving into various data or objects for deriving meaning. Now this object can both respond to information about being deferred or recurring, and provide the data needed for those calculations.
We replaced all instances of selling plans with this object outside of the component where the active record objects are defined. It has more methods, but as far as the rest of the code is concerned, it quacks like a selling plan.
Great! Let’s see what this does for us.
Many Places to Be
It would be naive to say that we can encapsulate everything. Shopify has a lot of code to address a lot of parts of commerce. If you were to buy a subscription, or really anything on a Shopify store, we have to handle:
- payments (both at checkout and later)
- inventory management (when we reserve your recurring orders)
- pricing (discounts received on subscriptions)
- delivery (what’s the point if you never get your order?).
Delivery components don’t need to know if there’s a subscription domain concept, but they absolutely need to know if there are recurring deliveries and what any delivery intervals would be. The payments code definitely cares if there are recurring payments for a subscription or maybe just one upfront payment for the year. Or if we have a pre-order, maybe we have 20% down at checkout and the 80% will be paid when the product ships. So the payments code needs to know about all of those. When the time comes to deliver the product, our caller must know to call payments to collect the remaining balance as well as delivery (and maybe inventory management).
Maybe this actually calls into question our solution from before—why do we really need to know if something is a subscription? Maybe we can just write methods that address the needs of a given section of code.
Instead of our delivery code asking “subscription?”, it can just ask “do you have recurring deliveries?”. The great part about this, is that the delivery code doesn’t need to have a domain understanding of a subscription, it just cares about how often it needs to deliver when.
There’s even the possibility of a further refactor, where we could create a different kind of wrapper that combines orders with or without selling plans and only operates on the number of deliveries.
A one-time purchase would just be an order with a single upfront delivery, with no interval and an anchor of today. And just like that, conditionals are gone!
There and Back Again
There’s at least one problem with this solution. And we have all been there before. “What about the data team?”
Our data teams have the herculean task of combining code, columns, and rows into something meaningful for our customers. For Shopify, our data team needs to construct meaning for our merchants. I imagine it would be very useful for merchants to know if their pre-orders are leading to greater sales or TBYB products are doing better.
Often, unless we can provide meaningful time and resources to helping them, our data teams must reach into databases and construct meaning from data that we had no intention of the world seeing. Maybe we had to rename a column. Maybe we have some data in a column that should be null otherwise but… legacy users. Maybe we just realized too late that our data model wasn’t quite right, so we have another table over here that augments our original table. Finally, what if we decide we did it wrong and want to change things, well now the eight most useful reports our customers use are tied to these tables of ours. So how can we avoid this?
We spent time taking our domain object and ripping it apart in the code into inspections of behavior, and now we must reconstitute it into something meaningful. What about a meeting of the minds? Or…just a new table.
If we take these traits we have described (upfront/deferred/recurring + billing/delivery) and store them somewhere, we can give the data team insulation against changes to our schemas. Let’s start with the simple version:
Now, when an order is created, we can go and populate this table. The reporting label can be populated via code that looks at our wrapper:
Great! This is an oversimplification of the logic, but it’s also extensible. We can add more types of selling plan ideas here if we need.
Wait! The data team wants to be able to do two types of reporting, and the merchants should be able to provide their own label for the type of purchase. With this system it’s easy. We can provide a field in the API for a label to be used. For now we can restrict it, but could theoretically make it completely customizable. We don’t even need to give up our current internal system.
Finally, we could even take it one step further if we wanted to track for more details in case our definitions change. We could store a kind which could be a trait, calculated label, or provided label. That way we’d also have records of things like deferred billing or recurring delivery for a given order.
By adding an extra table, we created a translation layer between the platform code and the domain concepts merchants want to know about in reports. There’s of course a cost to maintaining the translation table, but that keeps the domain logic of translation segregated from either team’s domain logic. Instead of having nasty shim code in either team’s codebase, the translation layer’s purpose is to be that shim code. Therefore, no matter how complicated it may get, that still meets the purpose of the translation layer.
Final Thoughts
It’s very easy to say “We should’ve done this.” It’s also very easy to say “If I do this now, I won’t have this problem next time.” Neither of those are guarantees. Nevertheless, this approach we took of creating a wrapper and focusing on behavior, not state, seems to have served us well. In addition to being more robust, it also forces us to really consider if we have the right abstractions to work with.
Additional Resources
- Shopify Editions Summer 2022
- Pre-orders with Shopify
- Try Before You Buy with Shopify
- Subscriptions
- Purchase Options APIs
- Safe Navigation Operator
- YAGNI
- Packwerk
John DeWyze is a Staff Developer at Shopify. He has a passion for healthy teams, psychological safety, and the human side of tech. He is a proud dad, enjoys playing bridge and softball, and is an aspiring pizzaiolo.
If building systems from the ground up to solve real-world problems interests you, our Engineering blog has stories about other challenges we have encountered. Visit our Engineering career page to find out about our open positions. Join our remote team and work (almost) anywhere. Learn about how we’re hiring to design the future together—a future that is digital by design.