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();
With Cosmoose:
const products = await Product.find({
category: 'electronics',
price: { $gte: 10, $lte: 100 },
});
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
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
Create a minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}
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);
}
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',
},
},
);
A lot is packed into this schema. Let's break it down:
-
trim: trueonname— automatically trims whitespace." Wireless Mouse "becomes"Wireless Mouse". -
uppercase: trueonsku— transforms to uppercase."wm-001"becomes"WM-001". -
optional: trueondescriptionandspecs.color— these fields aren't required. -
default: 'draft'onstatus— new products default to"draft"if no status is provided. -
Nested
specsobject — uses a separateSchemafor type-safe nested validation. -
tagsarray —Type.ARRAYwithitemsdefining the element type. -
timestamps: true— automatically managescreatedAtandupdatedAt. -
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;
}
});
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);
});
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);
});
Note on partition keys: Since our container uses
/categoryas 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);
});
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);
});
$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);
});
$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();
});
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);
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"
}
}
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);
});
Run the seed script:
pnpm seed
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.
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
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" }
}'
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
Filter by category
curl "http://localhost:3000/products?category=electronics"
Filter by price range, sorted
curl "http://localhost:3000/products?minPrice=20&maxPrice=100&sort=price&order=asc"
Combine everything
curl "http://localhost:3000/products?category=electronics&minPrice=30&sort=price&order=desc&limit=5"
Get a single product
curl http://localhost:3000/products/PRODUCT_ID
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 }'
Restock (patch with $incr)
curl -X POST "http://localhost:3000/products/PRODUCT_ID/restock?category=electronics" \
-H "Content-Type: application/json" \
-d '{ "quantity": 50 }'
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" }'
Delete a product
curl -X DELETE "http://localhost:3000/products/PRODUCT_ID?category=electronics"
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
- Full source code for this tutorial
- Cosmoose Documentation
- Cosmoose on GitHub
- Cosmoose on npm
- Azure Cosmos DB Free Tier
If you found this useful, give Cosmoose a star on GitHub!
Top comments (0)