Escena Viva's second satellite product is the merchandise store: T-shirts for the Concierto de Otoño (evt-001), screen-printed posters for the Noche de Monólogos (evt-002) and live vinyl records from the Festival de Jazz de Primavera (evt-003). Lucía wants the black T-shirt in size M; Marc wants the vinyl and a poster. The store is a new domain, but the users, the roles and the events are the same ones. The genuinely new idea in this project is real money. Until now, when something failed you returned a 500 and retried; here a failure can mean a customer paid and received nothing, or received something without paying. That demands another level of rigor: explicit statuses, consistency with an external system you do not control, idempotency everywhere and an audit trail for every cent.
A necessary warning. Charging real money brings legal, tax, accounting and data protection obligations that vary by country and that require professional advice. This project is educational: it teaches the engineering, it does not replace an accountant or a lawyer.
Contents
- The business requirement
- Design decisions and rejected alternatives
- The data model: variants, cart and order
- The cart: guest, user and merging on login
- The amount is always computed on the server
- Stock reservation versus overselling
- The technical challenge: integrating a payment gateway
- Webhooks: signature, raw body and idempotency
- The order state machine
- Queues, refunds and auditing
- Testing what can break, and what stays out of scope
- The business requirement
- Every event has associated products; a product has variants (size, color) with their own stock.
- Anyone can browse the catalog and fill a cart, with or without an account; paying requires authentication.
- Payment is made by card through a gateway; Escena Viva never sees the card details.
- The order moves through verifiable statuses the customer can check; full or partial refunds can be issued.
- Organizers see the orders for the products of their venue; administrators see them all.
- Design decisions and rejected alternatives
| Decision | Rejected alternative | Why |
|---|---|---|
| PostgreSQL with Sequelize (M7) | MongoDB, like the chat | Money asks for ACID transactions across several tables, referential integrity and exact sums |
| Stock on the variant, not the product | One counter on the product | Selling "a T-shirt" means nothing: you sell the black size M. Per-product stock does not stop you selling ten nonexistent XXLs |
| Order lines with a frozen price | Reading the product's current price | See section 3: it is the most important decision in this lesson |
PaymentIntent created on the server |
Card details reaching our API | Receiving a card number drags us into the full scope of PCI DSS |
| Confirmation by webhook | Confirming when the browser returns to the success page | The browser may close right after paying. The truth about the payment lives at the gateway |
| Temporary stock reservation | Deducting when adding to the cart, or only on payment | The first locks stock for hours; the second allows overselling while the card is being typed |
| Explicit state machine | Booleans paid, shipped, cancelled |
Five booleans give 32 combinations and only 7 valid ones. Impossible states must be unrepresentable |
One new dependency: npm install stripe. Nothing else: sequelize, pg, bullmq, ioredis and zod are already there.
- The data model: variants, cart and order
| Table | Key fields | Design note |
|---|---|---|
products |
id, eventId, name, venueId, active |
No price and no stock: both live on the variant |
variants |
id, productId, sku, size, color, priceCents, stock, reservedStock |
Unique, readable SKU: EV-TEE-003-M-BLK |
carts |
id, userId (null for a guest), guestToken, expiresAt |
|
cart_lines |
cartId, variantId, quantity |
Stores no price: the cart shows the current price |
orders |
id, userId, status, subtotalCents, taxCents, shippingCents, totalCents, currency |
|
order_lines |
orderId, variantId, sku, productName, size, color, unitPriceCents, quantity |
Copies price and description |
payments |
orderId, provider, externalReference, amountCents, status, rawEvent |
Stores the gateway's original JSON |
Why the order freezes the price. This is the decision that separates a toy store from a real one. The evt-001 T-shirt costs 2200 cents today and Lucía buys it. Three weeks later it drops to 1500 to clear stock. If the order line only stored variantId and the amount were read from the catalog: Lucía's history would show 1500 and would not match what she was charged; the issued invoice would say one thing and the database another — an accounting problem, not a cosmetic one; a refund would return 1500 instead of 2200; and any report of past revenue would change every time someone edits a price. The order line is an immutable historical document: it copies price, name and attributes. Duplicating information here is not sloppy denormalization; it is that the order's data and the catalog's data are different things that happen to coincide for one instant.
// src/domain/order.js — everything marked is COPIED, not referenced
const createOrderLine = ({ variant, product, quantity }) => ({
variantId: variant.id, sku: variant.sku, quantity,
productName: product.name, // frozen
size: variant.size, color: variant.color, // frozen
unitPriceCents: variant.priceCents, // frozen: the essential one
amountCents: variant.priceCents * quantity,
});
module.exports = { createOrderLine };
- The cart: guest, user and merging on login
Forcing people to register before they can see their cart is the most effective way to lose sales. There are two kinds: the guest cart, identified by a random guestToken stored in a 30-day httpOnly cookie (the cookie holds only the identifier: never trust business data that travels on the client), and the user cart, tied to userId and persistent across devices. Guest carts expire after 30 days via a repeatable BullMQ job. The interesting case is logging in with a cart already started:
// src/services/carts.js
async function mergeCarts({ cartRepository, guestToken, userId }) {
const guestCart = await cartRepository.findByToken(guestToken);
if (!guestCart) return cartRepository.getOrCreateForUser(userId);
const userCart = await cartRepository.getOrCreateForUser(userId);
for (const line of guestCart.lines) {
const existing = userCart.lines.find((l) => l.variantId === line.variantId);
// Business rule: add quantities up, never replace silently.
// Replacing makes the user "lose" what they had just added.
if (existing) existing.quantity = Math.min(existing.quantity + line.quantity, LINE_LIMIT);
else userCart.lines.push(line);
}
await cartRepository.save(userCart);
await cartRepository.remove(guestCart.id);
return userCart;
}
module.exports = { mergeCarts };The three possible policies are adding up, keeping the guest cart or keeping the user cart; adding up is the least surprising, always with a per-line cap.
- The amount is always computed on the server
A rule with no exceptions: the client sends what it wants to buy, never how much it costs. If your API accepts a totalCents field from the client, you are running a free store for anyone with the dev tools open.
// src/services/amount-calculation.js
const VAT_RATE_PER_MILLE = 210; // 21% expressed per mille, so no decimals
const FREE_SHIPPING_THRESHOLD = 5000;
const STANDARD_SHIPPING = 495;
function calculateAmount({ lines, shippingCents }) {
const subtotalCents = lines.reduce(
(sum, l) => sum + l.unitPriceCents * l.quantity, 0);
// Round to the cent once, and over the complete subtotal.
// Rounding line by line accumulates drift of up to N/2 cents.
const taxCents = Math.round((subtotalCents * VAT_RATE_PER_MILLE) / 1000);
return { subtotalCents, taxCents, shippingCents,
totalCents: subtotalCents + taxCents + shippingCents };
}
// Pure function of the subtotal: testable with no database.
const calculateShipping = (subtotal) => subtotal === 0 ? 0
: (subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING);
module.exports = { calculateAmount, calculateShipping, VAT_RATE_PER_MILLE };Why everything is integers: 0.1 + 0.2 in floating point gives 0.30000000000000004, and across thousands of orders those residues produce discrepancies nobody can explain. In whole cents there is a single rounding point — the tax — explicit and isolated in a pure function that is easy to test. A visible consequence: if the interface splits VAT per line, the sum may differ from the total by one cent; the server's total is authoritative and the breakdown is informative.
- Stock reservation versus overselling
You already solved this in M7 with capacity: 3000 seats, 1811 sold, and a transaction with a lock that stops two simultaneous purchases from selling the same seat. The store is the same problem with one new twist: between the customer clicking "Pay" and the gateway confirming, anywhere from 10 seconds to several minutes go by. If we deduct on confirmation, others can exhaust the stock inside that window and we will end up charging for a vinyl that no longer exists.
The solution is the temporary reservation: two columns, stock and reservedStock, and one rule — what is sellable is stock - reservedStock.
// src/repositories/inventory-sql.js
async function reserveStock({ sequelize, lines, orderId, minutes = 20 }) {
return sequelize.transaction(async (t) => {
for (const line of lines) {
// SELECT ... FOR UPDATE serializes the buyers of the same variant.
const [variant] = await sequelize.query(
'SELECT stock, reserved_stock FROM variants WHERE id = :id FOR UPDATE',
{ replacements: { id: line.variantId }, type: SELECT, transaction: t });
const available = variant.stock - variant.reserved_stock;
// M6 domain error: the central handler translates it into a 409.
if (available < line.quantity) throw new StateConflict('INSUFFICIENT_STOCK',
{ sku: line.sku, requested: line.quantity, available });
await sequelize.query(
'UPDATE variants SET reserved_stock = reserved_stock + :n WHERE id = :id',
{ replacements: { n: line.quantity, id: line.variantId }, transaction: t });
}
await inventoryRepository.createReservation({ orderId, minutes }, t);
});
}| Moment | stock |
reservedStock |
Sellable |
|---|---|---|---|
| Initial | 50 | 0 | 50 |
| Lucía starts paying for 2 | 50 | 2 | 48 |
| The payment is confirmed | 48 | 0 | 48 |
| (alternative) the payment fails or expires | 50 | 0 | 50 |
Releasing expired reservations is a repeatable BullMQ job (M10) that runs every minute: without it, a customer who abandons checkout freezes stock forever.
- The technical challenge: integrating a payment gateway
sequenceDiagram
participant C as Client
participant A as Escena Viva API
participant S as Stripe
C->>A: POST /api/v1/orders (lines + address)
A->>A: compute amount + reserve stock
A->>S: create PaymentIntent (amount, metadata.orderId)
A-->>C: { orderId, clientSecret }
C->>S: confirm payment with the card (never passes through A)
S->>A: webhook payment_intent.succeeded (signed)
A->>A: confirm order, consume reservation, enqueue email
The essential point: the card never touches our server. The browser sends it straight to Stripe with the clientSecret; our API only declares "4840 cents must be charged for order ord-8812". PCI DSS in one sentence: it is the card industry's security standard, and its scope — and the cost of complying — grows brutally the moment card data passes through your systems; by delegating to the gateway you stay at the simplest self-assessment level. Everything else is integration detail.
// src/services/payments.js
async function createPaymentIntent({ order, user }) {
const intent = await stripe.paymentIntents.create({
amount: order.totalCents, // the server sets the amount (section 5)
currency: order.currency, // 'eur'
// The metadata is the bridge between Stripe's world and ours.
metadata: { orderId: order.id, userId: user.id },
receipt_email: user.email, // [email protected]
automatic_payment_methods: { enabled: true },
// Outbound idempotency: on a retry, Stripe does not create two intents.
}, { idempotencyKey: `intent-${order.id}` });
return { externalReference: intent.id, clientSecret: intent.client_secret };
}
module.exports = { createPaymentIntent, stripe };
- Webhooks: signature, raw body and idempotency
A webhook is an HTTP request the provider makes to you, and since anyone can make you an HTTP request, verifying the signature is not optional: without it anyone can send you payment_intent.succeeded and walk away with free merchandise.
The classic mistake. The signature is computed over the exact bytes of the body. If express.json() already parsed it, the original is gone; re-serializing with JSON.stringify produces different bytes (key order, spacing, Unicode escapes) and verification always fails. The symptom is a 400 signature verification failed that looks like a key problem and is not. The fix is to mount the route with express.raw before the global express.json():
// src/app.js — excerpt of the middleware order
function createApplication(dependencies = {}) {
const app = express();
app.use(helmet());
app.use(requestId);
// CRITICAL ORDER: the webhook needs a Buffer, not an object.
app.use('/api/v1/webhooks/payments',
express.raw({ type: 'application/json', limit: '1mb' }),
createPaymentsWebhookRoutes(dependencies));
app.use(express.json({ limit: '100kb' })); // everything else does use JSON
// ... normal routers and the central error handler (M6) ...
return app;
}// src/routes/payments-webhook.js
routes.post('/', async (req, res) => {
let event;
try {
// req.body is a Buffer thanks to express.raw.
event = stripe.webhooks.constructEvent(req.body,
req.get('stripe-signature'), configuration.stripe.webhookSecret);
} catch (error) {
return res.status(400).json({ error: { code: 'INVALID_SIGNATURE',
message: 'Signature could not be verified', status: 400, details: null } });
}
// Idempotency: Stripe retries for days if you do not answer 2xx.
// event.id acts as the Idempotency-Key (M10), persisted in the database.
const isNew = await paymentEventRepository.recordIfNew(event.id, event.type);
if (!isNew) return res.status(200).json({ received: true, duplicate: true });
try {
const object = event.data.object;
if (event.type === 'payment_intent.succeeded') {
await orderService.confirmPayment({ orderId: object.metadata.orderId,
externalReference: object.id, amountCents: object.amount_received });
} else if (event.type === 'payment_intent.payment_failed') {
await orderService.markPaymentFailed({ orderId: object.metadata.orderId });
}
return res.status(200).json({ received: true }); // fast 200, queue separately
} catch (error) {
// A 500 makes Stripe retry: correct for a transient failure.
await paymentEventRepository.markFailed(event.id, error.message);
return res.status(500).json({ error: { code: 'INTERNAL_ERROR',
message: 'Retry', status: 500, details: null } });
}
});Four points people learn the hard way. recordIfNew must be atomic: an INSERT with event.id as the primary key that catches the uniqueness violation, because checking and then inserting in two steps has a race if two copies land on two processes. Answer fast and with 2xx: the provider's timeouts are short and everything slow goes to BullMQ. The webhook can arrive before the response to the client, which is common; that is why the order already exists in the database before the intent is created — when the webhook lands there is something to update — and why GET /orders/:id is the front-end's source of truth. And event ordering is not guaranteed: a payment_failed from an earlier attempt can arrive after succeeded, so the state machine must reject the invalid instead of applying it.
- The order state machine
stateDiagram-v2 [*] --> cart cart --> pending_payment: start payment (reserves stock) pending_payment --> paid: webhook succeeded pending_payment --> cancelled: failure, expiry or cancellation paid --> preparing: warehouse accepts preparing --> shipped: carrier picks up shipped --> delivered: delivery confirmation paid --> refunded: full refund preparing --> refunded: full refund delivered --> refunded: return accepted
Valid transitions are declared as data and checked in the domain, not scattered across controllers:
// src/domain/order-statuses.js
const TRANSITIONS = {
cart: ['pending_payment'],
pending_payment: ['paid', 'cancelled'],
paid: ['preparing', 'refunded', 'cancelled'],
preparing: ['shipped', 'refunded'],
shipped: ['delivered', 'refunded'],
delivered: ['refunded'],
cancelled: [], refunded: [], // terminal statuses: no way out
};
function transition(order, newStatus) {
const allowed = TRANSITIONS[order.status] ?? [];
if (!allowed.includes(newStatus)) throw new StateConflict('INVALID_TRANSITION',
{ currentStatus: order.status, requestedStatus: newStatus, allowed });
return { ...order, status: newStatus, updatedAt: new Date().toISOString() };
}
module.exports = { transition, TRANSITIONS };This table solves the problem from section 8: a late payment_failed on an already paid order throws StateConflict, gets logged and corrupts nothing.
- Queues, refunds and auditing
Nothing slow happens inside the webhook request:
// src/services/orders.js — excerpt
async function confirmPayment({ orderId, externalReference, amountCents }) {
const order = await orderRepository.get(orderId);
if (!order) throw new ResourceNotFound('ORDER_NOT_FOUND', { orderId });
// Defense: the amount charged must match the amount computed.
if (amountCents !== order.totalCents) throw new StateConflict(
'AMOUNT_MISMATCH', { orderId, amountCents });
const confirmed = transition(order, 'paid');
// Consumes the reservation in a transaction: stock -= n, reservedStock -= n.
await orderRepository.confirmInTransaction({ order: confirmed, externalReference });
// A fixed jobId = idempotency for free: BullMQ rejects an existing id,
// so even if the webhook were processed twice the email goes out once.
await emailQueue.add('order-confirmation', { orderId }, { attempts: 5,
backoff: { type: 'exponential', delay: 2000 }, jobId: `confirmation-${orderId}` });
await invoiceQueue.add('generate-invoice', { orderId }, { jobId: `invoice-${orderId}` });
return confirmed;
}The PDF invoice is generated in the consumer — a separate process from M10/M11 — and uploaded to object storage, because the PaaS filesystem is ephemeral (M11).
Refunds. A partial refund is not "subtracting from the total": it is refunding specific lines, using the price frozen on the line and checking that the amount already refunded is not exceeded. The call to stripe.refunds.create always carries an idempotencyKey: with money going out, a retry without a key can return the amount twice. Auditing. Every operation that touches money writes an immutable row into audit: who (actorId), what (action), on what (orderId), how much (amountCents), when (ISO) and with which requestId (M11, to cross-reference it with the pino logs). No deletes and no updates. When somebody asks "why is this 4840-cent order showing a 2200-cent refund", the answer must be in a table, not in a colleague's memory.
- Testing what can break, and what stays out of scope
Prioritize what would cause real losses:
// test/store/checkout.test.js
describe('store checkout', () => {
it('rejects a webhook with an invalid signature', async () => {
await request(createApplication()).post('/api/v1/webhooks/payments')
.set('stripe-signature', 't=1,v1=fake').set('Content-Type', 'application/json')
.send(Buffer.from(JSON.stringify({ type: 'payment_intent.succeeded' })))
.expect(400)
.expect((r) => expect(r.body.error.code).to.equal('INVALID_SIGNATURE'));
});
it('prevents overselling when two purchases race for the last unit', async () => {
await seedVariant({ sku: 'EV-VIN-003-UNI', stock: 1 });
const results = await Promise.allSettled([
startPayment({ sku: 'EV-VIN-003-UNI', quantity: 1, user: 'usr-lucia' }),
startPayment({ sku: 'EV-VIN-003-UNI', quantity: 1, user: 'usr-marc' }),
]);
expect(results.filter((r) => r.status === 'fulfilled')).to.have.lengthOf(1);
expect(results.find((r) => r.status === 'rejected').reason.appCode)
.to.equal('INSUFFICIENT_STOCK');
});
it('ignores a payment_failed that arrives after confirmation', async () => {
await sendSignedWebhook(successEvent);
await sendSignedWebhook(lateFailureEvent);
expect((await orderRepository.get('ord-8812')).status).to.equal('paid');
});
});Stripe is faked with Sinon (M9) or driven by its CLI in test mode. Transitions and amount calculation are pure functions: test them thoroughly with rounding edge cases.
| Out | Why | Extension |
|---|---|---|
| Coupons and promotions | They multiply the cases in amount calculation | A Discount object applied before tax, with explicit stacking rules |
| Per-country tax and multi-currency | They need up-to-date tax data and legal judgment | An external tax service; per-order currency with frozen rates |
| Subscriptions | An entire billing model of its own | The gateway's recurring billing + lifecycle webhooks |
| Warehouse integration | Depends on the logistics operator | A queue of dispatch and tracking events |
Common Mistakes and Tips
- Putting a global
express.json()before the webhook. The number-one mistake in this lesson: the signature will never verify. - Working with
Numberin euros. Whole cents always, with aCentssuffix in the name. And do not trust the browser's success page: it is a hint, not the truth; the truth arrives in the webhook. - Not storing the gateway's raw event. Keeping it in
payments.rawEventwill save you the day you have to reconcile against a bank statement. And never refund without anidempotencyKey: with money going out, idempotency matters even more than with money coming in. - Tip: expose two metrics with
prom-client(M11): orders per status and the age of the oldest order inpending_payment. If that age grows, something is wrong with the webhooks and you will know before a customer calls.
Exercises
-
Reservation expiry. Write the repeatable BullMQ job that releases expired reservations every minute and cancels their orders, with a test proving that sellable stock returns to its original value.
-
Bulletproof amount calculation. Write tests for
calculateAmountandcalculateShippingcovering: an empty cart, a subtotal of exactly 4999 and of 5000, and a line of 3 units at 1999 cents checking the exact VAT. -
Hardening the webhook. Create the
payment_eventstable withevent_idas the primary key and makerecordIfNewatomic. Prove that two concurrent deliveries of the same event produce a single confirmation.
Solutions
1. Reservation expiry
// src/queues/maintenance.js
await maintenanceQueue.add('release-reservations', {},
{ repeat: { every: 60_000 }, jobId: 'release-reservations-repeatable' });
new Worker('maintenance', async () => {
for (const reservation of await inventoryRepository.expiredReservations()) {
await sequelize.transaction(async (t) => {
for (const line of reservation.lines) {
await sequelize.query(
'UPDATE variants SET reserved_stock = reserved_stock - :n WHERE id = :id',
{ replacements: { n: line.quantity, id: line.variantId }, transaction: t });
}
// The status condition is essential: without it, a race with the
// webhook would cancel an order that has already been charged.
await sequelize.query(
`UPDATE orders SET status = 'cancelled' WHERE id = :id AND status = 'pending_payment'`,
{ replacements: { id: reservation.orderId }, transaction: t });
await inventoryRepository.removeReservation(reservation.id, t);
});
}
}, { connection });2. Amount calculation
it('charges shipping at 4999 and not at 5000', () => {
expect(calculateShipping(4999)).to.equal(495);
expect(calculateShipping(5000)).to.equal(0);
});
it('computes VAT over the complete subtotal', () => {
const lines = [{ unitPriceCents: 1999, quantity: 3 }];
const r = calculateAmount({ lines, shippingCents: calculateShipping(5997) });
expect(r.subtotalCents).to.equal(5997);
expect(r.taxCents).to.equal(1259); // round(5997 * 210 / 1000)
expect(r.totalCents).to.equal(7256);
});
it('returns everything at zero for an empty cart', () => {
expect(calculateAmount({ lines: [], shippingCents: 0 }).totalCents).to.equal(0);
});If VAT were computed line by line, round(1999 * 0.21) * 3 = 1260: one cent of difference per order. Multiply that by ten thousand orders and you have an uncomfortable conversation with accounting.
3. Atomic webhook
The table is minimal: event_id TEXT PRIMARY KEY, type, received_at TIMESTAMPTZ DEFAULT NOW(), status and error. The primary key is what does the work.
async function recordIfNew(eventId, type) {
// ON CONFLICT DO NOTHING RETURNING performs the check and the insert in a
// single atomic statement: there is no race window between two processes.
const [rows] = await sequelize.query(
`INSERT INTO payment_events (event_id, type) VALUES (:eventId, :type)
ON CONFLICT (event_id) DO NOTHING RETURNING event_id`,
{ replacements: { eventId, type } });
return rows.length > 0; // false if it already existed: this is a redelivery
}
it('processes only once with simultaneous deliveries', async () => {
const spy = sinon.spy(orderService, 'confirmPayment');
await Promise.all([sendSignedWebhook(successEvent), sendSignedWebhook(successEvent)]);
expect(spy.callCount).to.equal(1);
});Conclusion
You have built Escena Viva's merchandise store and, with it, crossed the line that separates applications that can fail without consequences from those that cannot. The three ideas you take away are durable and depend on neither Stripe nor Node: the order freezes its own history instead of reading it from the catalog; the amount is always computed on the server, in integers; and consistency with an external system is achieved with signatures, idempotency and a state machine that rejects the impossible instead of applying it.
You have also seen how the course's layers stack up: M7's transaction with locking prevented overselling, M10's idempotency tamed the provider's retries, queues pushed slow work out of the request and M11's object storage collected the invoices. Not one piece was new; what was new was the level of demand. In the next lesson we lower the transactional tension and raise a very different one: Escena Viva's magazine, where the challenge is no longer being right to the cent but the content itself — Markdown that may smuggle in malicious HTML, uploaded images that lie about what they are, and thousands of reads for every write.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
