DEV Community

Jugkapong Pooban
Jugkapong Pooban

Posted on

Build a Type-Safe Product Catalog API...

If you've ever worked with Azure Cosmos DB directly, you know the pain. Writing raw SQL queries by hand, managing parameterized inputs, zero validation before data hits the database. Compare these two approaches for finding electronics under $100:

Raw @azure/cosmos SDK:

const { resources } = await container.items
  .query({
    query: 'SELECT * FROM c WHERE c.category = @cat AND c.price >= @min AND c.price <= @max',
    parameters: [
      { name: '@cat', value: 'electronics' },
      { name: '@min', value: 10 },
      { name: '@max', value: 100 },
    ],
  })
  .fetchAll();
Enter fullscreen mode Exit fullscreen mode

With Cosmoose:

const products = await Product.find({
  category: 'electronics',
  price: { $gte: 10, $lte: 100 },
});
Enter fullscreen mode Exit fullscreen mode

Cosmoose is a type-safe ODM for Azure Cosmos DB, built with TypeScript. If you've used Mongoose with MongoDB, you'll feel right at home — schemas, models, a fluent query builder, and automatic container management.

In this tutorial, we'll build a Product Catalog API with Express and Cosmoose that covers:

  • Type-safe schema definition with transforms, nested objects, and defaults
  • Automatic container creation and sync
  • Full CRUD operations
  • Fluent query builder with filters, sorting, and pagination
  • Partial updates with patch operators ($set, $incr, $add)

Let's get started.


Prerequisites

  • Node.js 18+
  • Azure Cosmos DB account — use the free tier or the Cosmos DB Emulator for local development
  • Your Cosmos DB endpoint and primary key ready

Project Setup

mkdir cosmoose-product-catalog && cd cosmoose-product-catalog
pnpm init
pnpm add @cosmoose/core express dotenv
pnpm add -D typescript tsx @types/express @types/node
Enter fullscreen mode Exit fullscreen mode

Create a .env file with your Cosmos DB credentials:

COSMOS_ENDPOINT=https://your-account.documents.azure.com:443/
COSMOS_KEY=your-primary-key-here
COSMOS_DATABASE=product-catalog
Enter fullscreen mode Exit fullscreen mode

Create a minimal tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

We'll use tsx to run TypeScript directly — no build step needed.


Connect to Cosmos DB

Create src/db.ts — this is where we initialize Cosmoose and register our model:

// src/db.ts
import { Cosmoose } from '@cosmoose/core';
import { productSchema, type Product } from './schemas/product.js';

export const cosmoose = new Cosmoose({
  endpoint: process.env.COSMOS_ENDPOINT!,
  key: process.env.COSMOS_KEY!,
  databaseName: process.env.COSMOS_DATABASE || 'product-catalog',
});

export let Product: ReturnType<typeof cosmoose.model<Product>>;

export async function connectDB() {
  await cosmoose.connect();
  Product = cosmoose.model<Product>('products', productSchema);
}
Enter fullscreen mode Exit fullscreen mode

The database is created automatically if it doesn't exist. The model() call registers our Product model and binds it to the products container in Cosmos DB.


Define the Product Schema

This is where Cosmoose shines. Create src/schemas/product.ts:

// src/schemas/product.ts
import { Schema, Type } from '@cosmoose/core';

interface Specs {
  weight: number;
  dimensions: string;
  color?: string;
}

export interface Product {
  name: string;
  sku: string;
  description?: string;
  price: number;
  stock: number;
  category: string;
  tags: string[];
  status: string;
  specs: Specs;
}

const specsSchema = new Schema<Specs>({
  weight: { type: Type.NUMBER },
  dimensions: { type: Type.STRING },
  color: { type: Type.STRING, optional: true },
});

export const productSchema = new Schema<Product>(
  {
    name: { type: Type.STRING, trim: true },
    sku: { type: Type.STRING, uppercase: true },
    description: { type: Type.STRING, optional: true },
    price: { type: Type.NUMBER },
    stock: { type: Type.NUMBER },
    category: { type: Type.STRING },
    tags: { type: Type.ARRAY, items: { type: Type.STRING } },
    status: { type: Type.STRING, default: 'draft' },
    specs: { type: Type.OBJECT, schema: specsSchema },
  },
  {
    timestamps: true,
    container: {
      partitionKey: '/category',
    },
  },
);
Enter fullscreen mode Exit fullscreen mode

A lot is packed into this schema. Let's break it down:

  • trim: true on name — automatically trims whitespace. " Wireless Mouse " becomes "Wireless Mouse".
  • uppercase: true on sku — transforms to uppercase. "wm-001" becomes "WM-001".
  • optional: true on description and specs.color — these fields aren't required.
  • default: 'draft' on status — new products default to "draft" if no status is provided.
  • Nested specs object — uses a separate Schema for type-safe nested validation.
  • tags arrayType.ARRAY with items defining the element type.
  • timestamps: true — automatically manages createdAt and updatedAt.
  • partitionKey: '/category' — configures the Cosmos DB container to partition by category.

All validation happens before data reaches Cosmos DB, powered by Zod internally. If validation fails, Cosmoose throws a SchemaValidationFailedException with details.


Build the Express Routes

Create src/routes/products.ts with all our endpoints:

Create a Product

// src/routes/products.ts
import { Router } from 'express';
import { SchemaValidationFailedException } from '@cosmoose/core';
import { Product } from '../db.js';

export const productsRouter = Router();

productsRouter.post('/', async (req, res) => {
  try {
    const product = await Product.create(req.body);
    res.status(201).json(product);
  } catch (err) {
    if (err instanceof SchemaValidationFailedException) {
      res.status(400).json({ error: 'Validation failed', details: err.errors });
      return;
    }
    throw err;
  }
});
Enter fullscreen mode Exit fullscreen mode

Product.create() validates the input against our schema, auto-generates a UUID v7 id, sets createdAt/updatedAt timestamps, and applies transforms (trim, uppercase) — all before inserting into Cosmos DB.

List Products with Query Builder

This is the most impressive endpoint — it composes a Cosmoose query dynamically from query parameters:

productsRouter.get('/', async (req, res) => {
  const { category, status, minPrice, maxPrice, sort, order, limit, offset } = req.query;

  const filter: Record<string, unknown> = {};

  if (category) filter.category = category;
  if (status) filter.status = status;
  if (minPrice || maxPrice) {
    filter.price = {};
    if (minPrice) (filter.price as Record<string, number>).$gte = Number(minPrice);
    if (maxPrice) (filter.price as Record<string, number>).$lte = Number(maxPrice);
  }

  let query = Product.find(filter);

  if (sort) {
    const sortOrder = order === 'desc' ? -1 : 1;
    query = query.sort({ [sort as string]: sortOrder });
  }

  if (limit) query = query.limit(Number(limit));
  if (offset) query = query.offset(Number(offset));

  const products = await query;
  res.json(products);
});
Enter fullscreen mode Exit fullscreen mode

The fluent API lets you chain .sort(), .limit(), and .offset() before awaiting the result. Cosmoose generates a parameterized SQL query under the hood — no SQL injection risk, no string concatenation.

Get by ID

productsRouter.get('/:id', async (req, res) => {
  const product = await Product.findOne({ id: req.params.id });
  if (!product) {
    res.status(404).json({ error: 'Product not found' });
    return;
  }
  res.json(product);
});
Enter fullscreen mode Exit fullscreen mode

Note on partition keys: Since our container uses /category as the partition key, findOne() performs a cross-partition query to locate the product by ID without requiring the caller to know which category it belongs to. For mutating operations like patch and delete, we pass the partition key explicitly.

Patch with $set

productsRouter.patch('/:id', async (req, res) => {
  const { category } = req.query;
  const updated = await Product.patchById(
    req.params.id,
    { $set: req.body },
    { partitionKeyValue: category as string },
  );
  if (!updated) {
    res.status(404).json({ error: 'Product not found' });
    return;
  }
  res.json(updated);
});
Enter fullscreen mode Exit fullscreen mode

The $set operator updates only the specified fields. Everything else stays untouched — no need to send the full document.

Restock with $incr

productsRouter.post('/:id/restock', async (req, res) => {
  const { category } = req.query;
  const { quantity } = req.body;
  const updated = await Product.patchById(
    req.params.id,
    { $incr: { stock: quantity } },
    { partitionKeyValue: category as string },
  );
  if (!updated) {
    res.status(404).json({ error: 'Product not found' });
    return;
  }
  res.json(updated);
});
Enter fullscreen mode Exit fullscreen mode

$incr atomically increments a numeric field. No need to read the document first, calculate the new value, and write it back. Just tell Cosmos DB to add 50 to stock and it does. This avoids race conditions in concurrent scenarios.

Add a Tag with $add

productsRouter.post('/:id/tags', async (req, res) => {
  const { category } = req.query;
  const { tag } = req.body;
  const updated = await Product.patchById(
    req.params.id,
    { $add: { tags: tag } },
    { partitionKeyValue: category as string },
  );
  if (!updated) {
    res.status(404).json({ error: 'Product not found' });
    return;
  }
  res.json(updated);
});
Enter fullscreen mode Exit fullscreen mode

$add appends a value to an array field — again, without needing to fetch the document first.

Delete

productsRouter.delete('/:id', async (req, res) => {
  const { category } = req.query;
  const deleted = await Product.deleteById(req.params.id, {
    partitionKeyValue: category as string,
  });
  if (!deleted) {
    res.status(404).json({ error: 'Product not found' });
    return;
  }
  res.status(204).send();
});
Enter fullscreen mode Exit fullscreen mode

Wire It Up

Create the app entry point at src/index.ts:

// src/index.ts
import 'dotenv/config';
import express from 'express';
import { connectDB, cosmoose } from './db.js';
import { productsRouter } from './routes/products.js';

const app = express();
app.use(express.json());

app.use('/products', productsRouter);

const PORT = process.env.PORT || 3000;

async function main() {
  await connectDB();

  const report = await cosmoose.syncContainers();
  for (const result of report) {
    console.log(`Container "${result.name}": ${result.status}`);
  }

  app.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
  });
}

main().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

syncContainers() automatically creates the products container with the correct partition key if it doesn't exist. On subsequent runs, it reports "unchanged" — or flags drift if your schema's container config has changed.

Add these scripts to package.json:

{
  "scripts": {
    "dev": "tsx src/index.ts",
    "seed": "tsx src/seed.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Seed Sample Data

Create src/seed.ts to populate the database with test products:

// src/seed.ts
import 'dotenv/config';
import { connectDB, Product, cosmoose } from './db.js';

const products = [
  {
    name: 'Wireless Mouse',
    sku: 'wm-001',
    description: 'Ergonomic wireless mouse with USB-C receiver',
    price: 29.99,
    stock: 150,
    category: 'electronics',
    tags: ['peripherals', 'wireless'],
    status: 'active',
    specs: { weight: 85, dimensions: '10x6x4 cm', color: 'black' },
  },
  {
    name: 'Mechanical Keyboard',
    sku: 'mk-002',
    description: 'RGB mechanical keyboard with Cherry MX switches',
    price: 89.99,
    stock: 45,
    category: 'electronics',
    tags: ['peripherals', 'mechanical', 'rgb'],
    status: 'active',
    specs: { weight: 950, dimensions: '44x14x4 cm', color: 'white' },
  },
  {
    name: 'USB-C Hub',
    sku: 'uch-003',
    price: 49.99,
    stock: 0,
    category: 'electronics',
    tags: ['accessories', 'usb-c'],
    status: 'active',
    specs: { weight: 120, dimensions: '12x5x2 cm' },
  },
  {
    name: 'Cotton T-Shirt',
    sku: 'cts-004',
    description: '100% organic cotton, unisex fit',
    price: 19.99,
    stock: 200,
    category: 'clothing',
    tags: ['organic', 'unisex', 'basics'],
    status: 'active',
    specs: { weight: 180, dimensions: 'M' },
  },
  {
    name: 'Running Shoes',
    sku: 'rs-005',
    description: 'Lightweight running shoes with foam sole',
    price: 129.99,
    stock: 30,
    category: 'clothing',
    tags: ['footwear', 'sports'],
    status: 'active',
    specs: { weight: 280, dimensions: 'US 10', color: 'navy' },
  },
  {
    name: 'Desk Lamp',
    sku: 'dl-006',
    description: 'Adjustable LED desk lamp with dimmer',
    price: 39.99,
    stock: 75,
    category: 'home',
    tags: ['lighting', 'led'],
    specs: { weight: 650, dimensions: '40x15x15 cm', color: 'silver' },
  },
];

async function seed() {
  await connectDB();
  await cosmoose.syncContainers();

  console.log('Seeding products...\n');

  for (const data of products) {
    const product = await Product.create(data);
    console.log(`  Created: ${product.name} (${product.id})`);
  }

  console.log(`\nDone! ${products.length} products seeded.`);
  process.exit(0);
}

seed().catch((err) => {
  console.error('Seed failed:', err);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

Run the seed script:

pnpm seed
Enter fullscreen mode Exit fullscreen mode

You should see output like:

Seeding products...

  Created: Wireless Mouse (019722a3-...)
  Created: Mechanical Keyboard (019722a3-...)
  Created: USB-C Hub (019722a3-...)
  Created: Cotton T-Shirt (019722a3-...)
  Created: Running Shoes (019722a3-...)
  Created: Desk Lamp (019722a3-...)

Done! 6 products seeded.
Enter fullscreen mode Exit fullscreen mode

Note how the Desk Lamp has no status in the seed data — it defaults to "draft" thanks to our schema.


Try It Out

Start the server:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Create a product

curl -X POST http://localhost:3000/products \
  -H "Content-Type: application/json" \
  -d '{
    "name": "  Bluetooth Speaker  ",
    "sku": "bs-007",
    "price": 59.99,
    "stock": 25,
    "category": "electronics",
    "tags": ["audio", "bluetooth"],
    "specs": { "weight": 340, "dimensions": "8x8x10 cm" }
  }'
Enter fullscreen mode Exit fullscreen mode

Notice the name has extra whitespace — Cosmoose trims it automatically. The SKU "bs-007" becomes "BS-007". The status defaults to "draft".

List all products

curl http://localhost:3000/products
Enter fullscreen mode Exit fullscreen mode

Filter by category

curl "http://localhost:3000/products?category=electronics"
Enter fullscreen mode Exit fullscreen mode

Filter by price range, sorted

curl "http://localhost:3000/products?minPrice=20&maxPrice=100&sort=price&order=asc"
Enter fullscreen mode Exit fullscreen mode

Combine everything

curl "http://localhost:3000/products?category=electronics&minPrice=30&sort=price&order=desc&limit=5"
Enter fullscreen mode Exit fullscreen mode

Get a single product

curl http://localhost:3000/products/PRODUCT_ID
Enter fullscreen mode Exit fullscreen mode

Update the price (patch with $set)

curl -X PATCH "http://localhost:3000/products/PRODUCT_ID?category=electronics" \
  -H "Content-Type: application/json" \
  -d '{ "price": 24.99 }'
Enter fullscreen mode Exit fullscreen mode

Restock (patch with $incr)

curl -X POST "http://localhost:3000/products/PRODUCT_ID/restock?category=electronics" \
  -H "Content-Type: application/json" \
  -d '{ "quantity": 50 }'
Enter fullscreen mode Exit fullscreen mode

Add a tag (patch with $add)

curl -X POST "http://localhost:3000/products/PRODUCT_ID/tags?category=electronics" \
  -H "Content-Type: application/json" \
  -d '{ "tag": "on-sale" }'
Enter fullscreen mode Exit fullscreen mode

Delete a product

curl -X DELETE "http://localhost:3000/products/PRODUCT_ID?category=electronics"
Enter fullscreen mode Exit fullscreen mode

What We Built

In just a few files, we built a fully functional Product Catalog API that demonstrates:

Feature How It Was Used
Type-safe schemas Product with nested objects, arrays, transforms, defaults
Auto container sync syncContainers() creates the container with correct partition key
CRUD operations create(), findOne(), patchById(), deleteById()
Fluent query builder find() with .sort(), .limit(), .offset()
Filter operators $gte, $lte for price range queries
Patch operators $set for updates, $incr for stock, $add for tags
Timestamps Automatic createdAt / updatedAt
Validation Schema-level validation with Zod, before data hits the database

Cosmoose brings the developer experience you know from Mongoose to Azure Cosmos DB — type safety, schema validation, fluent queries, and patch operators — all without writing raw SQL.

Resources

If you found this useful, give Cosmoose a star on GitHub!

Top comments (0)