We close the module with the question the previous lesson left open. An event's screen in the Escena Viva app needs the event, its sessions and the venue's data. With the REST API, that means three chained calls, or one response with ?include=sessions,venue that returns far more than the screen uses. Neither option is bad; REST simply manages that dilemma instead of removing it. GraphQL removes it, and charges you for it. This lesson explains what it solves, what price it carries, how it mounts on top of the Express application you already have, and —most importantly— when you should not use it.
Contents
- The problem: overfetching and underfetching
- REST versus GraphQL: an honest comparison
- The schema as a contract
- Resolvers: how a tree gets resolved
- Mounting the server on Express
- Context and authorization
- The N+1 problem and DataLoader
- Mandatory limits: an API with no limits is a DoS
- Errors in GraphQL
- Subscriptions, and when to choose what
- The problem: overfetching and underfetching
Two symptoms with names of their own. Underfetching: the response does not carry everything you need and you have to chain GET /api/v1/events/evt-003, then /sessions, then /venues/org-ribera. Three round trips which, on a mobile connection with 120 ms of latency, are 360 ms of pure network before the server does anything; and the second cannot start until the first finishes, because it needs the identifier. Overfetching: the response carries too much. The catalog listing returns, for each event, the title, the long description, the refund policies, the tags, the images in four sizes and the organizer's details; the card on the home screen uses three fields. The rest are bytes that get serialized (CPU, lesson 10-04), transmitted and thrown away. With GraphQL, the client writes what it wants and receives exactly that, in a single request and with the exact shape of the query:
query EventScreen {
event(id: "evt-003") {
title startsAt
venue { name city }
sessions { id startsAt availableCapacity basePriceCents }
}
}
- REST versus GraphQL: an honest comparison
| Aspect | REST | GraphQL |
|---|---|---|
| Response shape and number of requests | The server decides; one per resource | The client decides; one per screen |
| HTTP caching | Native: Cache-Control, ETag, 304, proxies, CDN |
Almost none: everything is POST /graphql |
| Server complexity | Low | High: resolvers, DataLoader, cost limits |
| Versioning | Explicit (/v1, /v2) |
Evolutionary: fields are added and marked @deprecated |
| Status codes | Semantic and rich | Always 200; errors travel in errors |
| File uploads and learning curve | multipart/form-data; low, it is HTTP |
A separate specification; medium-high |
| Tooling | curl, Postman, any proxy |
GraphiQL, Apollo Studio, client-side typing |
| Monitoring and limits | Per route and status, for free; pagination | Per operation, by hand; depth and complexity mandatory |
| Fits well with | Public APIs, caches, clear resources | Varied clients (web, mobile, TV), data graphs |
Two cells deserve emphasis. HTTP caching is the most serious loss: in lesson 10-05 we chained three layers (browser, ETag/304, Redis) and the first two disappear almost completely, because every query goes by POST to the same URL; part of it can be recovered with persisted queries and GET, but that is extra work. And status codes: losing the semantics of 404, 409 or 429 has consequences throughout your monitoring, as we will see in section 9. The message is clear, and it is not a platitude: GraphQL does not replace REST, it coexists with it. In Escena Viva we keep /api/v1/* for third-party integrations, PDF downloads and payment-gateway webhooks, and we add /graphql for the app's screens, where the flexibility pays off.
- The schema as a contract
The schema is written in SDL (Schema Definition Language) and it is the contract: it defines what exists, what type it has and what can be asked for. It is typed, mandatory and verifiable.
"A cultural event scheduled at a venue."
type Event {
id: ID! title: String! description: String
status: EventStatus! startsAt: DateTime! basePriceCents: Int!
venue: Venue!
sessions(onlyAvailable: Boolean = false): [Session!]!
}
type Session {
id: ID! event: Event! startsAt: DateTime!
totalCapacity: Int! ticketsSold: Int! availableCapacity: Int! soldOut: Boolean!
}
type Venue { id: ID! name: String! city: String! events: [Event!]! }The type syntax is the part that causes the most confusion at first:
| Notation | Meaning |
|---|---|
String / String! |
A string that can be null / that never is |
[Session] |
A list that can be null, with elements that can be null |
[Session!]! |
A list that is never null, with elements that are never null |
[Session!]! is what you almost always want for a collection: if there are no sessions, it returns [], not null. And a practical warning about !: if a field declared String! resolves to null, GraphQL propagates the error upwards, nulling the entire object, and even the whole query if the chain of ! reaches the root. Mark ! only where the guarantee is real.
"A date and time in ISO 8601 with a time zone."
scalar DateTime
enum EventStatus { DRAFT PUBLISHED SOLD_OUT CANCELLED }
enum UserRole { ATTENDEE ORGANIZER ADMINISTRATOR }
enum OrderStatus { PENDING PAID CANCELLED }
"Everything that can appear in a global search."
interface SearchResult { id: ID! title: String! }
type User { id: ID! name: String! email: String! role: UserRole! orders: [Order!]! }
type Ticket { id: ID! code: String! seat: String priceCents: Int! }
type Order {
id: ID! user: User! session: Session! status: OrderStatus!
totalCents: Int! createdAt: DateTime! tickets: [Ticket!]!
}Custom scalars such as DateTime are not decoration: they carry serialization and validation functions, so an invalid date is rejected at the edge of the schema, exactly as zod did in REST (M6). And the three root types are the three entry doors:
type Query {
event(id: ID!): Event
events(status: EventStatus, limit: Int = 20, cursor: String): EventConnection!
session(id: ID!): Session
me: User
myOrder(id: ID!): Order
}
type Mutation {
createOrder(input: CreateOrderInput!): OrderResult!
cancelOrder(orderId: ID!, reason: String!): OrderResult!
publishEvent(eventId: ID!): Event!
}
type Subscription { capacityUpdated(sessionId: ID!): Session! }
"Complex arguments use input types, not object types."
input CreateOrderInput { sessionId: ID! quantity: Int! idempotencyKey: String! }
type OrderResult { order: Order businessError: BusinessError }
type BusinessError { code: String! message: String! details: [String!]! }
type EventConnection { nodes: [Event!]! endCursor: String hasNextPage: Boolean! }Notice OrderResult: predictable business errors (insufficient capacity, a cancelled session) are modeled as part of the schema, not as exceptions; we will come back to it in section 9. And notice idempotencyKey: lesson 10-05 is not left behind by changing protocol, it only moves.
- Resolvers: how a tree gets resolved
A resolver is the function that produces a field's value, with a four-argument signature: parent (the value returned by the previous level's resolver), args (the field's arguments), context (shared by the whole request: user, repositories, loaders) and info (query metadata: which fields are requested, the path in the tree). GraphQL resolves breadth-first, level by level: first event, then all of its fields, then those of venue and sessions, and so on downwards. If you do not define a resolver for a field, the default resolver applies —look for a property with that name on the parent— and that is why title or startsAt need no code.
'use strict';
const { GraphQLError } = require('graphql');
// Factories with injected dependencies (the same discipline as M9) that
// reuse the SAME M7 repositories: the data layer is not duplicated.
const requireUser = (context) => {
if (context.user) return;
throw new GraphQLError('Authentication required',
{ extensions: { code: 'NOT_AUTHENTICATED', status: 401 } });
};
const createResolvers = ({ repositories }) => ({
Query: {
event: (parent, { id }, context) => context.loaders.event.load(id),
events: (parent, { status, limit, cursor }) => repositories.events.getCatalog(
{ status, cursor, limit: Math.min(limit, 50) }), // the server's ceiling
me: (parent, args, context) =>
context.user ? repositories.users.getById(context.user.id) : null,
myOrder: async (parent, { id }, context) => {
requireUser(context);
const order = await repositories.orders.getById(id);
// The same M8 policy, checked here and not in a route.
return order && order.userId === context.user.id ? order : null;
},
},
// Field resolvers: 'parent' is the already resolved Event.
Event: {
venue: (event, args, context) => context.loaders.venue.load(event.venueId),
sessions: (event, { onlyAvailable }, context) =>
context.loaders.sessionsByEvent.load(event.id)
.then((s) => (onlyAvailable ? s.filter((x) => x.availableCapacity > 0) : s)),
},
Session: {
// Computed fields: they do not exist in the DB, they derive from the domain.
availableCapacity: (session) => session.totalCapacity - session.ticketsSold,
soldOut: (session) => session.ticketsSold >= session.totalCapacity,
event: (session, args, context) => context.loaders.event.load(session.eventId),
},
Mutation: {
createOrder: async (parent, { input }, context) => {
requireUser(context);
try {
// The SAME transactional M7 repository, with SELECT FOR UPDATE.
const order = await repositories.purchases.buyTickets({
userId: context.user.id, sessionId: input.sessionId,
quantity: input.quantity, idempotencyKey: input.idempotencyKey,
});
return { order, businessError: null };
} catch (error) {
// An expected error: part of the schema, not an exception.
if (error.appCode !== 'INSUFFICIENT_CAPACITY') throw error;
return { order: null, businessError: { code: error.appCode,
message: error.message, details: error.details || [] } };
}
},
},
});
module.exports = { createResolvers };This is the reward for having isolated the data layer back in Module 7: the repositories are exactly the same ones. The business logic, the transactions with SELECT ... FOR UPDATE, the capacity rules, the pure authorization in src/authorization/policy.js: everything is reused. GraphQL is a different facade over the same core, and if your domain were tangled up with Express this step would be unfeasible.
- Mounting the server on Express
With npm install graphql @apollo/server @as-integrations/express5 dataloader graphql-depth-limit:
'use strict';
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@as-integrations/express5');
const depthLimit = require('graphql-depth-limit');
const { typeDefs } = require('./schema.js');
const { createResolvers } = require('./resolvers.js');
const { createLoaders } = require('./loaders.js');
const { createContext } = require('./context.js');
const { configuration } = require('../config/index.js');
async function mountGraphql({ app, repositories }) {
const server = new ApolloServer({
typeDefs,
resolvers: createResolvers({ repositories }),
introspection: configuration.nodeEnv !== 'production', // see section 8
validationRules: [depthLimit(8)],
formatError: (formatted, original) => { // never leak details (M8)
if (formatted.extensions && formatted.extensions.code) return formatted;
console.error('[graphql] unhandled error', original);
return { message: 'Internal error', extensions: { code: 'INTERNAL_ERROR', status: 500 } };
},
});
await server.start();
// It coexists with the REST API: /api/v1/* stays exactly as it was.
app.use('/graphql', expressMiddleware(server, {
context: async ({ req }) => createContext({ req, repositories, createLoaders }),
}));
return server;
}
module.exports = { mountGraphql };It plugs into createApplication() (M6) as one more route, and the middleware we already had —request-id.js, http-logger.js, cors.js, helmet, limits.js— still applies because it is mounted earlier. The rate limiter matters especially here, but it is not enough: a single GraphQL query can be as expensive as ten thousand REST requests, so limiting by request count is limiting the wrong metric. We fix that in section 8. graphql-http is the minimalist alternative if you do not want Apollo: it implements the transport specification and little else, while Apollo brings query-plan caching, metrics, plugins and persisted queries.
- Context and authorization
The context is built once per request and it is where everything the resolvers need gets injected:
'use strict';
const { verifyAccessToken } = require('../services/tokens.js');
async function createContext({ req, repositories, createLoaders }) {
let user = null;
const header = req.get('authorization');
if (header && header.startsWith('Bearer ')) {
// The same M8 token service (JWT HS256, 15 min). An invalid token is
// treated as anonymous.
try { user = await verifyAccessToken(header.slice(7)); } catch { user = null; }
}
// NEW loaders on every request: their cache must neither survive nor
// cross between users. This is correctness and security, not optimization.
return {
user, repositories, requestId: req.requestId,
loaders: createLoaders({ repositories }),
};
}
module.exports = { createContext };And here comes the delicate part. In REST, authorization is mounted on the route (router.post('/events', authenticate, requireRole('organizer'), ...)), and if you forget the middleware you notice, because a route is a visible unit. In GraphQL there are no routes: there is a single endpoint and a graph the client navigates freely, so an unprotected field in any corner of the schema is reachable from any query that gets to it. A seemingly innocent query asking for events { venue { events { ... } } } can end up reaching Order.user and, if that resolver checks nothing, read Lucía's e-mail address. Authorization is checked in every resolver that exposes sensitive data, not at the entrance.
'use strict';
const { GraphQLError } = require('graphql');
const { hasPermission } = require('../authorization/permissions.js');
// A wrapper that applies a policy before resolving, reusing the pure M8
// functions: there is one policy for both REST and GraphQL.
const withPermission = (action, resolver) => (parent, args, context, info) => {
if (!context.user || !hasPermission(context.user, action, { parent, args })) {
throw new GraphQLError('You do not have permission for this operation',
{ extensions: { code: 'FORBIDDEN', status: 403 } });
}
return resolver(parent, args, context, info);
};
// Sensitive fields are declared protected explicitly.
const userResolvers = { User: {
email: withPermission('user:read:email', (user) => user.email),
orders: withPermission('user:read:orders', (user, args, context) =>
context.repositories.orders.listByUser(user.id)),
} };
module.exports = { withPermission, userResolvers };A defensive rule: deny by default. There are directive libraries (@auth(requires: ORGANIZER)) that let you declare it in the SDL itself, which is harder to forget than a wrapper in the resolver.
- The N+1 problem and DataLoader
The N+1 problem from Module 7 shows up in REST too, but in GraphQL it is structurally worse, because the client chooses the shape of the query and can trigger it without knowing. A query asking for events { nodes { title venue { name } sessions { id } } } with 3 events is 7 queries: 1 for the catalog, 3 for venues and 3 for sessions. With 100 events it would be 201, and the client has done nothing strange: it asked for the data it needs. In REST you could optimize the specific endpoint; here you do not know in advance which combination will be requested. DataLoader solves it with two mechanisms: batching, accumulating every .load(id) call that happens in the same tick of the event loop into a single call with all the identifiers; and a per-request cache, so the same id is never queried twice.
'use strict';
const DataLoader = require('dataloader');
// CRITICAL: return an array of the SAME size and in the SAME order as the
// ids received; if one is missing, return null in its place.
const byKey = (records, ids) => {
const index = new Map(records.map((r) => [r.id, r]));
return ids.map((id) => index.get(id) || null);
};
// A NEW set of loaders per request (see createContext). Each one makes a
// single query with WHERE id IN (...).
const createLoaders = ({ repositories }) => ({
event: new DataLoader(async (ids) =>
byKey(await repositories.events.getByIds(ids), ids)),
venue: new DataLoader(async (ids) =>
byKey(await repositories.venues.getByIds(ids), ids)),
// A one-to-many loader: it returns an array for each key.
sessionsByEvent: new DataLoader(async (eventIds) => {
const sessions = await repositories.sessions.listByEvents(eventIds);
const byEvent = new Map(eventIds.map((id) => [id, []]));
for (const session of sessions) byEvent.get(session.eventId).push(session);
return eventIds.map((id) => byEvent.get(id)); // an empty array if none
}),
});
module.exports = { createLoaders };Measured against the Escena Viva catalog with the query above:
| Metric | Without DataLoader | With DataLoader |
|---|---|---|
| PostgreSQL queries (3 events) | 7 | 3 |
| p50 / p99 latency (3 events) | 84 ms / 240 ms | 21 ms / 46 ms |
| Queries / p99 with 100 events | 201 / 3,100 ms | 3 / 78 ms |
The figure to remember is 201 down to 3 with 100 events: DataLoader turns a problem that grows linearly with the data into a constant one. In GraphQL it is not an optional optimization, it is an architectural requirement. Two warnings: loaders must be created per request, because if you create them at startup their cache survives across requests and different users, serving stale data and, worse, another user's data; and the order and size of the returned array must match the keys received exactly, which is the most frequent implementation mistake and silently crosses data between entities.
- Mandatory limits: an API with no limits is a DoS
A GraphQL API with no limits is a denial-of-service attack waiting to happen, and you do not need to be sophisticated: it is enough to nest events { nodes { venue { events { nodes { venue { ... } } } } } } a dozen levels deep. Each level multiplies the work, so with circular relations (Event → Venue → Event) a 200-byte query can generate millions of resolutions and exhaust the process's memory. It is a failure in the API4 (Unrestricted Resource Consumption) category of the OWASP API Security Top 10 we saw in Module 8.
| Defense | How | A reasonable value |
|---|---|---|
| Maximum depth | graphql-depth-limit |
7-10 levels |
| Complexity and pagination | graphql-query-complexity; limit with a ceiling |
1,000 points; a maximum of 50-100 |
| Timeout and body size | AbortSignal; express.json({ limit }) |
5-10 s; 16 KB on /graphql |
| Allowed queries and introspection | An allowlist of persisted queries; introspection: false |
Your own clients; in production |
| Rate limiter | limits.js with Redis (10-03) |
Per user, not only per IP |
'use strict';
const { GraphQLError } = require('graphql');
const { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } =
require('graphql-query-complexity');
// The cost is calculated BEFORE anything runs: if it exceeds, it is rejected.
const complexityRule = (max = 1000) => (context) => createComplexityRule({
maximumComplexity: max,
variables: context.request.variables,
// fieldExtensionsEstimator reads the cost declared on each schema field;
// simpleEstimator assigns 1 point to the ones that do not declare it.
estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
createError: (allowed, actual) => new GraphQLError(
`The query is too complex: ${actual} points (maximum ${allowed}).`,
{ extensions: { code: 'QUERY_TOO_COMPLEX', status: 400 } }),
});
module.exports = { complexityRule };About introspection: it is the ability to ask the server about its own schema, and it is what makes GraphiQL and type-generation tools work. In production it hands anyone the full map of your API, including the fields you have not announced yet and the admin mutations: turn it off and publish the schema through channels you control. It is not security through obscurity —limits and authorization are still your real defense— but there is no reason to give the map away. And persisted queries are the definitive defense when the only client is yours: the client sends a hash instead of the query and the server only runs the ones on its approved list, which wipes out depth and complexity bombs in one stroke.
- Errors in GraphQL
In GraphQL everything answers 200 OK (except transport or syntax errors, which can return 400). Errors travel in the body, and there is something REST does not have: partial responses.
{
"data": { "event": { "title": "Festival de Jazz de Primavera", "venue": null } },
"errors": [{ "message": "You do not have permission for this operation",
"path": ["event", "venue"],
"extensions": { "code": "FORBIDDEN", "status": 403, "requestId": "f1c2..." } }]
}data.event.title is valid and data.event.venue is null with its associated error. It is powerful, and it forces the client to check errors always, even when there is data. The consequences are concrete:
| Consequence | What it implies |
|---|---|
| The client cannot trust the HTTP code | You have to inspect errors on every response |
| Automatic retries never trigger | A 200 with an error fires no retry policy |
| Monitoring lies | Your 5xx rate will be 0% while the system burns |
| Proxies and status-code alerts are useless | They may cache an error; you must instrument by extensions.code |
Lesson 10-04 put the error rate among the four golden signals; with GraphQL you have to produce that signal by hand, with an Apollo plugin that in willSendResponse records the operation's name, its duration and every error's extensions.code. Without that, your Module 11 dashboard will show 0% errors forever. And that is why the schema distinguished two classes of error. Predictable business errors (insufficient capacity) travel in OrderResult.businessError: they are part of the contract, they are typed and the client handles them with its own compiler's help. Unexpected ones (the database is down) go in errors. This separation —sometimes called "errors as data"— is one of the ecosystem's best practices, and it saves the client from guessing by reading strings.
- Subscriptions, and when to choose what
Subscription lets the server push data to the client over a persistent connection (a WebSocket, usually with graphql-ws), and in Escena Viva the natural case is capacity during the Festival de Jazz opening night: with subscription { capacityUpdated(sessionId: "ses-003-1") { availableCapacity soldOut } }, the counter drops live as people buy, fed by the sale-recorded and low-capacity events that SalesManager has been emitting since Module 2, published through Redis (10-03) so they reach all seven workers.
We leave it announced: real-time communication, with WebSockets, rooms and message broadcasting, is worked on properly in Module 12 with Socket.IO and the chat project, where the problems —stateful connections, scaling across processes, reconnection— are solved in depth. The final criterion, which is what should stay with you:
| Situation | Choose |
|---|---|
| A public API for third parties, cacheable content, files and webhooks | REST: HTTP caching with ETag and a CDN, status codes, universal tooling |
| Your own application with many screens, heterogeneous clients or graph-shaped data | GraphQL |
| A small team, short deadlines, a simple domain | REST: fewer pieces that can fail |
| A mature product with external clients and an app of your own | Both, over the same domain |
Escena Viva sits in that last row, and it can afford to because of the architecture built throughout the course: a pure domain, isolated repositories, authorization policies as pure functions. On top of that core, REST and GraphQL are two facades; if the business lived inside the Express controllers, maintaining both would mean duplicating the logic and guaranteeing that one day they diverge.
Common Mistakes and Tips
- Believing GraphQL replaces REST. They are tools with different trade-offs; most mature systems use both. And forgetting DataLoader: without it, any nested query is an N+1.
- Loaders shared between requests, or ones that return an array of a different size or order: the first crosses data between users, the second crosses data between entities. Both silently.
- Authorizing only at the root. The graph is navigated from anywhere: check it in every sensitive resolver.
- Publishing without depth and complexity limits, or leaving introspection on in production: the first is a 200-byte DoS, the second gives away the map of your API. And monitoring by HTTP code is useless: with GraphQL it is always 200, so instrument by
extensions.code. - Tip: start with a small schema over your existing repositories, and model the domain, not your tables: a schema that mirrors the database one-to-one is usually bad design.
- Tip: mark the fields you are retiring with
@deprecated(reason: "...")and measure their usage before removing them; it is the equivalent of theSunsetheader from 10-05.
Exercises
Exercise 1: catalog schema and resolvers
Define the schema for Event, Session and Venue with their computed fields (availableCapacity, soldOut) and mount /graphql on createApplication(), reusing the M7 repositories without duplicating logic. Check that the REST API still works and write an integration test with supertest comparing the GraphQL query's result with that of GET /api/v1/events/evt-003.
Exercise 2: measure and eliminate the N+1
Instrument the repository to count SQL queries per request. Run the query that asks for 3 events with their venue and their sessions, and write down the number of queries without DataLoader. Implement the three loaders and measure again. Repeat with 100 seeded events.
Exercise 3: query bomb and defense
Write a query with 12 levels of nesting exploiting the circular Event → Venue → Event relation. Measure the response time and the event loop delay (10-04) with no limits. Apply depthLimit(8) and a complexity rule of 1,000 points, and verify that the query is rejected before it runs.
Solutions
Exercise 1. The key is to write no new logic: Session.availableCapacity is session.totalCapacity - session.ticketsSold, the same formula that already lives in src/domain/session.js, and the right thing to do is call that function, not rewrite it. The test compares, field by field, the result of request.get('/api/v1/events/evt-003') with that of request.post('/graphql').send({ query: '{ event(id: "evt-003") { title venue { name } } }' }), also checking that body.errors is undefined. That both match proves what we were after: two facades over one domain. If they diverge, there is duplicated logic somewhere and that is technical debt from day one.
Exercise 2. With 3 events and no loaders, the counter shows 7 queries: 1 for the catalog, 3 for venues, 3 for sessions. With the loaders it drops to 3: the catalog, venues WHERE id IN (...) and sessions WHERE eventId IN (...). With 100 seeded events the difference becomes dramatic —201 versus 3, and the p99 goes from ~3.1 s to ~78 ms— but what matters is not the improvement factor, it is the shape of the curve: without DataLoader the cost grows with the number of items and with it the cost is constant. A detail that surprises many people: loaders can batch because GraphQL resolves breadth-first, so every .load() call at the same level happens in the same tick of the event loop —exactly the microtask mechanism from Module 2— and DataLoader collects them all before querying.
Exercise 3. With no limits, the 12-level query with a circular relation takes between 8 and 40 seconds depending on the seeded data, and the event loop delay exceeds 3,000 ms: the process becomes useless for every other user, exactly the symptom from lesson 10-02 but triggered from the outside with a 200-byte string. With depthLimit(8) the query is rejected during the validation phase, before a single resolver runs, with Query exceeds maximum operation depth of 8 and in under 2 ms. The complexity rule also catches the case depth cannot see: a flat query but with limit: 10000 on several lists. Both are necessary, and neither replaces authorization: limits protect availability, not confidentiality.
Conclusion
Escena Viva reaches the end of Module 10 as a different system from the one it started as. It uses every core of the machine with cluster, with supervision, zero-downtime reload and the lesson learned that in-memory state does not survive several processes. It does not block the event loop: QR and PDF generation lives in a worker thread pool, and the loop delay dropped from 4,176 ms to 11 ms. It caches the catalog in Redis with the cache-aside pattern, versioned keys and invalidation on sale, going from 31,050 PostgreSQL queries to 7. It enqueues the heavy work with BullMQ, answers 202 Accepted and protects idempotency in both producer and consumer. It is measured: with honest load tests, CPU profiles, flamegraphs, heap snapshots and loop delay as the health metric, and with a clear order of levers —algorithm, query, cache, concurrency, hardware. It has a well-designed API, with consistent resources, methods with their guarantees, idempotency keys, status codes used with judgment, cursor pagination, versioning with a deprecation policy, HTTP caching with ETag and If-Match, and OpenAPI documentation generated from the schemas. And now it also has a GraphQL alternative that coexists with REST over the same domain, with DataLoader against N+1 and depth and complexity limits so it is not a denial of service waiting to happen.
It is a system that can take the Festival de Jazz de Primavera opening night. And yet, all of this still runs on your laptop.
The secrets live in a local .env you cannot share with anyone without sending it over an insecure channel. The logs vanish when you close the terminal, and with seven workers writing at once, they cannot even be read properly. There is nobody to restart the process if it dies at four in the morning: the src/cluster.js we wrote supervises its workers, but nobody supervises the primary. There is no container guaranteeing that the version of Node, PostgreSQL and Redis is the same on your machine and on the server. And there is no deployment: shipping a new version is a sequence of manual commands that only you know and that one day you will get wrong at two in the morning.
In Module 11, Deployment and DevOps, we close that distance: configuration and environment variables managed properly, logging and monitoring in production so the metrics we have learned to produce end up somewhere a human looks at them, PM2 to supervise and run in cluster mode what we implemented by hand here, packaging with Docker so the environment travels with the application, deployment to Heroku and other PaaS, and a continuous integration and deployment pipeline that turns shipping a version into something boring. Which is, in the end, the highest praise you can give a deployment.
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
