Key Takeaways
- Merging many pricing rules into a single app discount means the merchant sees one opaque entry in Shopify admin, and your backend has to reimplement scheduling and precedence that Shopify already does.
- Creating one
discountAutomaticAppCreateper rule gives each rule its own title, schedule, combination flags, and native customer targeting, visible and manageable in Shopify admin.- Each discount carries a single-rule config in its own metafield, so the Shopify Function code never changes when you split the architecture.
- Let Shopify own expiry (
startsAt/endsAt) and precedence (combinesWith), and reconcile merchant-side deletes with thediscounts/deletewebhook.
We started with the architecture most discount apps reach for first: one app-managed automatic discount per shop, holding every rule in one config metafield, with a Shopify Function picking the right rule at checkout. It works, but it has a design smell. A merchant who opens Shopify admin sees a single discount called something generic, with no way to tell which of their ten pricing rules it represents, no per-rule schedule, and no per-rule combination settings. And because all rules share one discount, you end up reimplementing expiry and precedence in your own backend instead of using what Shopify provides.
We moved Custom Pricing (and Volume Discount before it) to one automatic discount per rule. This guide documents that architecture: the create/update/delete sync, how the Function stays untouched, how to let Shopify own scheduling and combination, and the edge cases (stale GIDs, merchant-side deletes, segment gating) you have to handle.
Why One Discount per Rule
The merged model fails on three fronts, all of which the per-rule model fixes.
- Merchant visibility. With one discount per rule, each rule appears in Shopify admin with its own title (the rule's name), its own schedule, and its own status. The merchant can see and manage them the way they manage native discounts.
- Native scheduling. An automatic app discount accepts
startsAtandendsAt, and Shopify expires it for you (Shopify, discountAutomaticAppCreate). In the merged model, one discount cannot carry ten different schedules, so your backend has to flip rules on and off on a timer. - Per-rule combination.
combinesWithis a property of a discount, not of a rule inside it. One discount per rule lets each rule decide independently whether it stacks with product, order, and shipping discounts.
The cost is more Admin API calls and more GIDs to track, which is a fair trade for handing scheduling and precedence back to the platform.
What We're Building
The architecture has four parts:
- A per-rule sync method: create on first save, update on change, delete on removal, with each rule's discount GID stored on the rule row.
- A per-rule config metafield on each discount, shaped as a single-element rule array so the existing Function needs no change.
- Native scheduling and combination passed straight through to Shopify.
- Reconciliation for the cases where the Shopify-side and app-side states drift: stale GIDs and merchant-side deletes.
Step 1: Sync One Discount per Rule
In this step you'll write a syncDiscount(shop, rule) that creates or updates exactly one automatic discount for one rule, keyed by a GID stored on the rule.
The entry point is a simple branch on whether the rule already has a discount GID:
// app/Services/PricingService.php
public function syncDiscount(Shop $shop, PricingRule $rule): void
{
$functionHandle = config('shopify.pricing_discount_function_handle');
if (! $functionHandle) {
return; // function not configured, nothing to sync
}
if ($rule->shopify_discount_id) {
$this->updateAutomaticDiscount($shop, $rule);
} else {
$this->createAutomaticDiscount($shop, $rule, $functionHandle);
}
}Create calls discountAutomaticAppCreate, checks userErrors (always, per Shopify's mutation rules), and stores the returned discountId on the rule so future saves update instead of duplicate:
private function createAutomaticDiscount(Shop $shop, PricingRule $rule, string $functionHandle): void
{
$variables = ['automaticAppDiscount' => $this->buildDiscountInput($rule, $functionHandle)];
$response = $this->graphql($shop, DiscountAutomaticAppCreateMutation::mutation(), $variables);
$errors = $response['data']['discountAutomaticAppCreate']['userErrors'] ?? [];
if (! empty($errors)) {
Log::warning('[Pricing] Discount create errors', ['rule' => $rule->id, 'errors' => $errors]);
return;
}
$gid = $response['data']['discountAutomaticAppCreate']['automaticAppDiscount']['discountId'] ?? null;
if ($gid) {
$this->ruleRepo->update($rule->id, ['shopify_discount_id' => $gid]);
$rule->shopify_discount_id = $gid;
}
}What just happened: the discount GID lives on the rule row (pricing_rules.shopify_discount_id), which is what makes the whole model per-rule. Your CRUD then maps cleanly: create and update both call syncDiscount; delete captures the GID and calls deleteDiscount; a status toggle re-syncs the affected rule.
Watch out: always read
userErrorsbefore using mutation data. A Shopify mutation returns HTTP 200 even when it failed, so skipping the check means you store anullGID and silently create a duplicate on the next save.
Step 2: Recover From Stale GIDs
In this step you'll handle the case where the GID you stored no longer points to a live discount, which happens when a merchant deletes it in Shopify admin.
If an update fails, the most likely cause is a stale GID. Null it out and recreate, so the rule heals itself on the next sync instead of staying broken:
private function updateAutomaticDiscount(Shop $shop, PricingRule $rule): void
{
$variables = ['id' => $rule->shopify_discount_id, 'automaticAppDiscount' => $this->buildDiscountInput($rule)];
$response = $this->graphql($shop, DiscountAutomaticAppUpdateMutation::mutation(), $variables);
$errors = $response['data']['discountAutomaticAppUpdate']['userErrors'] ?? [];
if (! empty($errors)) {
// Stale GID (e.g. discount deleted in Shopify) -> null it and recreate.
$this->ruleRepo->update($rule->id, ['shopify_discount_id' => null]);
$rule->shopify_discount_id = null;
$functionHandle = config('shopify.pricing_discount_function_handle');
if ($functionHandle) {
$this->createAutomaticDiscount($shop, $rule, $functionHandle);
}
}
}What just happened: a failed update degrades into a recreate rather than an error the merchant has to notice. The rule's intent (its config, schedule, targeting) is all in your database, so recreating the Shopify-side discount is lossless.
Step 3: Keep the Function Untouched With a Single-Rule Config
In this step you'll give each discount a config metafield shaped exactly like the old multi-rule payload, so the Shopify Function code does not change at all.
The discount input carries the title, native schedule, combination flags, customer context, and a metafield holding the config for this one rule:
private function buildDiscountInput(PricingRule $rule, ?string $functionHandle = null): array
{
$input = [
'title' => $rule->name, // shows in Shopify admin
'startsAt' => $rule->starts_at?->toIso8601String() ?? now()->toIso8601String(),
'endsAt' => $rule->ends_at?->toIso8601String(), // Shopify expires it natively
'combinesWith' => $this->buildCombinesWith($rule->combines_with),
'context' => $this->buildContext($rule), // native customer/segment scoping
'metafields' => [[
'namespace' => self::DISCOUNT_METAFIELD_NS,
'key' => self::DISCOUNT_METAFIELD_KEY,
'type' => 'json',
'value' => json_encode($this->buildSingleRuleConfig($rule)),
]],
];
if ($functionHandle) {
$input['functionHandle'] = $functionHandle; // only on create
}
return $input;
}The config is the old multi-rule shape with a one-element rules array:
private function buildSingleRuleConfig(PricingRule $rule): array
{
$p = $this->buildRulePayload($rule);
return [
'version' => '1.0',
'updated_at' => now()->toIso8601String(),
'rules' => [$p], // single rule, same shape as before
'productTags' => /* this rule's tags */,
'collectionIds' => /* this rule's collections */,
'customerTags' => /* this rule's customer tags */,
];
}What just happened: because the config shape is unchanged, the Function still iterates config.rules and picks a rule exactly as it did when the array had many. No function redeploy, no typegen, no checkout risk. The split is entirely a backend concern.
Step 4: Let Shopify Own Scheduling and Combination
In this step you'll hand expiry and precedence to Shopify instead of computing them yourself.
Scheduling is free once each rule is its own discount: set startsAt/endsAt and Shopify activates and expires it. Your scheduled job shrinks to flipping the app-side status and re-syncing the storefront metafield; it no longer manages the discount lifecycle.
Combination is a deliberate product decision encoded in buildCombinesWith. For example, if you do not want two of your own pricing discounts to stack, set productDiscounts to false and Shopify applies only the single best-priced discount for the buyer:
private function buildCombinesWith(?array $c): array
{
return [
'productDiscounts' => (bool) ($c['product'] ?? true),
'orderDiscounts' => (bool) ($c['order'] ?? true),
'shippingDiscounts' => (bool) ($c['shipping'] ?? true),
];
}The consequence worth stating clearly: when productDiscounts is false and every rule is an independent discount, a cross-rule priority field in your database no longer decides the outcome. Each discount stands alone and Shopify picks the best price for the buyer. Do not reintroduce "highest-priority rule wins" logic in the backend for checkout; it will disagree with what Shopify actually applies (Shopify, discountAutomaticAppCreate example).
Step 5: Gate Segments Natively, Reconcile Deletes
Two targeting and lifecycle edges need explicit handling.
Segment gating. A pure Discount Function cannot resolve customer segments, so if you rely only on the Function to gate a segment-restricted rule, the discount applies to everyone. Set the discount's native context so Shopify scopes it:
private function buildContext(PricingRule $rule): array
{
$type = $rule->condition?->condition_eligibility_type ?? 'all';
return match ($type) {
'specific_customers' => ['customers' => ['add' => $this->extractIds($rule->condition->eligibility_customer_ids ?? [])]],
'customer_segments' => ['customerSegments' => ['add' => $this->extractIds($rule->condition->eligibility_segment_ids ?? [])]],
default => ['all' => 'ALL'], // tags / logged-in / markets stay function-gated
};
}Merchant-side deletes. A merchant can delete your discount in Shopify admin. Subscribe to the discounts/delete webhook and, when it fires, null the matching rule's shopify_discount_id and mark it inactive (match by the numeric suffix of the GID), then re-sync the storefront metafield. Without this, your database thinks a discount exists that Shopify has removed.
Verification
- Admin visibility: create two rules; confirm two separate discounts appear in Shopify admin, each titled with its rule name.
- Scheduling: set an
endsAtin the near future; confirm Shopify expires that discount without your backend touching it. - Combination: with
productDiscounts: false, add two eligible rules to a cart; confirm only the best-priced discount applies, not both. - Stale GID recovery: delete a discount in Shopify admin, then edit the rule in your app; confirm the update fails, nulls the GID, and recreates the discount.
- Segment gating: restrict a rule to a segment; confirm a customer outside the segment does not receive the discount at checkout.
- Function unchanged: confirm no function redeploy was required, because the config metafield shape is identical to the multi-rule version.
Key Takeaways
1. One discount per rule hands work back to Shopify. Scheduling and precedence are platform features. The merged model forces you to reimplement them; the per-rule model does not.
2. Keep the Function's input shape stable. Wrapping one rule in the old multi-rule config shape means the split is invisible to the Function. You change the backend, not the deployed function.
3. Store the discount GID on the rule. That single column is what makes create/update/delete map cleanly to your CRUD and what lets a failed update self-heal into a recreate.
4. Gate segments with native context, not just the Function. A pure Function cannot resolve segments, so an ungated discount leaks to everyone. Let Shopify scope it.
Frequently Asked Questions
Does one discount per rule hit Shopify's discount limits?
Automatic app discounts are generous in number, so a per-rule model for discounts is practical. Note that Cart and Checkout Validation Functions are different: a shop can only enable 25 validations at once, shared across all validation-based features. If you apply this same per-rule pattern to validations, you have to handle that cap (surface a warning when it is reached) and, because validations have no native startsAt/endsAt, drive scheduling with an enable flag toggled by a periodic job instead.
Will splitting the architecture require redeploying my Function?
No, as long as the per-discount config metafield keeps the same shape. We wrap a single rule in the existing multi-rule config (rules: [oneRule]), so the Function iterates the array exactly as before. No redeploy and no typegen.
What happens to the old merged discount after migrating?
It does not clean itself up. The old one-per-shop discount keeps existing until someone deletes it in Shopify admin, and if left enabled it can double-apply alongside the new per-rule discounts. Delete it manually as part of the migration.
How do I keep priority working across rules?
If you set combinesWith.productDiscounts: false, you cannot, and you should not try. Independent discounts are resolved by Shopify, which picks the best price for the buyer. Priority only makes sense within a single discount's rule set, not across separate discounts.
Written by Duke, based on the production implementation of the Flicky Shopify app. Code samples are drawn from the shipping codebase and simplified for readability. The per-rule config metafield is kept lean on purpose: see why we store numeric IDs, not GIDs.