---
title: "Medusa Architecture Explained: How It Works for Developers"
slug: "medusa-architecture-explained-developers-guide"
published: "2026-04-20"
updated: "2026-04-01"
validated: "2026-04-01"
categories:
  - "Medusa.js"
tags:
  - "Medusa architecture"
  - "medusajs"
  - "Medusa modules"
  - "Medusa workflows"
  - "module links"
  - "pickup scheduling medusa"
  - "medusa data models"
  - "medusa migrations"
  - "medusa admin ui"
  - "container dependency injection"
  - "commerce framework"
  - "custom medusa module"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "medusa"
  - "medusajs"
  - "node.js"
  - "typescript"
  - "postgresql"
status: "stable"
llm-purpose: "Medusa architecture demystified: learn how modules, services, workflows, links, and migrations let developers extend Medusa safely. Read the guide to get…"
llm-prereqs:
  - "Access to Medusa"
  - "Access to MedusaJS"
  - "Access to Node.js"
  - "Access to TypeScript"
  - "Access to PostgreSQL"
llm-outputs:
  - "Completed outcome: Medusa architecture demystified: learn how modules, services, workflows, links, and migrations let developers extend Medusa safely. Read the guide to get…"
---

**Summary Triples**
- (Project shell, purpose, Acts as a customization layer that wires prebuilt Medusa modules into a running commerce app)
- (Medusa request flow, sequence, HTTP routes → workflows → module services → data models → database)
- (Modules, contain, services, models, migrations, links, and optional Admin UI extensions)
- (Services, responsibility, Encapsulate business logic, manage models, and handle transactions)
- (Workflows, role, Coordinate multi-step domain processes by calling multiple services in order)
- (Links, function, Provide composable extension points to alter or extend behavior via DI without modifying core code)
- (Admin extensions, expose, Merchant-facing UIs that map to module APIs and models)
- (Migrations, guarantee, Database schema changes remain consistent with model definitions across environments)
- (Overriding behavior, mechanism, Replace or decorate services through the dependency injection container and module registration)
- (Local dev & deploy, recommendation, Run Medusa with Docker + Postgres and execute migrations during startup for reproducible environments)
- (Testing changes, approach, Add unit tests for services, integration tests for workflows, and smoke tests for API routes)
- (Extending data model, steps, Add model definition → create migration → update service to use model → expose via API/workflow → add Admin UI if needed)

### {GOAL}
Medusa architecture demystified: learn how modules, services, workflows, links, and migrations let developers extend Medusa safely. Read the guide to get…

### {PREREQS}
- Access to Medusa
- Access to MedusaJS
- Access to Node.js
- Access to TypeScript
- Access to PostgreSQL

### {STEPS}
1. Grasp the Medusa mental model
2. Inspect the project structure and shell
3. Create a custom module scaffold
4. Define data models and module links
5. Implement service logic and APIs
6. Author workflows and hook into flows
7. Extend the Admin UI with routes/widgets
8. Generate migrations and deploy server/worker

<!-- llm:goal="Medusa architecture demystified: learn how modules, services, workflows, links, and migrations let developers extend Medusa safely. Read the guide to get…" -->
<!-- llm:prereq="Access to Medusa" -->
<!-- llm:prereq="Access to MedusaJS" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to PostgreSQL" -->
<!-- llm:output="Completed outcome: Medusa architecture demystified: learn how modules, services, workflows, links, and migrations let developers extend Medusa safely. Read the guide to get…" -->

# Medusa Architecture Explained: How It Works for Developers
> Medusa architecture demystified: learn how modules, services, workflows, links, and migrations let developers extend Medusa safely. Read the guide to get…
Matija Žiberna · 2026-04-20

The first time I opened a Medusa codebase, it felt almost too empty to be serious.

There was no giant app layer full of controllers and business logic. Mostly I saw `src/modules`, `src/workflows`, `src/api`, and `src/admin`, plus a lot of commerce behavior that clearly existed somewhere but not inside the project tree I was editing. The important shift was realizing that a Medusa app is not supposed to contain the whole commerce engine. It is supposed to contain the part you are changing. Medusa ships a lot of the core domain behavior already, and your code sits around it as an extension layer. That part is straight from the architecture docs, but it only really clicked once I tried to add a feature that crossed carts, orders, stock locations, admin UI, and checkout hooks. ([Medusa Docs](https://docs.medusajs.com/learn/introduction/architecture?utm_source=chatgpt.com))

The feature I used to understand that architecture was a pickup scheduling prototype. I wanted admins to define pickup slots against existing stock locations, let shoppers choose one on the cart, and then carry that selection onto the final order after checkout. That sounds small. In practice it forced me to touch almost every Medusa extension seam that matters: a custom module, module links, workflows, custom API routes, an Admin route, migration generation, and a checkout hook.

That concrete build changed how I think about Medusa. Before that, the architecture sounded elegant in the abstract. After building against it, it felt more like this:

Medusa's codebase feels empty at first, and that is both its strength and its tax.

The strength is obvious once you accept the model. You are not fighting a giant monolith to add one business-specific feature. The tax is that you need to learn where Medusa expects each kind of logic to live, and until that mental map settles, even a modest feature can feel more ceremonious than it would in a conventional ORM-first app.

## The pickup scheduling build that made the architecture click

Here is the shape of the feature I built:

- Reused Medusa `stock_location` records as pickup locations instead of inventing a second location model.
- Added a custom module with two records: `pickup_slot` and `pickup_selection`.
- Linked `pickup_slot` to `stock_location`, and `pickup_selection` to `cart` and `order`.
- Exposed the feature through custom admin and store routes.
- Moved the selected slot from cart scope to order scope when checkout completed.

That single feature ended up being the clearest explanation of Medusa's architecture I could find, because it made each concept show up under pressure instead of as a glossary item.

## Why the codebase feels thin

Medusa projects look thin because most of the core commerce behavior is already packaged into built-in modules. Your local code is mostly the customization layer, not the whole system. That means the repo can look underwhelming on day one and surprisingly capable on day ten.

I think this is one of Medusa's best traits if you are building commerce features that do not justify forking the platform. But it also means onboarding is weirder than in systems where the entire domain model is visible in one app tree. With Payload, for example, a lot of meaning is concentrated in the collection definition. With Medusa, meaning is distributed across modules, workflows, routes, links, and hooks. Once you stop looking for one central model layer, the project layout makes more sense.

## Module, service, workflow, route: what those layers feel like in practice

On paper, these layers are easy to define. In practice, they feel more opinionated than many Node backends.

A **module** is where new business data really belongs. For pickup scheduling, that meant defining `pickup_slot` and `pickup_selection` inside one isolated feature boundary instead of scattering that logic across carts or orders. Medusa documents modules as reusable domain packages, and that framing is accurate. The part the docs do not really convey is that a module makes the codebase feel calmer once the feature is working, because it gives you one obvious home for the custom domain instead of making you thread custom fields through core entities. ([Medusa Docs](https://docs.medusajs.com/learn/fundamentals/modules?utm_source=chatgpt.com))

A **service** is the runtime interface for that module. That part is conventional enough. The service owns CRUD-like behavior and feature-specific operations against the models. ([Medusa Docs](https://docs.medusajs.com/learn/fundamentals/data-models?utm_source=chatgpt.com))

A **workflow** is where Medusa becomes distinctly Medusa. I used workflows for slot creation, slot deletion, selection changes on the cart, and final confirmation on order creation. That separation felt heavy at first, especially for simple mutations. After the feature started spanning validation, capacity changes, reselection logic, and order finalization, the workflow boundary stopped feeling like ceremony and started feeling like guardrails. It gave the feature a place for business process logic that was broader than one service call but narrower than "misc backend glue."

A **route** is deliberately thin. In this build, the routes mostly validated payloads, resolved dependencies, and handed control to workflows. That split is one of the more convincing parts of Medusa's architecture once you have lived with it for a bit. It discourages the slow drift where route handlers become your real application.

## Links are elegant once they click, but they are not free

The most distinctive part of this build was Medusa's link model.

I linked:

- `pickup_slot` to `stock_location`
- `pickup_selection` to `cart`
- `pickup_selection` to `order`

That let me keep the pickup scheduling data in its own module instead of modifying Medusa core tables. Architecturally, I think this is the right idea. Cross-module relationships stay explicit, and module isolation remains real instead of being something people talk about and then immediately violate.

But links have a learning cost. If you come from a typical ORM mindset, they feel heavier than a normal relation at first. You are not just saying "these records relate." You are accepting Medusa's rule that cross-domain connections need their own explicit mechanism. Once I accepted that, the model felt clean. Before that, it felt like extra ceremony for something an ORM would let me express in one line. Both reactions are true.

For this pickup feature, I deliberately kept both the raw IDs and the Medusa links:

- The plain IDs made filtering and MVP business logic simpler.
- The links kept the feature aligned with Medusa's extension model.

That hybrid approach felt pragmatic. Purists may prefer pushing everything through the full Medusa link/query path, but for a feature prototype I found it useful to keep the logic readable without throwing away the architecture.

## Stock locations were the right reuse point

One decision that paid off immediately was treating Medusa stock locations as pickup locations instead of inventing a parallel location system.

That reuse gave me admin-managed places for free and kept the feature aligned with Medusa's existing domain model. It also exposed one of the more interesting Medusa tradeoffs: when the platform already has a concept that is close to what you need, the best custom feature is often not a brand new model. It is a thin extension around the existing concept.

That sounds obvious, but it changes the way you design features in Medusa. You spend less time asking "what tables should I add?" and more time asking "which built-in domain should this feature attach itself to?"

## The checkout hook is where the architecture stopped being theoretical

The part that made the whole system feel real was the order-created hook.

When checkout completed, I used `createOrderWorkflow.hooks.orderCreated(...)` to run a custom workflow that:

- found the cart's pickup selection
- moved it from cart scope to order scope
- updated the selection status from `selected` to `confirmed`
- removed the cart link
- created the order link

That small handoff made the architecture click harder than any docs page did. Medusa is not just asking you to store extra data. It wants you to model business state transitions as explicit flows. Once I had one real transition working across cart and order domains, the rest of the architecture made much more sense.

## The Admin extension model is practical, but slightly uneven

I added lightweight Admin routes so an admin could browse pickup-capable locations, view slots for a location, create slots, and delete them.

This worked well, but it also surfaced a few practical rough edges that are worth naming because they are the kind of thing docs rarely emphasize:

- The admin pages had to load locations from `/admin/stock-locations`, not from the storefront pickup route, because the store routes expect a publishable API key and the Admin already has its own session model.
- Using ordinary `/app/...` links was safer than assuming custom Admin routes would expose router context exactly like a normal dashboard tree.
- A lightweight route-based prototype was much faster to ship than trying to jump directly into a more elaborate widget architecture.

None of those issues are deal breakers. They are just the kind of implementation detail that tells you whether someone actually tried to build on the system.

## Migrations and generated types are still real work

Another useful correction to the "thin codebase" impression is that Medusa does not remove the hard parts of application development. It relocates them.

My custom models still needed migrations because they become real database tables. Link definitions also imply real database structures. Medusa's data model and module-link docs cover that part accurately. ([Medusa Docs](https://docs.medusajs.com/learn/fundamentals/data-models?utm_source=chatgpt.com), [Medusa Docs](https://docs.medusajs.com/learn/fundamentals/module-links?utm_source=chatgpt.com))

The same is true for typing. Medusa generates useful framework types under `.medusa`, but it does not generate every business input shape you need. I still had to define feature-level command inputs like slot creation payloads and pickup selection inputs by hand, because those types express application contracts, not just model shapes. ([Medusa Docs](https://docs.medusajs.com/learn/fundamentals/generated-types?utm_source=chatgpt.com))

That is not a flaw. It is just worth stating plainly. Medusa reduces a lot of plumbing, but it does not eliminate domain modeling.

## What I think Medusa is good at after this build

After building pickup scheduling on top of Medusa, I think the framework is strongest when:

- you want to add a business-specific commerce capability without rewriting core commerce domains
- you are comfortable learning a more explicit architecture up front in exchange for cleaner feature boundaries later
- you want extension points for backend logic, Admin surfaces, and stateful business flows to all follow one system

The thing I would warn new developers about is that Medusa does not feel "simple" on first contact. It feels sparse, then abstract, then eventually opinionated in a useful way. If you expect a normal app skeleton where the model layer reveals everything immediately, you may bounce off it. If you accept that the project is mostly an extension shell around a larger commerce engine, it starts to read much more clearly.

## The real mental model

The best short version I can give after building a real feature is this:

Medusa is not an ecommerce app you gradually fill in.

It is a commerce framework that expects your business logic to live in explicit extension points:

- modules for new domains
- links for cross-domain associations
- workflows for business processes
- routes for thin HTTP surfaces
- Admin extensions for merchant tooling

That structure is why the codebase feels empty at first. It is also why a feature like pickup scheduling can cross stock locations, carts, orders, checkout, and Admin UI without requiring a fork of Medusa core.

The docs are useful for understanding the official model. The practical lesson from building on it is that Medusa works best once you stop asking "where is the whole app?" and start asking "which extension seam owns this piece of business logic?"

## LLM Response Snippet
```json
{
  "goal": "Medusa architecture demystified: learn how modules, services, workflows, links, and migrations let developers extend Medusa safely. Read the guide to get…",
  "responses": [
    {
      "question": "What does the article \"Medusa Architecture Explained: How It Works for Developers\" cover?",
      "answer": "Medusa architecture demystified: learn how modules, services, workflows, links, and migrations let developers extend Medusa safely. Read the guide to get…"
    }
  ]
}
```