Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@ Public, Markdown-only course packs for [LearnDeck](https://github.com/learn-deck

## What is here

The first catalogue entry is **DDD and Hexagonal Architecture with Node.js + TypeScript**. It is a project-based, six-to-eight-hour course for developers moving from feature delivery toward confident system design.
The catalogue currently holds two packs:

- **DDD and Hexagonal Architecture with Node.js + TypeScript** (`ddd-backend-foundations`) — a project-based, six-to-eight-hour course for developers moving from feature delivery toward confident system design.
- **Testing Fundamentals** (`testing-fundamentals`) — a five-to-seven-hour course on writing tests that earn their confidence: red-first flow, boundaries, honest test doubles, edge tables, and refactoring under green.

```text
courses/
ddd-backend-foundations/
course.md
modules/
testing-fundamentals/
course.md
modules/
notes/
references/
*.md
```
Expand Down
71 changes: 63 additions & 8 deletions courses/ddd-backend-foundations/modules/00-start-a-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
id: start
title: Set up your backend
goal: Confirm one Node.js + TypeScript workspace and make a tiny status route visible.
action: Create the agreed folder structure and one health/status route in your project folder. Run npm run dev yourself when you are ready.
action: Create src/domain/, src/application/, src/ports/, and src/adapters/ plus one health/status route in your project folder. Run npm run dev yourself when you are ready.
sources:
- ./00-start-a-path.md
- ../../../references/language-paths.md
Expand All @@ -26,6 +26,11 @@ questions:

# 00 · Set up your backend

## Outcome

I can run one Node.js + TypeScript backend workspace with a visible status
route and explain why it lives apart from LearnDeck's progress data.

You only need one project for this course: a small Node.js + TypeScript backend.
LearnDeck remembers your answers locally; your project folder holds the code you
will build. Keeping them separate lets you retry, rename, or delete the project
Expand All @@ -42,13 +47,50 @@ without touching the course itself.
2. Use the Node.js + TypeScript checks in
[`language-paths.md`](../../../references/language-paths.md). They only tell
you what is present; they never install or run anything for you.
3. In your project, create the first four areas: `domain`, `application`,
`ports`, and `adapters`. Empty folders are enough today.
4. Add a tiny status endpoint such as `GET /health` that returns a simple,
honest response.
3. In your project, create these four areas: `src/domain/`,
`src/application/`, `src/ports/`, and `src/adapters/`. Empty folders are
enough today.
4. If the folder is empty, copy this minimal setup. It uses `tsx` to run
TypeScript directly; run `npm install` yourself. LearnDeck never installs
packages or starts your server.

`package.json`

```json
{
"name": "ddd-backend",
"private": true,
"type": "module",
"scripts": { "dev": "tsx --watch src/server.ts" },
"devDependencies": { "tsx": "^4.19.0", "typescript": "^5.6.0" }
}
```

`src/server.ts`

```ts
import { createServer } from "node:http";

const server = createServer((request, response) => {
if (request.method === "GET" && request.url === "/health") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ status: "ok" }));
return;
}
response.writeHead(404);
response.end();
});

server.listen(3000);
```

Run `npm install`, then `npm run dev`, and check `GET /health`. The expected
response is verbatim: `200 {"status":"ok"}`.

5. When the project is ready, run `npm run dev` yourself and look at the
endpoint. Tell your AI guide what you ran and what you observed so it can
record that evidence through LearnDeck.
endpoint. If a guide is connected, tell it what you ran and observed so it
can record that evidence; otherwise record it with the evidence form or in
`NOTES.md` in your workspace.

> [!TIP]
> Do not design the perfect server today. A plain status response is valuable
Expand All @@ -59,7 +101,7 @@ type: checklist
id: start-ready
label: Before you continue
items:
- Node.js and npm are available on my Mac.
- Node.js 22 or newer and npm are available on my machine.
- My backend project folder is separate from LearnDeck.
- I know the status route I will make visible.
```
Expand All @@ -74,3 +116,16 @@ or collect your project outside the folder you confirm.

When you are ready, answer the question below in your own words. A short,
concrete answer is better than architecture vocabulary.

## Definition of done

Before answering, check that:

- The project contains `src/domain/`, `src/application/`, `src/ports/`, and `src/adapters/`.
- A learner-run development command starts the backend, such as `npm run dev`.
- A visible `GET /health` route returns `200 {"status":"ok"}`.
- You can name the project folder, command, route, and observed response.

After you submit your answer, choose **Mark as self-reviewed and continue** if
you are working without a connected guide. Guide evaluation is optional, not
required; if a guide is connected, you may request feedback instead.
61 changes: 57 additions & 4 deletions courses/ddd-backend-foundations/modules/01-model-the-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,55 @@ rule? Give one example of each for a small task or booking service.
> your domain must protect—even when tomorrow's client is not HTTP.

1. Choose a deliberately small problem: task tracking, booking, inventory
reservation, or another bounded workflow. Record the choice in progress.
reservation, or another bounded workflow. Record the choice in your answer
or in `NOTES.md` in your workspace.
2. Write a short domain note in your workspace: nouns, verbs, state changes,
and what must always be true.
3. Define one use case in application language: input, successful outcome, and
expected domain failures.
4. Create a domain type or aggregate that protects one invariant without
importing an HTTP framework, database client, or logger.
5. Ask the agent to record the domain-note and code paths. Then explain the
invariant in your own words.
importing an HTTP framework, database client, or logger. In DDD terms, an
aggregate is the consistency boundary: everything the invariant needs to
stay true is checked inside it, in one operation. A small value object—an
immutable type compared by its values, such as a `TimeRange` that refuses
`endsAt <= startsAt`—is often the cheapest first guard.
5. If a guide is connected, ask it to record the domain-note and code paths;
otherwise record those paths in the evidence form or in `NOTES.md` in your
workspace. Then explain the invariant in your own words.

## Worked example: protect the overlap rule

Here is one small domain decision fully worked for the booking service:

```ts
type Booking = {
roomId: string;
startsAt: number;
endsAt: number;
};

export function overlaps(existing: Booking, candidate: Booking): boolean {
return existing.roomId === candidate.roomId
&& existing.startsAt < candidate.endsAt
&& candidate.startsAt < existing.endsAt;
}
```

Times here are epoch milliseconds and each booking is a half-open interval
`[startsAt, endsAt)`, so a booking that starts exactly when another ends does
not overlap. Writing that convention down in your domain note is itself a
ubiquitous-language decision: everyone, including your tests, now means the
same thing by "overlap".

Your next analogous decision: decide what your domain operation should return
when `overlaps` is true. Write the result type and one expectation for a second
booking attempt; keep HTTP and database concerns out of it.

Why this decision? The predicate compares the same room and intersecting time
intervals, so it protects the booking invariant independently of how a request
or row is represented. Its small boundary lets the application turn `true`
into a domain rejection without asking the domain to know about status codes or
SQL.

Use [Vaughn Vernon's aggregate guidance](../../../references/source-index.md#ddd)
and [the hexagonal architecture reference](../../../references/source-index.md#hexagonal)
Expand All @@ -67,3 +107,16 @@ reject first, and explain why they are not the same responsibility.

Given a new route, decide whether its rule belongs in the adapter, application
use case, or domain model—and say why.

## Definition of done

Before answering, check that:

- A domain note names the booking or other workflow's nouns, verbs, state changes, and invariant.
- One domain type or aggregate protects that invariant without HTTP, database, or logger imports.
- One use case names its input, successful outcome, and expected domain failure.
- You can distinguish the domain invariant from one transport validation rule.

After you submit your answer, choose **Mark as self-reviewed and continue** if
you are working without a connected guide. Guide evaluation is optional, not
required; if a guide is connected, you may request feedback instead.
82 changes: 82 additions & 0 deletions courses/ddd-backend-foundations/modules/02-draw-the-hexagon.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,75 @@ direction has been reversed? What makes that costly to change or test?
5. Confirm that the inner code has no imports of the web framework, SQL driver,
or environment package.

## Worked example: let the inside name the port

Here is one small dependency decision fully worked for `CreateBooking`:

```ts
type Booking = { roomId: string; startsAt: number; endsAt: number };

export interface BookingRepository {
findOverlapping(roomId: string, startsAt: number, endsAt: number): Promise<Booking | null>;
}

export class InMemoryBookingRepository implements BookingRepository {
constructor(private readonly bookings: Booking[]) {}

async findOverlapping(roomId: string, startsAt: number, endsAt: number) {
return this.bookings.find((booking) =>
booking.roomId === roomId && booking.startsAt < endsAt && startsAt < booking.endsAt,
) ?? null;
}
}
```

Your next analogous decision: choose the next capability this use case needs,
such as saving a new booking. Write its port signature and the in-memory method,
keeping the operation named after booking behaviour rather than SQL.

Why this decision? The use case asks whether the room and time are available,
so the port exposes exactly that capability. The in-memory adapter implements
the same inside-facing contract; a database adapter can replace it at the edge
without changing the booking decision.

## What this is NOT

The wrong direction makes the domain depend on the HTTP framework:

```ts
// domain/booking.ts — wrong
import type { Request } from "express";

export function canBook(request: Request): boolean {
return request.body.roomId !== undefined;
}
```

The correct direction translates HTTP at the edge and keeps the domain plain:

```ts
// domain/booking.ts — right
type Booking = { roomId: string; startsAt: number; endsAt: number };
type BookingCommand = { roomId: string; startsAt: number; endsAt: number };

export function canBook(command: BookingCommand, existing: readonly Booking[]) {
return existing.every((booking) =>
booking.roomId !== command.roomId || booking.endsAt <= command.startsAt || command.endsAt <= booking.startsAt,
);
}
```

The wrong version lets Express's request shape reach a domain decision, so the
rule depends on transport. The right version accepts domain data; an outer
adapter translates the request and the inner code remains callable without
Express.

Cockburn's names for the two sides are worth keeping: a **driving** adapter
(an HTTP handler, a test) calls the application through a port; a **driven**
adapter (a repository, a clock) is called by the application through a port it
defines. In both cases the port belongs to the inside and the adapter to the
outside—only who initiates the call changes.

Use the original [Ports and Adapters article](../../../references/source-index.md#hexagonal)
for the direction, not as a folder-name ritual.

Expand All @@ -66,3 +135,16 @@ database's API?

Classify each as a port or adapter: `TaskRepository`, PostgreSQL query client,
system clock, and Fastify handler.

## Definition of done

Before answering, check that:

- One use case calls a port owned by the inner application/domain boundary.
- One in-memory adapter implements that port and is wired at the outer composition point.
- The inner code has no web framework, SQL driver, or environment-package imports.
- You can name the caller, port owner, adapter, and dependency direction for one booking decision.

After you submit your answer, choose **Mark as self-reviewed and continue** if
you are working without a connected guide. Guide evaluation is optional, not
required; if a guide is connected, you may request feedback instead.
59 changes: 56 additions & 3 deletions courses/ddd-backend-foundations/modules/03-make-an-api-useful.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,54 @@ formatting difficult to test and change independently?
> The domain rejects a genuine double booking. These are different failures,
> so they deserve different messages and tests.

1. Choose one command endpoint for the use case from step 01.
1. Choose one command endpoint for the use case you named in module 01 and
wired in module 02.
2. In the HTTP adapter, parse and validate only transport-shaped input.
3. Translate the request to the application input, call the use case, and map
known domain failures to stable responses.
4. Add a status/health route. If your project includes a tiny frontend, have it
call or display that route; it is a visibility aid, not the course product.
5. Ask the agent to check the documented Node.js development command. Run
`npm run dev` yourself and record the route, command, and observed result.
5. If a guide is connected, ask it to check the documented Node.js development
command. Run `npm run dev` yourself and record the route, command, and
observed result; otherwise keep that record in the evidence form or in
`NOTES.md` in your workspace.

## Worked example: two different failures, two deliberate responses

Here is one small response decision fully worked for `POST /bookings`. The
adapter distinguishes malformed transport input from a rejected domain
decision:

```ts
// adapters/http/bookings.ts
const result = await createBooking(command, repository);

if (result.kind === "rejected") {
response.writeHead(409, { "content-type": "application/problem+json" });
response.end(JSON.stringify({
type: "urn:ddd-backend:problem:room-already-booked",
title: "Room already booked",
status: 409,
detail: `Room ${command.roomId} is already booked for that time.`,
}));
return;
}

response.writeHead(201, { "content-type": "application/json" });
response.end(JSON.stringify(result.booking));
```

Why this decision? A body that is not valid JSON never reaches `createBooking`;
the adapter answers `400` on its own, because malformed transport input is the
transport's problem. A well-formed request that loses the booking rule gets a
stable `409 Conflict` problem response (RFC 9457's `application/problem+json`
shape), so a client can tell "fix my request" apart from "the room is taken"
without parsing prose. The use case returned `{ kind: "rejected", reason:
"room-already-booked" }` and never saw a status code.

Your next analogous decision: choose the success representation. Decide what
`201 Created` should return for your endpoint—and which fields of the domain
object the response deliberately exposes.

Use the HTTP references in [the source index](../../../references/source-index.md#http)
to reason about resource semantics and problem responses.
Expand All @@ -64,3 +104,16 @@ stop?

Given a new error, decide whether it is a transport error, application decision,
or domain failure before choosing its HTTP response.

## Definition of done

Before answering, check that:

- One command endpoint and the status route are reachable with the documented development command.
- Malformed transport input stops at the HTTP boundary with a deliberate response.
- A double booking or other invariant failure produces a distinct stable response.
- You recorded the command, route, and observed results in your workspace or answer.

After you submit your answer, choose **Mark as self-reviewed and continue** if
you are working without a connected guide. Guide evaluation is optional, not
required; if a guide is connected, you may request feedback instead.
Loading