Building a Custom Module for Medusa.js: Examples and Architecture

Store customizations on Medusa.js often break with every platform update, and the team spends weeks restoring logic. We design and implement custom modules turnkey — isolated from the core, so updates happen without downtime or loss of functionality. The logic stays operational, and maintenance takes a fraction of the time.

Development and maintenance of all types of websites:

Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Frequently Asked Questions

Why a custom module for Medusa.js breaks on update

Updating the core is the most common moment when a custom module for Medusa.js stops working.

The reason is not the update itself, but how the module was integrated: if it edits core files directly or keeps its own migrations in the shared chain, the update overwrites the changes or halts on a migration conflict. Let's break down how this is resolved architecturally.

The first category of failures is migration conflict. The module adds its tables to the same sequence as the core, and during an update the order of application changes: some migrations have already run, some are still pending, and the schema ends up in an intermediate state.

The database comes up, but some queries fail on missing columns. The fix is isolation: the module's migrations live as a separate set and are applied independently of the core.

The second is overwriting of extensions. Edits made on top of the core in services, event subscribers, or resolvers disappear during an update: the files are replaced with new versions, and the module's logic silently vanishes.

Here only extension through the standard hooks works — event subscriptions, custom services, wrappers over existing ones — then a core update does not touch the module's code.

The third and most costly is the loss of entity relationships. The module references core entities by identifiers, and during an update the structure or delete cascade changes: relationships break, dangling records appear, reports and carts show incomplete data.

This is not discovered immediately, but weeks later, when the discrepancy has already accumulated. The protection is explicit foreign keys with a well-thought-out delete policy and integrity checks in migrations.

We account for these three scenarios at the module design stage, not after the first update: isolated migrations, extension through the standard hooks, referential integrity control. Next — how this looks in the architecture and with real examples.

What a Properly Isolated Module Delivers

Module Boundaries and Build Speed

Isolation begins with the module declaring its dependencies explicitly and not pulling them from the shared application context. Rebuilding when editing a single domain stops triggering compilation of the entire project: only the affected module and what depends on it get built.

On a typical project, this cuts the build cycle from minutes to tens of seconds, and hot reload during development fits within a couple of seconds — edits stop piling up in a queue.

Recovery Time After a Failure

When a module crashes, the failure is localized: the rest of the application keeps serving requests instead of going into a general outage.

Recovery boils down to restarting a single module rather than the entire service — the time to return to a working state is measured in seconds instead of minutes. A whole class of failures also disappears, where the crash of a secondary function dragged down checkout or payment.

Share of Manual Operations

Configuration, migrations, and module registration are described declaratively, so connecting a new domain requires no changes to the core and no manual synchronization of environments.

Updating a module means swapping the version and running migrations, not a set of manual data-transfer steps. As a result, the share of operations an engineer performs by hand during a rollout drops, and along with it, the likelihood of a careless mistake.

What This Delivers Over the Long Run

A module can be developed and tested separately: its own checks, its own fixtures, a predictable area of responsibility. A new engineer gets up to speed on a task faster because they see the boundaries instead of untangling a knotted mess.

It is precisely this predictability that is the main effect of isolation — it pays off on every subsequent iteration.

Extension approaches: comparison of approaches

In Medusa.js, extending functionality can almost always be done in three ways, and the choice between them determines how the system will behave during updates and load growth.

A built-in extension lives inside the core and reuses its services directly, a separate module moves the logic into an isolated package with its own lifecycle, and an external service moves it outside the application entirely. Below is a comparison by the criteria that actually affect maintenance.

Option When it fits Limitations of the approach
Built-in extension The logic is tightly coupled to orders, cart, or catalog and must read their state within the same transaction Updating the core requires manually porting the changes; coupling grows, tests slow down
Separate module Reusable domain logic is needed: integrations, calculations, custom entities Requires explicit description of dependencies and migrations; inter-module calls must be designed in advance
External service Load is peaky, the logic is on a different stack, or a ready-made service already exists A network call appears: idempotency, retries, and handling of partial failures are needed

The difference shows up over the long run: a built-in extension is faster to assemble, but every core update turns into a revision of the changes. A separate module is more expensive at the start — but it updates independently and is covered by tests in isolation.

An external service gives freedom of stack, but shifts part of the responsibility for data consistency onto the infrastructure.

In practice, the choice is rarely either-or: the order core stays built-in, calculations and integrations go into a module, and heavy operations go into an external service.

Describe the task — we will come back with an estimate and a plan for which option will cover it without unnecessary coupling.

Module Design and Implementation Stages

Work on a module begins not with code, but with an analysis of what already exists in the project.

At this stage, it is important to understand exactly where the boundary lies between the platform's standard behavior and your logic — otherwise the module will start duplicating built-in mechanisms and drag along unnecessary dependencies.

  1. Codebase audit. We review the project structure, the core version, the set of installed modules, and how services and event subscriptions are organized within them. We identify the extension points that are already in use and those that remain untouched.
  2. Data schema design. We define the module's entities, their relationships to the base ones (product, order, customer), migration rules, and rollback strategy. We separately discuss which fields are mandatory and which are populated by event.
  3. Module configuration. We assemble the configuration: registration in the main application, connection parameters, access rights, environment variables for different environments. The module must start up in both development and production without manual edits.
  4. Implementation and integration verification. We write the logic, cover key scenarios with automated tests, and verify the integration with existing services — order placement, delivery calculation, exchange with external systems.
  5. Load testing. We run scenarios under a load close to production and check whether requests to the catalog and cart degrade after the module is connected.
  6. Handover and documentation. We deliver the architecture description, data schema, deployment instructions, and a checklist of typical failures. The client's team must be able to update the module without us.

Each stage ends with an artifact that can be verified: an extension point map, data schema, configuration, test report. This way, at any step it is clear what has already been done and what remains — and the risk of hidden rework at the end of the project disappears.

Artifacts the client's team retains

Work on a custom module for Medusa does not end at deployment — after handover, the client's team must retain a package that allows the module to be developed, updated, and passed on to new engineers without us. Below is exactly what is captured in the handover package.

  • Module configuration — working settings files with a description of each parameter: which values are mandatory, which depend on the environment, what changes when a new payment or delivery provider is connected.
  • Data schema — a description of entities, relationships, and migrations: which tables the module creates, how they relate to the core, what happens when a migration is rolled back.
  • Access policies — a matrix of roles and permissions: which administrators can view and edit the module's data, which operations are available via the API, where restrictions are enforced at the request level.
  • Documentation — instructions for deploying from scratch, a description of extension points, and usage examples so that new logic can be added without reading the entire source code.
  • Update procedure — the sequence of actions when a new core version is released: which checks are run, how module compatibility is tested, and at what point the update is rolled back.
  • Set of automated tests — coverage of the module's key scenarios: order placement, cost calculation, synchronization with external services. The tests show that an update has not broken behavior.
  • Integration diagram — a map of the external services the module is connected to, indicating which data is transferred where and what happens when a service is unavailable.

Each item is not a formality for the sake of a report, but a response to a specific risk: a change of contractor, a core update, connecting a new sales channel.

If the client's team opens the documentation and reproduces the environment from scratch using it, the package has been assembled correctly.

Case: loyalty module for an online store

What it was before implementation

An online store on Medusa.js with loyalty points accumulation directly in the main application: the logic for accrual, expiration, and redemption lived in order controllers and background jobs.

Any change to the rules meant editing the core, running the full test suite, and rebuilding the entire application.

The build took about 12 minutes, and releasing loyalty rules had to be coordinated with the team responsible for checkout — the deployment queue stretched over days.

In parallel, manual operations had accumulated: managers manually recalculated points on returns, cancellations, and partial shipments.

For every hundred orders, there were 8–10 manual adjustments, and it was precisely these that accounted for the bulk of discrepancies in customer balances.

What was changed

All loyalty logic was moved into a separate custom module: dedicated tables for balances and transactions, an accrual service with explicit rules, subscriptions to order events instead of controller edits.

The main application no longer knows about the program's details — it only publishes events, and the module decides how many points to accrue, redeem, or return.

Expiration and accrual rules were moved into the module's configuration rather than the code: changing conditions no longer requires rebuilding the core.

Operation idempotency was also addressed separately — reprocessing the same event does not double the points, which eliminated an entire class of discrepancies during queue failures.

What it became

Application build time dropped from 12 to 4 minutes — loyalty edits no longer drag a full project rebuild along with them. Manual point adjustments fell from 8–10 per hundred orders to isolated cases per month.

The failure class "double accrual on event reprocessing" disappeared entirely: operations became idempotent, and balance discrepancies are now caught at the testing stage rather than in support.

The module is isolated, so it can be developed as a separate stream: adding new tiers, promotions, and partner accruals without touching checkout.

How does a custom module differ from a plugin?

A plugin in Medusa is an add-on over an existing contour: it subscribes to events, adds routes, extends services, but lives inside the core and inherits its lifecycle. A custom module is a self-contained unit with its own entities, migrations, services, and connection points.

The difference shows up not in the volume of code, but in the level of isolation: a plugin shares space with the core, while a module maintains its own boundary and communicates with the rest of the application through explicit interfaces.

This leads to the main practical divergence — behavior during updates. A plugin hooks into internal hooks and structures, so a major platform update regularly breaks compatibility: handlers have to be rewritten, load order checked, dependency conflicts caught.

A module is updated separately from the core — if the contract at the junction hasn't changed, the upgrade goes through without edits to business logic.

This is what determines the cost of maintenance: for a plugin it grows with the frequency of platform updates, for a module it remains predictable and is concentrated in one place.

Typical scenarios diverge along the same boundary. A plugin is appropriate when you need to add an integration with an external service, extend an existing entity with a field, or attach a handler to an event — a point intervention without its own data model.

A custom module is chosen when a separate subject area appears: a loyalty program, warehouse accounting, billing, complex pricing rules. Such logic gets its own tables, its own migrations, and its own set of tests, rather than dissolving into the core.

There is also an intermediate path: start with a plugin and extract the code into a module when it becomes overgrown with state and connections.

We assess not "whether it's trendy to build a module," but where the domain boundary lies — this determines how much the next platform update will cost.

Common technical concerns when choosing a contractor

A custom module in Medusa is an isolated package that connects to the core through the module mechanism and does not touch its source code.

So the main question is not "will it work," but "how will it survive updates and a change of contractor." Below is what is most often checked on our side before the start.

How will the module behave when the core is updated?

We do not patch the core or fork it — all logic lives in a separate module with explicitly declared dependencies. When moving to the current branch, the core is updated, and the module undergoes a compatibility check separately: contracts, extension points, migrations. If a core version changes behavior, it shows up in tests, not in production.

What about migrating already accumulated data?

We describe the module's data schema with versioned migrations: each migration is reversible, and the rollout runs on a copy of the database before production.

Existing orders, products, and relations are transferred by scripts with integrity checks — we compare the number of records and key relations before and after. Rollback is possible at any step.

How do you verify that the module does not break the store?

We cover critical paths with tests: checkout, shipping calculation, synchronization with external systems. Before rollout, we run the module on a copy of production data — this is how we catch conflicts with existing extensions.

We separately test behavior under load so that the new code does not degrade the response time of the catalog and cart.

What do we have left after delivery?

We hand over more than just code: the architecture diagram, a description of extension points, deployment and update instructions, a set of tests, and a migration log. We hold a walkthrough with your team — so that the module can be developed without us. If there is no team, we take on support ourselves.

These four points cover the main part of the risks. Describe your task — we will come back with an estimate and a plan.

Let's discuss your task and come back with a plan

To make the estimate substantive rather than "give or take a quarter," the preliminary review is based on your current codebase and store data. Then we lock down the work plan and timelines by stage — so it's clear what gets done first and where the checkpoints are.

  • A short conversation about the task: which processes the module covers, what already works and what's getting in the way — based on what your team and the product owner say.
  • Access to the repository and Medusa configuration: we look at the core version, module structure, core customizations, and event subscriptions.
  • Review of the data schema: entities, relationships, migrations, volumes — this determines whether the module will be a separate package or an extension of an existing one.
  • Integration check: payment providers, warehouse, shipping, accounting systems, queues — what the module needs to call and what needs to call it.
  • Agreement on boundaries: what's included in the module, what stays in the core, which interfaces we lock down as public.
  • Timeline estimate by stage: design, implementation, migrations, tests, rollout — with a separate buffer for incompatibilities.
  • Rollout and rollback plan: how the module is enabled on staging, how it's verified against production data, and how it's disabled without losing orders.

What we need from you to get started: access to the repository and test environment, a description of the processes the module covers, and the contact of the engineer managing the project on your side.

If some data is missing, the review is still possible — some estimates will just be based on assumptions, and we'll flag that directly in the plan.

Describe the task — we'll come back with a timeline estimate and a staged work plan, with clearly flagged risks and checkpoints.

Our competencies:

Latest works

  • Development of a web application for FEEDME
    Development of a web application for FEEDME
    1393
  • Development of an online store for the company FURNORO
    Development of an online store for the company FURNORO
    1368
  • Development of a web application for Enviok
    Development of a web application for Enviok
    1139
  • CRM development for Chasseurs
    CRM development for Chasseurs
    1152
  • Website development for SBH Partners
    Website development for SBH Partners
    1231
  • Website development for Red Pear
    Website development for Red Pear
    641