Things You Don’t Know About Medusa.js - Part 1

By Viktor Holik

Featured image

Medusa.js is more than just another option like Shopify or WooCommerce. It’s a powerful collection of eCommerce tools without the traditional frontend, making your development work faster, more reliable, and innovative.

In this article, I’m excited to share with you some cool tips and tricks about Medusa.js that many developers haven’t discovered yet. These ideas can really help make your Medusa.js shops run better.

Let’s get started!

Transactions

Have you ever encountered a situation where you’re performing multiple operations, and one of them fails, causing inconsistencies? Consider the following scenario in a code snippet related to handling webhooks for orders:

// api/webhook/[order_id].ts
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
  // Code to initialize services...
  const { order_id: orderId } = req.params;
  // Retrieve an order...
  
  // Attempt to update each product status in the order
  for (const lineItem of order.items) {
    // But what if an update fails?
    await productService.update(lineItem.product_id, {status: ProductStatus.PROPOSED})
  }

  return res.sendStatus(200);
};

This approach might result in data inconsistency, where some products are marked as ‘proposed’ while others are not, making it difficult to track and rectify errors if only part of the data is updated.

To tackle this issue, consider using transactions to wrap the operations:

// api/webhook/[order_id].ts
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
  // Code to initialize services and a transaction manager...
  const { order_id: orderId } = req.params;

  try {
    // Start a transaction
    await manager.transaction(async transactionManager => {
      // Retrieve the order within the transaction...
      
      for (const lineItem of order.items) {
        // Use the transaction manager to ensure all updates are part of the transaction
        await productService.withTransaction(transactionManager).update(lineItem.product_id, {status: ProductStatus.PROPOSED})
      }
    });
  } catch(e) {
    // Handle errors, perhaps logging and sending a failure response
    return res.sendStatus(500)
  }

  return res.sendStatus(200);
};

Using transactions ensures that all database operations either complete successfully or fail together. This means if an update fails, all changes made by previous operations in the transaction are undone, preventing partial updates and maintaining data integrity.

You can declare transactions in custom services by extending from TransactionBaseService.

Cache

In Medusa, caching is a powerful tool used to store the outcomes of various computations like price selection or tax calculations. However, a lesser-known use case is leveraging the cache service to store your own data, significantly enhancing performance.

Consider a scenario where your store has a vast inventory of products. Fetching these products from the database with every API call can be inefficient and slow down your application. This is where the cache service comes into play, offering a way to store data temporarily and access it much faster.

Here’s a basic example of fetching products without using the cache:

export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
  const productService: ProductService = req.scope.resolve('productService');
  // Products are fetched from the database every time the GET route is accessed
  const [products, count] = await productService.listAndCount({take: 100});

  res.status(200).json({ products, count });
};

To improve efficiency, you can use the cache service like this:

const CACHE_KEY = 'products';

export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
  const cacheService: ICacheService = req.scope.resolve('cacheService');
  const productService: ProductService = req.scope.resolve('productService');

  // Attempt to retrieve cached product data
  const cached = (await cacheService.get(CACHE_KEY)) as
    | Record<string, unknown>
    | undefined;

  // If cached data exists, return it instead of querying the database
  if (cached) {
    return res.json(cached.data);
  }

  // Fetch products from the database if no cache is found
  const [products, count] = await productService.listAndCount({});

  // Cache the newly fetched product data for 1 hour
  await cacheService.set(
    CACHE_KEY,
    { data: { products, count } },
    60 * 60, // Cache duration of 1 hour
  );

  res.status(200).json({ products, count });
};

Medusa’s default caching strategy uses @medusajs/cache-inmemory, which is suitable for development or small-scale applications. However, for production environments, especially those with high traffic or large datasets, switching to @medusajs/cache-redis is recommended.

Modules

Modules are packages with self-contained commerce logic, promoting separation of concerns, maintainability, and reusability. Modules increase Medusa’s extensibility, allowing for customization of core commerce logic and composition with other tools. This flexibility allows for greater choice in the tech stack used in conjunction with Medusa.

You can run medusa modules in Next.js function or compatible Node.js environment. Right now modules are still in beta but anyway you can try to use it already. Here is an example:

1.Install desired module. The list of available product you can see at Medusa.js documentation

npm install @medusajs/product

2.Add Database URL to your environment variables

POSTGRES_URL=<DATABASE_URL>

3.Apply database migrations

If you are using an existing Medusa database, you can skip this step. This step is only applicable when the module is used in isolation from a full Medusa setup

Before you can run migrations, add in your package.json the following scripts:

"scripts": {
    //...other scripts
    "product:migrations:run": "medusa-product-migrations-up",
    "product:seed": "medusa-product-seed ./seed-data.js"
},

4.Change Next.js config

const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ["@medusajs/product"],
  },
}

module.exports = nextConfig

Medusa modules can be executed within Next.js functions or any Node.js environment that’s compatible. Although these modules are currently in the beta phase, they are available for use. Here’s an example:

// /app/api/products/route.ts
import { NextResponse } from "next/server";
import { initialize as setupProductModule } from "@medusajs/product";

export async function GET(req: Request) {
  const productModule = await setupProductModule();
  
  // Extract country code from the request headers
  const country: string = req.headers.get("x-country") || "US";

  const continent = continentMap[country];

  // Fetch customized product listings
  const result = await productModule.list({
          tags: { value: [continent] },
  });

  return NextResponse.json({ products: result });
}

Here is a full example of using product module in Next.js by Medusa.js team.

Testing

Medusa integrates unit testing seamlessly with Jest, enhancing this capability with the medusa-test-utils package, which simplifies the testing process significantly.

Here are some of the most valuable tools it offers:

MockRepository: This is a mock repository you can effortlessly customize.

const userRepository = MockRepository({
 find: () => Promise.resolve([{ id: IdMap.getId('ironman'), role: 'admin' }]),
});

IdMap: A utility for managing a map of unique IDs linked to specific keys, facilitating consistent ID referencing across tests.

import { IdMap } from "medusa-test-utils";

export const products = {
  product1: {
    id: IdMap.getId("product1"),
    title: "Product 1",
  },
  product2: {
    id: IdMap.getId("product2"),
    title: "Product 2",
  }
};

export const ProductServiceMock = {
  retrieveVariants: jest.fn().mockImplementation((productId) => {
    if (productId === IdMap.getId("product1")) {
      return Promise.resolve([
        { id: IdMap.getId("1"), product_id: IdMap.getId("product1") },
        { id: IdMap.getId("2"), product_id: IdMap.getId("product2") },
      ])
    }

    return [];
  }),
};

MockManager

const userService = new UserService({
  manager: MockManager,
  userRepository,
});

it("successfully retrieves a user", async () => {
  const result = await userService.retrieve(IdMap.getId("ironman"));

  expect(result.id).toEqual(IdMap.getId("ironman"));
});

Integration testing

While Medusa doesn’t offer an out-of-the-box solution for integration testing, Riqwan Thamir from Medusa core team has developed a project for integrating Medusa services into intrgratioon test suites effectively. Here’s a basic approach for setting up integration tests in Medusa.

I hope you find these insights into Medusa’s testing capabilities useful. If you’re interested in more tips like these, showing support through engagement helps! Thanks for reading, and stay tuned second part and more useful content.

Other blog posts

Medusa vs Magento: Total cost of ownership

Magento, compared to Medusa, may lead to higher long-term costs due to its licensing model and the risk associated with the gradual decline in the popularity of the PHP language...

Medusa vs Magento: Performance comparison

This comparison is about seeing if Magento, with its new headless approach, can match the performance of platforms built to be headless from day one...

Tell us about your project

Got a project in mind? Let's make it happen!

placeholder

Grzegorz Tomaka

Co-CEO & Co-founder

LinkedIn icon
placeholder

Jakub Zbaski

Co-CEO & Co-founder

LinkedIn icon