Why subclassing Medusa's fulfillment service silently fails
Medusa's cancelFulfillment calls its shipped/delivered guard by class name, so subclass overrides never run — how to spot the shape and work around it.
- Medusa.js
- Migrations
- Operations

A customer buys a license key. The order fulfills through a manual provider, an admin clicks the mark-as-delivered action — or your own code calls markFulfillmentAsDeliveredWorkflow — and then there's a chargeback, or support needs to void the order. Cancelling the fulfillment throws MedusaError: Fulfillment with id ... already delivered. Nothing was shipped. Nothing physical exists anywhere in the world. And the order itself can't be cancelled either, because Medusa requires every fulfillment on it to be cancelled first, and now none of them can be.
So you reach for the documented escape hatch: extend the module. Subclass the core module's service, override the guard, register your subclass in medusa-config.ts in place of the built-in module. You write the override, deploy it, and the exact same error comes back. This is a real design gap, reported against Medusa v2.19.0.
The guard is invoked by name, not by type
Here is the relevant shape inside the Fulfillment module service:
async cancelFulfillment(id: string, sharedContext = {}) {
const fulfillment = await this.fulfillmentService_.retrieve(id, {}, sharedContext)
FulfillmentModuleService.canCancelFulfillmentOrThrow(fulfillment) // <-- hardcoded
// ...
}
Static methods in JavaScript are inherited, so your subclass genuinely has its own canCancelFulfillmentOrThrow. But the call site doesn't ask the instance what class it is. It names FulfillmentModuleService literally, which resolves at that lexical scope to the base class, forever, regardless of what your instance actually is. The polymorphic form the issue proposes is one line:
;(this.constructor as typeof FulfillmentModuleService).canCancelFulfillmentOrThrow(fulfillment)
That's behavior-preserving for the base class — this.constructor on a base instance is the base class — and it makes the documented override pattern actually work for this guard. Until something like it lands upstream, the guard is effectively final even though nothing in the type system says so.
The silence is what costs you
A hard failure would be cheap. This one passes review. Unit tests that call your subclass's canCancelFulfillmentOrThrow directly pass, because that method exists and does what you wrote. Type-checking passes, because the override signature is compatible. The class shows up in your module registration. Every signal you'd normally trust says the customization is live.
Only a test that enters through cancelFulfillment on a fulfillment with delivered_at set catches it — and in development you probably never set delivered_at in fixtures, because the happy path never touches it. The rule generalizes past Medusa: assert against the entry point production calls, not the method you happened to write. Any test that calls an override directly is testing your code, not the wiring.
The same shape shows up wherever a class names itself
The defect is not the guard's logic; it's the dispatch. Look for any place a class refers to its own identifier inside its own instance methods. In the upstream tree that's a one-liner:
rg -n 'FulfillmentModuleService\.' packages/modules/fulfillment/src
In your own project, sweep every module service you've subclassed and check whether the methods you rely on are reached through this or through a name. A second, related smell in the same file: reaching for _-suffixed fields. fulfillmentService_, fulfillmentProviderService_, and baseRepository_ are private by convention. If your override touches them, you are outside any stable API and you own that coupling across upgrades.
The honest workaround is replacing the method, not the guard
Because the guard isn't reachable, the only override that changes behavior today is cancelFulfillment itself. That means copying the upstream method body into your subclass, changing exactly one line, and accepting that the copied body touches those private fields.
Do it deliberately if you do it. Pin the Medusa version. Keep the override to that single method and nothing else. Record, in a comment at the top of the override, the upstream commit you copied from, so the upgrade step is a diff rather than an archaeology session. Treat it as a vendored patch you re-derive each minor release, not as code you own. That's a real maintenance cost, and it's the correct thing to weigh against the alternative below.
Preventing the state beats relaxing the guard
The cheaper fix for most non-physical catalogs is to never produce the state the guard objects to. shipped_at and delivered_at are meaningful for parcels; for a license, a ticket, or a download entitlement they're decoration that an admin can set with one mis-click and never undo. If nothing in your flow calls markFulfillmentAsDeliveredWorkflow for those items, the guard never fires, and you need no override at all.
That pushes the customization up to the workflow layer, where Medusa's extension surface is both documented and more stable than a module service's private internals. Model delivery for digital items in your own domain — an entitlement record, a redemption timestamp — and let the fulfillment row stay in a state core Medusa is willing to cancel. The issue also floats a provider-level capability, something like handlesPhysicalGoods: false, that the core guard would consult. That's a proposal in the thread, not shipped behavior; don't build against it yet.
Know which of your customizations are load-bearing
The useful takeaway from one hardcoded reference is a question about your whole install: for each customization you've written, does anything actually route through it? Subclassing a service is a promise the language usually keeps, and when a call site quietly opts out, your override becomes decorative — present, tested, reviewed, and dead.
This is the class of surprise that shapes how a replatform goes. Coming off Magento, where interceptors and plugins give you a supported seam around nearly any public method, the instinct is that overriding is always available. In Medusa the seams are real but narrower, and they live at the workflow and provider layers more than inside module services. Planning that out before the cutover is most of the work in a Magento 2 or Adobe Commerce migration to Medusa or Vendure.
Start with the sweep: grep every subclassed service for self-named calls, then write one integration test per override that enters through the public method and asserts your behavior — not the override's existence. Whatever fails that test is a customization you think you have. If you're mapping out a replatform and want the extension points sized honestly before you commit to a stack, get in touch.
Need this done on a real stack?
Magento 2, Adobe Commerce, migrations to Medusa.js or Vendure, enterprise Next.js, WordPress, and AI automation.
Contact us