notrab.dev

The Geordie Webmaster

Building a GraphQL Cart API with Durable Objects, MCP, and Drizzle

Published October 7th, 2025

I’ve been experimenting with Cloudflare Durable Objects for a while now, and recently shipped a rewrite of CartQL using them. The architecture turned out pretty interesting so I wanted to share how the pieces fit together.

CartQL is a shopping cart API. Each cart needs to be its own isolated unit of state that can handle mutations, track items, and eventually convert into an order. Durable Objects are a perfect fit here because each cart ID maps to exactly one DO instance with its own SQLite database.

The stack:

  • Cloudflare Workers as the entry point
  • Durable Objects for cart state
  • Drizzle ORM for the DO’s SQLite storage
  • GraphQL (via graphql-yoga) for the public API
  • MCP (Model Context Protocol) for AI agent access

Durable Objects

The core idea is that each cart lives in its own Durable Object. When a request comes in with a cart ID, we get a stub to that specific DO instance:

const durableObjectId = env.CART_DO.idFromName(cartId);
const stub = env.CART_DO.get(durableObjectId);

The DO itself exposes a simple HTTP interface internally. It handles routes like /add-item, /update-item, /checkout, etc. This keeps the DO’s responsibilities clear and makes it easy for different consumers to interact with it the same way.

async fetch(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const { pathname } = url;

  switch (pathname) {
    case '/get':
      return this.getCart(url.searchParams.get('id')!);
    case '/add-item':
      return this.addItem(await request.json());
    case '/checkout':
      return this.checkout(await request.json());
    // ...
  }
}

Drizzle for DO Storage

Each Durable Object has its own SQLite database. Drizzle makes working with it really nice. You define your schema once:

export const carts = sqliteTable("carts", {
  id: text("id").primaryKey(),
  currencyCode: text("currency_code").notNull().default("USD"),
  totalItems: integer("total_items").notNull().default(0),
  subTotal: integer("sub_total").notNull().default(0),
  grandTotal: integer("grand_total").notNull().default(0),
  abandoned: integer("abandoned", { mode: "boolean" }).default(false),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch())`),
  // ...
});

export const cartItems = sqliteTable("cart_items", {
  id: text("id").primaryKey(),
  cartId: text("cart_id").notNull(),
  name: text("name"),
  price: integer("price").notNull(),
  quantity: integer("quantity").notNull().default(1),
  lineTotal: integer("line_total").notNull(),
  // ...
});

Then in the DO constructor, you initialize Drizzle with the DO’s storage:

constructor(ctx: DurableObjectState, env: Env) {
  super(ctx, env);
  this.storage = ctx.storage;
  this.db = drizzle(this.storage, { logger: false });

  ctx.blockConcurrencyWhile(async () => {
    await this._migrate();
  });
}

The blockConcurrencyWhile bit is important. It makes sure the schema is set up before any requests can come in. Migrations run on first access to each DO instance.

Now you get type-safe queries:

const cart = await this.db
  .select()
  .from(carts)
  .where(eq(carts.id, cartId))
  .get();

const items = await this.db
  .select()
  .from(cartItems)
  .where(eq(cartItems.cartId, cartId))
  .all();

And inserts/updates are just as clean:

await this.db.insert(cartItems).values({
  id,
  cartId,
  name,
  price,
  quantity,
  unitTotal: price,
  lineTotal: price * quantity,
});

await this.db
  .update(carts)
  .set({
    totalItems,
    grandTotal,
    updatedAt: new Date(),
  })
  .where(eq(carts.id, cartId));

GraphQL and MCP Sharing the Same DO

The interesting part… both the GraphQL API and the MCP server talk to the same Durable Object instances. They’re just different interfaces to the same underlying data.

GraphQL

The GraphQL resolvers get a cart by creating a stub and making an internal HTTP request:

const resolvers = {
  Query: {
    cart: async (_, { id, currency }, { env }) => {
      const durableObjectId = env.CART_DO.idFromName(id);
      const stub = env.CART_DO.get(durableObjectId);

      const url = new URL(`http://internal/get?id=${id}`);
      const response = await stub.fetch(new Request(url.toString()));
      return await response.json();
    },
  },

  Mutation: {
    addItem: async (_, { input }, { env }) => {
      const durableObjectId = env.CART_DO.idFromName(input.cartId);
      const stub = env.CART_DO.get(durableObjectId);

      const response = await stub.fetch(
        new Request("http://internal/add-item", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(input),
        }),
      );

      return await response.json();
    },
  },
};

MCP

The MCP server does exactly the same thing. It registers tools that AI agents can call, and those tools make the same internal requests to the DO:

this.server.tool(
  "get_cart",
  "Retrieve a shopping cart by its ID",
  {
    cartId: z.string().describe("The unique ID of the cart"),
  },
  async ({ cartId }) => {
    const durableObjectId = env.CART_DO.idFromName(cartId);
    const stub = env.CART_DO.get(durableObjectId);

    const url = new URL(`http://internal/get?id=${cartId}`);
    const response = await stub.fetch(new Request(url.toString()));
    const cart = await response.json();

    return {
      content: [{ type: "text", text: JSON.stringify(cart, null, 2) }],
    };
  },
);

this.server.tool(
  "add_item_to_cart",
  "Add a new item to a shopping cart",
  {
    cartId: z.string(),
    id: z.string(),
    name: z.string().optional(),
    price: z.number().describe("Price in cents"),
    quantity: z.number().optional().default(1),
    // ...
  },
  async ({ cartId, id, name, price, quantity }) => {
    const durableObjectId = env.CART_DO.idFromName(cartId);
    const stub = env.CART_DO.get(durableObjectId);

    const response = await stub.fetch(
      new Request("http://internal/add-item", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ cartId, id, name, price, quantity }),
      }),
    );

    const cart = await response.json();
    return {
      content: [{ type: "text", text: JSON.stringify(cart, null, 2) }],
    };
  },
);

The MCP server itself is also a Durable Object (extending McpAgent), but it doesn’t store cart data. It just acts as the MCP protocol handler and routes requests to the cart DOs.

Worker Routes

The main worker routes requests to either GraphQL or MCP:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const url = new URL(request.url);
    const path = url.pathname;

    // MCP endpoints
    if (path === "/mcp" || path === "/mcp/message") {
      return CartQLMCP.serve("/mcp", { binding: "MCP_OBJECT" }).fetch(
        request,
        env,
        ctx,
      );
    }

    // GraphQL endpoints
    if (path === "/" || path === "/graphql") {
      const yoga = createYoga({
        schema: createSchema({ typeDefs, resolvers }),
        context: () => ({ env }),
      });

      return yoga.handleRequest(request, { env });
    }

    return new Response("Not Found", { status: 404 });
  },
};

This works well…

A few things I like about this setup:

Single source of truth. Whether a request comes from GraphQL, MCP, or anything else, it hits the same DO and the same database. No sync issues.

Isolation. Each cart is completely isolated. One cart’s activity doesn’t affect another. The DO handles concurrency internally.

Type safety end to end. Drizzle gives you inferred types from the schema. GraphQL has its own types. MCP uses Zod for validation. It all lines up.

Easy to extend. Want to add a REST API? Just make the same stub requests. Want to add webhooks? The DO can publish events when things change (we actually do this with Cloudflare Queues).

Automatic cleanup. DOs have an alarm system. We use it to mark carts as abandoned after 7 days of inactivity and clean them up:

async alarm(): Promise<void> {
  const cutoffTimestamp = Math.floor(
    (Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000
  );

  const abandonedCarts = await this.storage.sql
    .exec(`SELECT id FROM carts WHERE updated_at < ?`, [cutoffTimestamp])
    .toArray();

  for (const cart of abandonedCarts) {
    await this._deleteCartCompletely(cart.id);
  }
}

Wrangler Config

For reference, the DO bindings in wrangler.jsonc:

{
  "durable_objects": {
    "bindings": [
      {
        "class_name": "CartDurableObject",
        "name": "CART_DO"
      },
      {
        "class_name": "CartQLMCP",
        "name": "MCP_OBJECT"
      }
    ]
  }
}

The combination of Durable Objects + Drizzle + multiple API interfaces (GraphQL/MCP) has worked out really well. The DO gives you a single place where all state lives, Drizzle makes the SQL layer pleasant to work with, and having both GraphQL and MCP means the same cart data is accessible to both traditional apps and AI agents.

The key insight is that the DO’s HTTP interface becomes the universal contract. GraphQL resolvers and MCP tools are just different ways to call it. Add a REST endpoint tomorrow and it’s the same pattern.

Try it out at cartql.com, and let me know what you think!