4 MIN READ

Medusa Project Structure: Where Every Kind of Code Belongs

Medusa loads code by convention. A directory-by-directory guide to what goes where, and the decision tree for placing any new piece of logic.

BY RAHUL MEHTAUPDATED
Illustration for “Medusa Project Structure: Where Every Kind of Code Belongs” — Architecture

Medusa has no registration file for most things. Put a subscriber in src/subscribers/ and it is a subscriber. This is pleasant right up until you are unsure which of six directories a new piece of logic belongs in — and that choice determines how the codebase ages.

Here is the map, and the decision tree.

The map

text
src/
├── admin/          Dashboard extensions — widgets and routes
├── api/            HTTP endpoints, mirroring the URL path
├── jobs/           Scheduled work, cron-style
├── links/          Relationships between modules
├── modules/        Commerce domains: models, service, migrations
├── scripts/        One-off executables — seeds, backfills
├── subscribers/    Event handlers
└── workflows/      Multi-step processes with compensation

src/modules/

The unit of data ownership. One directory per domain, each with models/, service.ts, index.ts and generated migrations/. If it has tables, it lives here. Modules explained.

src/workflows/

Business processes. Convention is src/workflows/<name>.ts for the workflow and src/workflows/steps/<name>.ts for its steps. Anything touching two modules or one external system belongs here. Workflows explained.

src/api/

Directory structure mirrors the URL:

text
src/api/
├── admin/
│   └── suppliers/
│       ├── route.ts          → /admin/suppliers
│       └── [id]/route.ts     → /admin/suppliers/:id
├── store/
│   └── wishlists/route.ts    → /store/wishlists
└── middlewares.ts            → route middleware config

Export GET, POST, PUT or DELETE from route.ts. Routes under /admin require admin authentication; /store routes require a publishable key.

Keep route handlers thin: validate, resolve, call a workflow or service, respond. Business logic in a route handler is logic no subscriber or job can reuse.

src/subscribers/

Event handlers. One file per concern, exporting a handler and a config naming the event. Events and subscribers.

src/jobs/

Scheduled work:

src/jobs/sync-inventory.tsts
import { MedusaContainer } from "@medusajs/framework/types"
import { syncInventoryWorkflow } from "../workflows/sync-inventory"

export default async function syncInventoryJob(container: MedusaContainer) {
  await syncInventoryWorkflow(container).run({ input: {} })
}

export const config = {
  name: "sync-inventory",
  schedule: "0 * * * *",
}

Jobs run on instances in worker mode. Keep the job body to a single workflow invocation — a job is a trigger, not a place for logic.

One file per relationship, named for the pair: product-brand.ts, order-invoice.ts.

src/admin/

widgets/ inject into existing dashboard pages; routes/ add new ones. Admin customisation.

src/scripts/

Executables run manually via npx medusa exec ./src/scripts/<name>.ts. Seeds, backfills, data repairs. Not part of the request path, and not scheduled.

The decision tree

For any new piece of logic, in order:

  1. Does it own data with its own lifecycle? → a module.
  2. Does it happen on a schedule? → a job that calls a workflow.
  3. Does it react to something that already happened? → a subscriber.
  4. Is it triggered by an HTTP request? → an API route that calls a workflow or service.
  5. Does it touch more than one module or an external system? → a workflow.
  6. Is it a single-module operation? → a method on that module's service.
  7. Is it a one-off? → a script.

Note that 2, 3 and 4 all end up calling a workflow. That is intentional: the entry point and the logic are different concerns, and keeping them separate is what lets the same process be triggered three ways.

What people get wrong

Logic in API routes. Fine until a scheduled job needs the same behaviour and you copy it.

A module with no models. If it has no data, it is a workflow or a service wrapper, not a module.

Subscribers doing transactional work. If failure should undo the original operation, it is a workflow step.

Scripts that become permanent. A backfill script run monthly is a job that has not been promoted yet.

Deep nesting in api/. URL depth is API design. Four levels usually means the resource model needs rethinking.

Configuration

medusa-config.ts at the root does two jobs: project configuration (database, Redis, CORS, secrets, worker mode) and module registration. Module and plugin registration is explicit; everything under src/ is not. See environment variables for how to keep that file free of literals.

Organising a large codebase

Convention tells you which directory. It does not tell you how to organise thirty modules and eighty workflows. What has held up for us:

Group workflows by domain, not by verb.

text
src/workflows/
├── subscriptions/
│   ├── renew-subscription.ts
│   ├── pause-subscription.ts
│   └── steps/
│       ├── charge-subscription.ts
│       └── advance-billing-period.ts
├── b2b/
│   ├── approve-order.ts
│   └── steps/
└── import/
    └── import-products.ts

Nested folders load fine, and a new engineer looking for subscription billing finds it in one guess.

Keep steps next to the workflow that owns them. A shared steps/ directory at the root becomes a junk drawer within a quarter. Only genuinely reused steps belong in a common location, and there are fewer of those than you expect.

One file per API route. Resist combining. The path is the file, and that is the property that makes the codebase navigable.

Where the shared code goes

The one thing convention does not cover: helpers that are neither a module nor a workflow.

CodeLocation
Pure functions, formatting, validation`src/lib/`
Shared TypeScript types`src/types/`
Third-party API clientsInside the module that owns them
Constants and enums`src/lib/constants.ts`

Keep a third-party client inside the module that wraps that service, not in a shared lib. The moment two modules import the same client, you have coupled them through the back door — and the isolation rules exist precisely to prevent that.

Naming conventions

Small decisions, applied consistently, that make a codebase navigable:

ThingConventionExample
Module directorySingular domain noun`src/modules/loyalty/`
Module constant`SCREAMING_SNAKE``LOYALTY_MODULE`
Workflow fileImperative verb phrase`renew-subscription.ts`
Step fileImperative verb phrase`charge-subscription.ts`
Step name stringMatches the filename`"charge-subscription"`
Subscriber fileEvent it handles`order-placed.ts`
Link fileThe pair`product-brand.ts`

The one that pays off during an incident is matching step names to filenames. When a workflow fails at 2am, the log line names the step, and you want that to be a filename you can open rather than a string you have to grep for.


Structure is cheap to get right at the start and expensive to fix later. We do architecture reviews if a codebase has drifted.

Frequently asked questions

Where do I put custom business logic in Medusa?

In a workflow if it spans modules or calls an external system, or in a module service method if it is confined to one domain. API routes, subscribers and jobs should be thin entry points that invoke those, so the same logic is reachable from all three.

How does Medusa know about my files?

By convention. Each directory under `src/` has a loader that picks up files matching its pattern — `route.ts` under `api/`, default exports with a `config` under `subscribers/` and `jobs/`, and so on. Only modules and plugins need explicit registration in `medusa-config.ts`.

What is the difference between a job and a subscriber?

A job runs on a schedule; a subscriber runs in response to an event. Use a job for periodic reconciliation, and a subscriber for reacting to something that just happened. Both should delegate to a workflow.

Can I change the Medusa directory structure?

The conventional paths are what the loaders look for, so moving them means losing automatic loading. You can organise freely *within* each directory — nested folders under `workflows/` or `modules/` work fine — which is usually all anyone actually wants.

Where do custom API routes go?

Under `src/api/`, with the directory path mirroring the URL and dynamic segments in brackets. `src/api/store/wishlists/[id]/route.ts` becomes `/store/wishlists/:id`, and the file exports one function per HTTP method.

What belongs in src/scripts?

One-off executables run with `npx medusa exec`: seed data, data backfills, migrations of business data that are not schema changes. If you find yourself running one on a schedule, promote it to a job.

[ Keep reading ]