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
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 compensationsrc/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:
src/api/
├── admin/
│ └── suppliers/
│ ├── route.ts → /admin/suppliers
│ └── [id]/route.ts → /admin/suppliers/:id
├── store/
│ └── wishlists/route.ts → /store/wishlists
└── middlewares.ts → route middleware configExport 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:
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.
src/links/
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:
- Does it own data with its own lifecycle? → a module.
- Does it happen on a schedule? → a job that calls a workflow.
- Does it react to something that already happened? → a subscriber.
- Is it triggered by an HTTP request? → an API route that calls a workflow or service.
- Does it touch more than one module or an external system? → a workflow.
- Is it a single-module operation? → a method on that module's service.
- 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.
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.tsNested 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.
| Code | Location |
|---|---|
| Pure functions, formatting, validation | `src/lib/` |
| Shared TypeScript types | `src/types/` |
| Third-party API clients | Inside 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:
| Thing | Convention | Example |
|---|---|---|
| Module directory | Singular domain noun | `src/modules/loyalty/` |
| Module constant | `SCREAMING_SNAKE` | `LOYALTY_MODULE` |
| Workflow file | Imperative verb phrase | `renew-subscription.ts` |
| Step file | Imperative verb phrase | `charge-subscription.ts` |
| Step name string | Matches the filename | `"charge-subscription"` |
| Subscriber file | Event it handles | `order-placed.ts` |
| Link file | The 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.
The Medusa Container: Dependency Injection Without the Ceremony
Everything in Medusa is resolved from a container. What is registered, how scoping works per request, and how to avoid the two mistakes that cause leaks.
Medusa Module Links: Relating Data Across Isolated Modules
Modules cannot reference each other's tables, so how do you attach a brand to a product? Links — and the query layer that reads across them.
Medusa v2 Modules Explained: Building Custom Commerce Domains
Modules are the unit of everything in Medusa v2. What they are, why isolation matters, and how to build one that will not embarrass you in a year.



