Bangun API Pesanan Modular

Build a tested Fastify Order API with modular boundaries and validation.

Introduction

30 Second Summary

A small app can feel finished as soon as its first request succeeds. Its design gets tested when a customer sends an impossible order.

In this project, you will build a local Order API using Fastify with TypeScript. You will evolve it into a modular monolith with explicit boundaries from request to response.

What You'll Build

You will demo your local API from Windows PowerShell with every outcome visible in the response or its structured terminal log.

By the end of this project, you'll have:

  • A working order flow where a valid keyboard request returns an accepted order with a generated ID.
  • Predictable error contracts that stop a zero quantity with VALIDATION_ERROR. Oversized keyboard orders return OUT_OF_STOCK.
  • A repeatable proof you can run to show the process is alive. Your architecture decision record explains why this architecture fits the project.
  • Secret Mission: An inventory endpoint that exposes current stock through the existing module boundary. An automated test protects the new contract.

Are there any prerequisites?

Experience building a few small apps is enough preparation. Your Windows setup needs the listed local tools without any cloud account or paid service.

Before We Start

Your goal is a local Fastify Order API built as a modular monolith that teaches explicit module boundaries without several networked services. A module should be extracted only when evidence requires independent ownership, deployment, scaling, availability, or technology.

Set Up a Reproducible Local Workspace

Architecture decisions become unreliable when each machine uses a different runtime. Dependency drift can also change behavior between installs.

You'll establish the project in Visual Studio Code with Windows PowerShell. You'll confirm a compatible Node.js runtime before any application code exists.

The package manifest pins Fastify 5.12.5.

The development configuration pins TypeScript 7.0.2. Node.js type definitions stay at 24.19.1.

In this step, get ready to:
  • Confirm that your Node.js runtime meets the project requirement.
  • Create the local workspace with pinned configuration files.
  • Install the pinned local dependencies.
Confirm the Node.js runtime

This project runs .ts files through Node.js built-in type stripping. Stable support requires v24.12.0 or newer on the 24.x LTS line.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Press Ctrl+` to show the integrated terminal.
  • Select the terminal dropdown in the terminal toolbar.
  • Select PowerShell as the terminal profile.
  • Check your installed Node.js version by running this command:
node --version

What Does This Command Check?

The node --version command prints the active Node.js version. This confirms which runtime executes the project.

✔️ I see the required version

The output must begin with v24.. It must show v24.12.0 or newer.

Your runtime is compatible. Continue to the workspace substep.

ⓧ I see an older or different major version

Your current runtime does not meet the project's compatibility requirement. Install the current Node.js 24.x LTS release before continuing.

  • Open the official Node.js download page in your browser.
  • Download the Windows installer for v24.21.0 LTS.
  • Run the downloaded installer.
  • Keep the default features selected in the setup wizard.
  • Complete the installation.
  • Close the existing terminal with the trash icon in its toolbar.
  • Press Ctrl+` to create a fresh PowerShell terminal.
  • Repeat the version command shown above.

ⓧ Command not found

Node.js is unavailable to the current terminal. Install the required LTS release from the official download page.

  • Open the official Node.js download page in your browser.
  • Download the Windows installer for v24.21.0 LTS.
  • Run the downloaded installer.
  • Keep the default features selected in the setup wizard.
  • Complete the installation.
  • Close the existing terminal with the trash icon in its toolbar.
  • Press Ctrl+` to create a fresh PowerShell terminal.
  • Repeat the version command shown above.

Node.js Still Unavailable?

  • Close every Visual Studio Code window after installing Node.js.
  • Reopen Visual Studio Code through Windows search.
  • Run the version check from a fresh PowerShell terminal.

Help me diagnose why the required Node.js version is unavailable.

Create the local workspace

A workspace gives the editor plus terminal one shared project location. Creating it on your Desktop keeps the folder easy to find during this project.

  • Return to the PowerShell terminal from the version check.
  • Move to your Desktop by running this command:
cd ~/Desktop

What Does This Command Do?

The cd command changes PowerShell's current location. The ~/Desktop path points to the Desktop inside your Windows user profile.

  • Create the production-order-api folder by running this command:
mkdir production-order-api

What Does This Command Do?

The mkdir command creates a directory in the current location. Your source files plus installed dependencies will live inside production-order-api.

  • Confirm that PowerShell prints a directory entry named production-order-api.

Folder Already Exists?

Use the existing folder only if it is empty. Rename an older folder before repeating the creation command if it contains another project.

Help me resolve the local folder conflict.

  • Enter the new folder in PowerShell by running the commands below:
cd production-order-api
code .

What Do These Commands Do?

  • The first command moves PowerShell into production-order-api.
  • The second command opens the current folder as a Visual Studio Code workspace.
  • Confirm that the Explorer sidebar shows production-order-api as the open folder.

Workspace Did Not Open?

Use File in Visual Studio Code if the command-line launcher is unavailable. Select Open Folder from that menu.

Choose production-order-api from your Desktop. The Explorer sidebar should then show the folder name.

Help me open my project folder in Visual Studio Code.

Pin and install dependencies

The package.json file defines the runtime scripts plus exact package versions. The tsconfig.json file configures type checking for Node.js native TypeScript execution.

Both configuration files use JSON. Exact pins give future installs the same dependency baseline.

  • Select the new-file icon at the top of the Explorer sidebar.
  • Enter package.json as the file name.
  • Paste this configuration into the editor:
{
  "name": "production-order-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/server.ts",
    "check": "tsc -p tsconfig.json",
    "test": "node --test"
  },
  "dependencies": {
    "fastify": "5.12.5"
  },
  "devDependencies": {
    "@types/node": "24.19.1",
    "typescript": "7.0.2"
  }
}

What Does This Configuration Do?

  • The type setting enables ECMAScript modules for the project.
  • The scripts object defines the project's start, type-checking, and test commands.
  • The dependencies object pins Fastify for application runtime behavior.
  • The devDependencies object pins the compiler plus Node.js type definitions.
  • Press Ctrl+S to save package.json.
  • Confirm that package.json appears in the Explorer sidebar.

Seeing JSON Problems?

Check each quote plus comma against the reference. A missing comma can prevent npm from reading the entire manifest.

Help me compare my package manifest with the required JSON.

  • Select the new-file icon at the top of the Explorer sidebar.
  • Enter tsconfig.json as the file name.
  • Paste this configuration into the editor:
{
  "compilerOptions": {
    "noEmit": true,
    "target": "ESNext",
    "module": "NodeNext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "strict": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}

What Does This Configuration Do?

  • The noEmit setting makes TypeScript check code without generating JavaScript files.
  • The module settings align local imports with Node.js native TypeScript execution.
  • The strict setting enables stronger type checks.
  • The include patterns cover source files plus test files.
  • Press Ctrl+S to save tsconfig.json.
  • Confirm that tsconfig.json appears beside package.json in the Explorer sidebar.

Configuration File Not Listed?

Confirm that the file is saved inside production-order-api. Check that its name ends with .json only once.

Help me locate or rename my TypeScript configuration file.

✔️ Awesome, I've got everything!

Great. Double check that both configuration files are saved inside production-order-api.

ⓧ I'd like to double check the full code

Compare your complete package.json with this reference.

{
  "name": "production-order-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/server.ts",
    "check": "tsc -p tsconfig.json",
    "test": "node --test"
  },
  "dependencies": {
    "fastify": "5.12.5"
  },
  "devDependencies": {
    "@types/node": "24.19.1",
    "typescript": "7.0.2"
  }
}

What Should Match?

Every script name plus dependency version should match this file exactly. Exact pins keep later installs consistent.

Compare your complete tsconfig.json with this reference.

{
  "compilerOptions": {
    "noEmit": true,
    "target": "ESNext",
    "module": "NodeNext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "strict": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}

What Should Match?

Every compiler option plus include pattern should match this file exactly. These settings let the runtime execute TypeScript while the compiler checks it separately.

The npm installer reads package.json to download the pinned packages. It also records the resolved dependency tree in package-lock.json.

  • Press Ctrl+` to show the integrated terminal in the production-order-api workspace.
  • Confirm that the PowerShell prompt ends with production-order-api.

Before you run this, picture the two workspace items you expect npm to add.

  • Install the pinned project dependencies by running this command:
npm install

What Does This Command Do?

The command installs the dependency versions declared in package.json. It creates node_modules for installed packages.

It also creates package-lock.json to preserve the resolved dependency tree. Later installs can reuse that exact resolution.

The terminal should return to the PowerShell prompt without an installation error. The Explorer sidebar should now contain the installed dependency folder plus the lockfile.

  • Confirm that node_modules appears in the Explorer sidebar.
  • Confirm that package-lock.json appears beside your configuration files.

Dependency Installation Failed?

  • Confirm that the terminal prompt points to production-order-api.
  • Compare package.json with the full reference above.
  • Check that your internet connection can reach the npm package registry.

Help me diagnose the failed dependency installation.

Your reproducible baseline is ready. Every install now starts from the same pinned package versions.

Next, you'll run the smallest Order API. Its first requests will expose an unsafe input assumption.

Ship the Smallest Order API

Your pinned workspace gives the Order API a stable runtime with a fixed dependency set. Now you can turn that base into a reachable endpoint.

Fastify will serve the route. Structured logging will make each request visible in the terminal.

In this step, get ready to:
  • Create the local Order API files.
  • Send a valid keyboard order from PowerShell.
  • Probe the route with a zero quantity to expose the missing contract.

Why Fastify for this API?

Express is a familiar option for Node.js APIs. Fastify gives this project structured logging through its core setup.

Its application factory also keeps route setup in one place. That keeps this baseline focused on observable behavior.

Build the naive order route

An application factory assembles the API so other code can start it. A separate listener owns the local network address.

  • In VS Code's file sidebar, create a folder named src inside production-order-api.

You should see src listed beside package.json and tsconfig.json.

  • Create app.ts inside the src folder.
  • Build the Fastify application factory by pasting this code into src/app.ts:
import fastify from 'fastify'

export function buildApp() {
  const app = fastify({ logger: true })

  app.post('/orders', async (request, reply) => {
    return reply.code(201).send(request.body)
  })

  return app
}

What does this code do?

  • The fastify import provides the application factory.
  • buildApp() creates a fresh application instance.
  • logger: true enables structured request logs.
  • POST /orders returns request.body with HTTP 201.
  • Save src/app.ts.
  • Confirm the file sidebar lists app.ts under src.
  • Check that the editor shows no red syntax underlines in src/app.ts.

Seeing a problem in app.ts?

  • Check that app.ts is inside the src folder.
  • Compare every quote with the code block above.

Help me troubleshoot my application factory.

✔️ Awesome, I've got app.ts

Good progress. src/app.ts now exposes the smallest possible order route.

ⓧ I'd like to double check the full code

Compare your complete src/app.ts file with this reference.

import fastify from 'fastify'

export function buildApp() {
  const app = fastify({ logger: true })

  app.post('/orders', async (request, reply) => {
    return reply.code(201).send(request.body)
  })

  return app
}

The route needs a listener before PowerShell can reach it. The listener binds the application to the local address defined by this project.

  • Create server.ts inside the src folder.
  • Add the local server listener by pasting this code into src/server.ts:
import { buildApp } from './app.ts'

const app = buildApp()

try {
  await app.listen({ port: 3000, host: '127.0.0.1' })
} catch (error) {
  app.log.error(error)
  process.exit(1)
}

How does the listener work?

  • buildApp imports the application factory from app.ts.
  • app.listen binds the server to 127.0.0.1 on port 3000.
  • The catch block logs startup failures before stopping the process.
  • Save src/server.ts.
  • Confirm the file sidebar lists server.ts under src.
  • Check that the import ends with ./app.ts.

Seeing a problem in server.ts?

  • Confirm that app.ts and server.ts are both inside src.
  • Check that the imported name matches buildApp exactly.

Help me fix my local server listener.

✔️ Awesome, I've got server.ts

Your listener is ready to expose the Order API on the local address.

ⓧ I'd like to double check the full code

Compare your complete src/server.ts file with this reference.

import { buildApp } from './app.ts'

const app = buildApp()

try {
  await app.listen({ port: 3000, host: '127.0.0.1' })
} catch (error) {
  app.log.error(error)
  process.exit(1)
}
Send a valid keyboard order

The HTTP listener makes the route reachable from another local process. Starting the server keeps the first terminal attached to that process.

  • Start the Order API from the existing PowerShell terminal by running:
npm start

What does this command do?

The npm command runs the start script from package.json. That script launches src/server.ts.

  • Leave the first terminal running.
  • Confirm that the terminal remains occupied by the server process.

You'll see structured JSON output showing that the server is listening on the local address.

Server stops immediately?

  • Confirm that both TypeScript files are saved.
  • Check that src/server.ts imports ./app.ts.

Help me diagnose why the Fastify server stops during startup.

  • Create a second PowerShell terminal with the plus button in VS Code's terminal panel.
  • Send a valid keyboard order from the second terminal by running:
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3000/orders -ContentType 'application/json' -Body '{"productId":"keyboard","quantity":1}'

What does this request test?

  • Invoke-RestMethod sends a POST request to the local order route.
  • The request body contains the product keyboard with a quantity of 1.
  • The route returns the submitted body with HTTP 201.

You'll see productId set to keyboard with quantity set to 1. The server terminal prints a matching structured request log.

You now have a live round trip from PowerShell to the Fastify route.

Request cannot reach the API?

  • Confirm that the first terminal still has the server running.
  • Check that the request uses http://127.0.0.1:3000/orders exactly.

Help me connect my PowerShell request to the local Order API.

Expose the missing contract

A quantity of zero gives you a sharp contract test because an order cannot contain zero items. The response reveals whether the current transport boundary protects that rule.

Before you send the request, do you think the current route can distinguish this body from the valid order?

  • Probe the route with a zero-quantity order by running:
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3000/orders -ContentType 'application/json' -Body '{"productId":"keyboard","quantity":0}'

What does this request test?

This request changes quantity from 1 to 0. Every other part of the request remains the same.

You'll see productId set to keyboard with quantity set to 0. The matching server log records HTTP 201.

This shortfall is intentional

The API has accepted business-invalid data because the route returns every submitted body unchanged. No request contract checks the quantity.

This visible failure gives the next step a precise behavior to change.

  • Compare the two PowerShell responses.
  • Confirm that only the submitted quantity changes between them.
  • Confirm that the structured logs contain a request ID for each order request.

Missing the zero-quantity response?

  • Confirm that the server is still running in the first terminal.
  • Check that the second request body contains "quantity":0.

Help me verify the zero-quantity request against my naive order route.

You have proved that the smallest route is reachable while its boundary still trusts every request. Next, you'll turn the order assumptions into an enforceable API contract.

Enforce the Order API Contract

Last step, your PowerShell requests proved that the Fastify route accepts whatever body it receives. A zero quantity reached the handler with a 201 response.

This step enforces an API contract with JSON Schema. The contract rejects invalid bodies before domain logic runs.

In this step, get ready to:
  • Define the required shape of an order request.
  • Sanitize validation failures plus unexpected failures.
  • Prove each request receives the expected HTTP status code.
Define the order request schema

A request schema describes the exact data your route accepts. Fastify checks each incoming body against this schema before calling the handler.

  • Select src/app.ts from the file list in VS Code.
  • Locate the fastify import at the top of the file.
  • Add the order-body schema directly below the import by pasting this code:
const createOrderBodySchema = {
  type: 'object',
  additionalProperties: false,
  required: ['productId', 'quantity'],
  properties: {
    productId: { type: 'string', minLength: 1 },
    quantity: { type: 'integer', minimum: 1 }
  }
} as const

What does this schema enforce?

  • The object rule requires the request body to contain a JSON object.
  • The additionalProperties rule rejects fields outside the contract.
  • The required list makes both order fields mandatory.
  • The productId rule requires at least one character.
  • The quantity rule accepts integers starting at 1.
  • Save src/app.ts.
  • Check the new schema with the project type checker by running:
npm run check

What does this check prove?

The check script asks TypeScript to inspect the project without generating output files. A clean result confirms the schema has valid syntax.

You should return to the PowerShell prompt without type errors.

Seeing a schema error?

Check each comma plus closing brace against the snippet. Confirm that as const appears after the final brace.

Ask for help with the exact checker output: Why is my order schema failing the TypeScript check?

The schema guards requests only after the route references it. The route configuration is where the contract becomes active.

  • Find the existing app.post('/orders' route in src/app.ts.
  • Replace the complete route with this schema-aware version:
app.post('/orders', { schema: { body: createOrderBodySchema } }, async (request, reply) => {
  return reply.code(201).send(request.body)
})

How does the route use the contract?

The route passes createOrderBodySchema as its body schema. Fastify validates the body before the handler returns a created response.

  • Save src/app.ts.
  • Check the route configuration by running:
npm run check

What does this second check prove?

The checker confirms that the route can reference createOrderBodySchema. This catches a misspelled schema name before the server starts.

You should return to the prompt without errors again.

Is the schema name unresolved?

Confirm that the route uses createOrderBodySchema with the same capitalization as the constant above it.

Get help with the route wiring: Why can my order route not find createOrderBodySchema?

Add the root error policy

Validation failures need a stable response that clients can handle. Unexpected failures need a generic response that keeps internal details private.

  • Find the const app = fastify({ logger: true }) line in src/app.ts.
  • Add the root error handler directly below that line by pasting:
app.setErrorHandler((error, request, reply) => {
  if (error.validation) {
    return reply.status(400).send({ error: { code: 'VALIDATION_ERROR', message: 'Request body is invalid' } })
  }
  request.log.error({ err: error }, 'unhandled error')
  return reply.status(500).send({ error: { code: 'INTERNAL_ERROR', message: 'Internal Server Error' } })
})

How does the error policy work?

  • The error.validation check identifies request-schema failures.
  • Known validation failures receive status 400 with a stable error code.
  • Unexpected errors are written to the structured server log.
  • Clients receive a sanitized status 500 response without internal details.
  • Save src/app.ts.
  • Check the complete error policy by running:
npm run check

What does the final static check prove?

The checker verifies that the handler uses valid request plus reply APIs. It also confirms that the complete file remains type-safe.

Good work. The checker should return to the prompt without reporting an error.

Does the error handler fail the check?

Confirm that app.setErrorHandler sits inside buildApp(). Check that it appears after the app constant.

Ask for help with the reported line: Why is my Fastify error handler failing the TypeScript check?

✔️ Awesome, I've got everything!

Your src/app.ts now connects request validation to a safe root error policy.

ⓧ I'd like to double check the full code

Compare your complete src/app.ts with this version.

import fastify from 'fastify'

const createOrderBodySchema = {
  type: 'object',
  additionalProperties: false,
  required: ['productId', 'quantity'],
  properties: {
    productId: { type: 'string', minLength: 1 },
    quantity: { type: 'integer', minimum: 1 }
  }
} as const

export function buildApp() {
  const app = fastify({ logger: true })

  app.setErrorHandler((error, request, reply) => {
    if (error.validation) {
      return reply.status(400).send({ error: { code: 'VALIDATION_ERROR', message: 'Request body is invalid' } })
    }
    request.log.error({ err: error }, 'unhandled error')
    return reply.status(500).send({ error: { code: 'INTERNAL_ERROR', message: 'Internal Server Error' } })
  })

  app.post('/orders', { schema: { body: createOrderBodySchema } }, async (request, reply) => {
    return reply.code(201).send(request.body)
  })

  return app
}
Verify both contract paths

The static checks prove that the code is valid. Two live requests now prove that the contract rejects bad input while preserving valid behavior.

  • Return to the PowerShell terminal running the previous server.
  • Press Ctrl+C if the server process is still active.
  • Restart the server with the updated contract by running:
npm start

What does this command start?

The start script loads src/server.ts with the updated application code. The process keeps this terminal busy while the server listens locally.

  • Switch back to the second PowerShell terminal from the previous step.

Before you send the zero-quantity order, do you expect the handler to accept it again?

  • Test the invalid contract path by running this request:
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3000/orders -ContentType 'application/json' -Body '{"productId":"keyboard","quantity":0}'

What should this request prove?

The value 0 violates the schema minimum. Fastify sends the validation failure to your root error handler before the order handler runs.

PowerShell should report HTTP 400. The response uses VALIDATION_ERROR with the message Request body is invalid.

Is the zero quantity still accepted?

The old server process may still be running. Stop it from its terminal before starting the updated server again.

Confirm that the route includes schema: { body: createOrderBodySchema }.

Get help tracing the request: Why does my Order API still accept quantity zero?

The unsafe path is closed. Invalid order data now stops at the API boundary.

Before you send a valid order, do you think the new schema preserves the original created response?

  • Test the valid contract path by running this request:
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3000/orders -ContentType 'application/json' -Body '{"productId":"keyboard","quantity":1}'

What should this request prove?

The request contains both required fields. Its quantity satisfies the minimum value.

You should see a response containing keyboard with quantity 1.

  • Return to the server terminal to confirm the request completed with status 201.

That is the contract working on both paths. Bad input receives a predictable error while valid input still creates an order.

Does the valid order fail?

Check that productId is spelled exactly as the schema requires. Confirm that quantity is the number 1.

Ask for help with the response: Why is my valid keyboard order failing schema validation?

Your Order API now has a trustworthy boundary. Next, you will separate its transport plus business responsibilities into a modular monolith.

Refactor into a Modular Monolith

Your API now blocks invalid request bodies at the edge. However, the route still owns transport behavior without a dedicated place for stock policy or accepted orders.

A modular monolith keeps one process while separating responsibilities through explicit boundaries. This structure keeps the application simple to run while giving each module a focused job.

In this step, get ready to:
  • Build explicit interfaces for the domain modules.
  • Move HTTP transport into route plugins.
  • Verify liveness, accepted orders, stock conflicts, and structured logs.
Separate domain responsibilities

Explicit TypeScript interfaces define what each module can do. The implementation behind each interface can change without forcing every caller to change.

Why a modular monolith?

A modular monolith keeps order creation inside one Fastify process. Clear module interfaces preserve a future path to separate services if ownership or scaling requirements change.

This approach keeps your attention on contracts and boundaries. It avoids adding network calls or multiple deployments before the application needs them.

  • In the VS Code Explorer sidebar, create a `domain` folder inside `src`.
  • Create `errors.ts` inside `src/domain`.
  • Define the shared domain failure type by pasting this code into `src/domain/errors.ts`:
export class DomainError extends Error {
  readonly code: string
  readonly statusCode: number

  constructor(code: string, message: string, statusCode: number) {
    super(message)
    this.name = 'DomainError'
    this.code = code
    this.statusCode = statusCode
  }
}

What does this code do?

  • `DomainError` gives expected business failures a stable machine-readable `code`.
  • `statusCode` tells the global error policy which HTTP status represents the failure.
  • The class extends the built-in `Error` type so normal error handling can catch it.
  • Save `src/domain/errors.ts`.
  • Check the new domain error type by running:
npm run check

What does this check prove?

The type checker confirms that `DomainError` is valid under the strict settings in `tsconfig.json`. You should return to the PowerShell prompt without a type error.

Does the domain error fail the check?

Confirm that `errors.ts` sits inside `src/domain`. Compare every property name with the code above.

Ask for help with the exact diagnostic if the check still fails: Help me fix the TypeScript error in my DomainError class.

  • In the VS Code Explorer sidebar, create an `inventory` folder inside `src/modules`.
  • Create `service.ts` inside `src/modules/inventory`.
  • Add the inventory boundary and its private stock map by pasting this code into `src/modules/inventory/service.ts`:
import { DomainError } from '../../domain/errors.ts'

export interface InventoryService {
  reserve(productId: string, quantity: number): void
}

export function createInventoryService(): InventoryService {
  const stock = new Map<string, number>([['keyboard', 2], ['mouse', 5]])
  return { reserve(productId, quantity) {
    const available = stock.get(productId)
    if (available === undefined) throw new DomainError('PRODUCT_NOT_FOUND', 'Product does not exist', 404)
    if (available < quantity) throw new DomainError('OUT_OF_STOCK', 'Not enough stock is available', 409)
    stock.set(productId, available - quantity)
  } }
}

How does inventory stay isolated?

  • `InventoryService` exposes only the `reserve()` capability needed by order policy.
  • The private `stock` map starts with two keyboards and five mice.
  • Missing products become `PRODUCT_NOT_FOUND` domain failures.
  • Insufficient stock becomes an `OUT_OF_STOCK` domain failure.
  • Save `src/modules/inventory/service.ts`.
  • Verify the inventory boundary by running:
npm run check

What does this check prove?

The type checker confirms that the inventory implementation satisfies `InventoryService`. It also verifies the import path to `DomainError`.

Does the inventory service fail the check?

Confirm that `service.ts` sits inside `src/modules/inventory`. Check that its relative import reaches `src/domain/errors.ts`.

Share the diagnostic without changing the interface names: Help me debug the inventory service type check.

  • In the VS Code Explorer sidebar, create an `orders` folder inside `src/modules`.
  • Create `repository.ts` inside `src/modules/orders`.
  • Add accepted-order storage by pasting this code into `src/modules/orders/repository.ts`:
export interface CreateOrderInput { productId: string; quantity: number }
export interface Order extends CreateOrderInput { id: string; status: 'accepted' }
export interface OrderRepository { add(input: CreateOrderInput): Order; list(): Order[] }
export function createOrderRepository(): OrderRepository {
  const orders: Order[] = []
  return { add(input) { const order: Order = { id: `order-${orders.length + 1}`, productId: input.productId, quantity: input.quantity, status: 'accepted' }; orders.push(order); return order }, list() { return [...orders] } }
}

How does the repository own storage?

  • `CreateOrderInput` describes the data needed to create an order.
  • `Order` adds a generated ID plus the accepted status.
  • `OrderRepository` exposes storage operations without exposing the private `orders` array.
  • `list()` returns a copy so callers cannot mutate the stored array directly.
  • Save `src/modules/orders/repository.ts`.
  • Verify the repository contract by running:
npm run check

What does this check prove?

The type checker confirms that every repository operation returns the promised order shape. You should return to the prompt without a diagnostic.

Does the repository fail the check?

Compare the `OrderRepository` method signatures with the returned object. Check the spelling of the literal status value.

Use the diagnostic to focus the fix: Help me debug my OrderRepository types.

  • Create `service.ts` inside `src/modules/orders`.
  • Add the order policy coordinator by pasting this code into `src/modules/orders/service.ts`:
import type { InventoryService } from '../inventory/service.ts'
import type { CreateOrderInput, Order, OrderRepository } from './repository.ts'
export interface OrderService { createOrder(input: CreateOrderInput): Order; listOrders(): Order[] }
export function createOrderService(inventory: InventoryService, orders: OrderRepository): OrderService {
  return { createOrder(input) { inventory.reserve(input.productId, input.quantity); return orders.add(input) }, listOrders() { return orders.list() } }
}

How does order policy coordinate modules?

  • `OrderService` depends on interfaces instead of private storage details.
  • `createOrder()` reserves stock before saving an accepted order.
  • A failed reservation prevents the repository from storing an impossible order.
  • `listOrders()` delegates order retrieval to the repository boundary.
  • Save `src/modules/orders/service.ts`.
  • Verify all four domain files together by running:
npm run check

What does this check prove?

The type checker confirms that the order service can coordinate the inventory and repository interfaces. A clean prompt proves these domain boundaries fit together.

Does the order service fail the check?

Confirm that both imports use the exact filenames under `src/modules`. Check that `createOrder()` reserves inventory before returning `orders.add(input)`.

Ask for a comparison of the connected interfaces: Help me debug the OrderService boundary.

Strong progress. Stock policy, accepted-order storage, and order orchestration now have separate owners with explicit interfaces.

Move transport into route plugins

The domain modules contain business behavior without HTTP details. Route plugins now translate requests into service calls while keeping transport separate from policy.

  • Create `routes.ts` inside `src/modules/orders`.
  • Move order transport into the route plugin by pasting this code into `src/modules/orders/routes.ts`:
import type { FastifyInstance, FastifyPluginOptions } from 'fastify'
import type { CreateOrderInput } from './repository.ts'
import type { OrderService } from './service.ts'
interface OrderRoutesOptions extends FastifyPluginOptions { orderService: OrderService }
const createOrderBodySchema = { type: 'object', additionalProperties: false, required: ['productId', 'quantity'], properties: { productId: { type: 'string', minLength: 1 }, quantity: { type: 'integer', minimum: 1 } } } as const
export async function orderRoutes(app: FastifyInstance, options: OrderRoutesOptions): Promise<void> {
  app.post<{ Body: CreateOrderInput }>('/orders', { schema: { body: createOrderBodySchema } }, async (request, reply) => reply.code(201).send(options.orderService.createOrder(request.body)))
  app.get('/orders', async () => options.orderService.listOrders())
}

What belongs in the order route?

  • The JSON Schema remains at the HTTP boundary so invalid input stops before domain logic runs.
  • `OrderRoutesOptions` requires callers to supply an `OrderService`.
  • The POST route delegates creation policy to `createOrder()`.
  • The GET route exposes accepted orders through `listOrders()`.
  • Save `src/modules/orders/routes.ts`.
  • Verify the order transport types by running:
npm run check

What does this check prove?

The type checker confirms that request bodies match `CreateOrderInput`. It also confirms that the plugin receives a compatible `OrderService`.

Does the order route fail the check?

Check that `routes.ts` sits beside `repository.ts` and `service.ts`. Confirm that the imports include their `.ts` extensions.

Ask for help interpreting the route generic or plugin options diagnostic: Help me fix the order route types.

  • In the VS Code Explorer sidebar, create a `platform` folder inside `src/modules`.
  • Create `routes.ts` inside `src/modules/platform`.
  • Add the process liveness route by pasting this code into `src/modules/platform/routes.ts`:
import type { FastifyInstance } from 'fastify'
export async function platformRoutes(app: FastifyInstance): Promise<void> { app.get('/healthz/live', async () => ({ status: 'ok' })) }

What does liveness prove?

`/healthz/live` confirms that the process can receive a request and return a response. It stays shallow because this version has no required external dependency.

  • Save `src/modules/platform/routes.ts`.
  • Verify the platform route by running:
npm run check

What does this check prove?

The type checker confirms that `platformRoutes` is a valid asynchronous route plugin. The route can now be registered from the application root.

Does the platform route fail the check?

Confirm that the file path is `src/modules/platform/routes.ts`. Check the `FastifyInstance` type import and the liveness URL.

Ask for focused help with the file: Help me debug the platform route.

  • Return to `src/app.ts` in the editor.
  • Replace its current contents with this composition root:
import fastify from 'fastify'
import { DomainError } from './domain/errors.ts'
import { createInventoryService } from './modules/inventory/service.ts'
import { createOrderRepository } from './modules/orders/repository.ts'
import { orderRoutes } from './modules/orders/routes.ts'
import { createOrderService } from './modules/orders/service.ts'
import { platformRoutes } from './modules/platform/routes.ts'
interface BuildAppOptions { logger?: boolean }
export function buildApp(options: BuildAppOptions = {}) {
  const app = fastify({ logger: options.logger ?? true })
  const inventoryService = createInventoryService()
  const orderRepository = createOrderRepository()
  const orderService = createOrderService(inventoryService, orderRepository)
  app.setErrorHandler((error, request, reply) => {
    if (error.validation) return reply.status(400).send({ error: { code: 'VALIDATION_ERROR', message: 'Request body is invalid' } })
    if (error instanceof DomainError) return reply.status(error.statusCode).send({ error: { code: error.code, message: error.message } })
    request.log.error({ err: error }, 'unhandled error')
    return reply.status(500).send({ error: { code: 'INTERNAL_ERROR', message: 'Internal Server Error' } })
  })
  app.register(platformRoutes)
  app.register(orderRoutes, { orderService })
  return app
}

Why is this the composition root?

  • `buildApp()` creates each concrete service in one place.
  • The same inventory instance is supplied to the order service.
  • Plugin registration connects transport to the required service boundary.
  • The global error handler converts validation failures and domain failures into stable client responses.
  • Unexpected failures remain hidden behind the sanitized internal error contract.
  • Save `src/app.ts`.
  • Verify the complete module graph by running:
npm run check

What does this check prove?

The type checker follows every import from the composition root through the route and domain modules. A clean prompt confirms that the application graph is connected correctly.

Does the composition root fail the check?

Compare every import path with the Explorer file tree. Confirm that `orderRoutes` receives `{ orderService }` during registration.

Use the first diagnostic as the starting point: Help me debug the modular app composition.

Rebuild and test the composition root

The application now has boundaries worth exercising together. A fresh process proves that startup, routing, domain policy, storage, and global error handling all cooperate at runtime.

  • Return to `src/server.ts` in the editor.
  • Keep network startup isolated by replacing the file with this code:
import { buildApp } from './app.ts'
const app = buildApp()
try { await app.listen({ port: 3000, host: '127.0.0.1' }) } catch (error) { app.log.error(error); process.exit(1) }

Why keep startup separate?

`server.ts` builds the application before binding it to the local network address. Keeping startup outside `buildApp()` lets later tests exercise the same application without opening a port.

  • Save `src/server.ts`.
  • Stop the earlier server from its PowerShell terminal if it is still running.
  • Run one final type check before starting the refactored application:
npm run check

What does the final check cover?

This command checks every TypeScript file included by `tsconfig.json`. You should return to the PowerShell prompt without an error.

Before you start the server, do you expect the refactor to preserve the existing local address and structured logging behavior?

  • Start the modular API by running:
npm start

What does this command do?

The start script runs `src/server.ts`. The process listens on `127.0.0.1:3000` while structured logging remains enabled.

You should see a structured startup log in the terminal. Keep this terminal running so it can serve the verification requests.

Does the server fail to start?

Confirm that the earlier server process has stopped before starting this one. A second process cannot use the same local port.

If startup fails for another reason, use the first log entry to trace it: Help me debug why the modular API does not start.

  • Add a second PowerShell terminal in the existing VS Code terminal panel.
  • Check process liveness from the second terminal by running:
Invoke-RestMethod -Uri http://127.0.0.1:3000/healthz/live

What does this request check?

This GET request calls the platform module without changing application state. A successful response proves that the process can answer through its registered liveness route.

You should see `status` with the value `ok`. The server terminal should also show a structured request log with a request ID.

  • Create one accepted keyboard order from the second terminal by running:
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3000/orders -ContentType 'application/json' -Body '{"productId":"keyboard","quantity":1}'

What path does this request follow?

The order route validates the body before calling `OrderService`. Inventory reserves one keyboard before the repository stores the accepted order.

You should see `order-1` with product `keyboard`, quantity `1`, and status `accepted`. This proves that IDs and storage now belong to the repository.

Before you send the next order, what should happen when the request asks for three keyboards after one of the original two has already been reserved?

  • Test the inventory policy with an oversized order by running:
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3000/orders -ContentType 'application/json' -Body '{"productId":"keyboard","quantity":3}'

What does the conflict prove?

Inventory detects that the requested quantity exceeds available stock. `DomainError` carries `OUT_OF_STOCK` to the global error handler for an HTTP 409 conflict response.

The repository never receives the rejected order. This preserves the boundary between stock policy and accepted-order storage.

PowerShell should report HTTP 409 with `OUT_OF_STOCK`. In the server terminal, you should still see structured request logs with request IDs for the liveness and order requests.

Do the requests show a different result?

Restart the server if the accepted order does not begin with `order-1`. This project stores orders and stock in memory for the lifetime of the process.

Check that the first order uses quantity `1` and the conflict request uses quantity `3`. Confirm that both requests use product `keyboard`.

Ask for help using the response plus the matching request log: Help me trace the unexpected Order API response.

That refactor has paid off. Your single local process now enforces stock policy through clear module boundaries while preserving validated input and structured logs.

✔️ Awesome, I've got everything!

Great. Double check that every source file is saved and the liveness, accepted order, and stock conflict checks all produced the expected results.

ⓧ I'd like to double check the full code

Compare your `src/app.ts` file with this complete version.

import fastify from 'fastify'
import { DomainError } from './domain/errors.ts'
import { createInventoryService } from './modules/inventory/service.ts'
import { createOrderRepository } from './modules/orders/repository.ts'
import { orderRoutes } from './modules/orders/routes.ts'
import { createOrderService } from './modules/orders/service.ts'
import { platformRoutes } from './modules/platform/routes.ts'
interface BuildAppOptions { logger?: boolean }
export function buildApp(options: BuildAppOptions = {}) {
  const app = fastify({ logger: options.logger ?? true })
  const inventoryService = createInventoryService()
  const orderRepository = createOrderRepository()
  const orderService = createOrderService(inventoryService, orderRepository)
  app.setErrorHandler((error, request, reply) => {
    if (error.validation) return reply.status(400).send({ error: { code: 'VALIDATION_ERROR', message: 'Request body is invalid' } })
    if (error instanceof DomainError) return reply.status(error.statusCode).send({ error: { code: error.code, message: error.message } })
    request.log.error({ err: error }, 'unhandled error')
    return reply.status(500).send({ error: { code: 'INTERNAL_ERROR', message: 'Internal Server Error' } })
  })
  app.register(platformRoutes)
  app.register(orderRoutes, { orderService })
  return app
}

Compare your `src/domain/errors.ts` file with this complete version.

export class DomainError extends Error {
  readonly code: string
  readonly statusCode: number

  constructor(code: string, message: string, statusCode: number) {
    super(message)
    this.name = 'DomainError'
    this.code = code
    this.statusCode = statusCode
  }
}

Compare your `src/modules/inventory/service.ts` file with this complete version.

import { DomainError } from '../../domain/errors.ts'

export interface InventoryService {
  reserve(productId: string, quantity: number): void
}

export function createInventoryService(): InventoryService {
  const stock = new Map<string, number>([['keyboard', 2], ['mouse', 5]])
  return { reserve(productId, quantity) {
    const available = stock.get(productId)
    if (available === undefined) throw new DomainError('PRODUCT_NOT_FOUND', 'Product does not exist', 404)
    if (available < quantity) throw new DomainError('OUT_OF_STOCK', 'Not enough stock is available', 409)
    stock.set(productId, available - quantity)
  } }
}

Compare your `src/modules/orders/repository.ts` file with this complete version.

export interface CreateOrderInput { productId: string; quantity: number }
export interface Order extends CreateOrderInput { id: string; status: 'accepted' }
export interface OrderRepository { add(input: CreateOrderInput): Order; list(): Order[] }
export function createOrderRepository(): OrderRepository {
  const orders: Order[] = []
  return { add(input) { const order: Order = { id: `order-${orders.length + 1}`, productId: input.productId, quantity: input.quantity, status: 'accepted' }; orders.push(order); return order }, list() { return [...orders] } }
}

Compare your `src/modules/orders/routes.ts` file with this complete version.

import type { FastifyInstance, FastifyPluginOptions } from 'fastify'
import type { CreateOrderInput } from './repository.ts'
import type { OrderService } from './service.ts'
interface OrderRoutesOptions extends FastifyPluginOptions { orderService: OrderService }
const createOrderBodySchema = { type: 'object', additionalProperties: false, required: ['productId', 'quantity'], properties: { productId: { type: 'string', minLength: 1 }, quantity: { type: 'integer', minimum: 1 } } } as const
export async function orderRoutes(app: FastifyInstance, options: OrderRoutesOptions): Promise<void> {
  app.post<{ Body: CreateOrderInput }>('/orders', { schema: { body: createOrderBodySchema } }, async (request, reply) => reply.code(201).send(options.orderService.createOrder(request.body)))
  app.get('/orders', async () => options.orderService.listOrders())
}

Compare your `src/modules/orders/service.ts` file with this complete version.

import type { InventoryService } from '../inventory/service.ts'
import type { CreateOrderInput, Order, OrderRepository } from './repository.ts'
export interface OrderService { createOrder(input: CreateOrderInput): Order; listOrders(): Order[] }
export function createOrderService(inventory: InventoryService, orders: OrderRepository): OrderService {
  return { createOrder(input) { inventory.reserve(input.productId, input.quantity); return orders.add(input) }, listOrders() { return orders.list() } }
}

Compare your `src/modules/platform/routes.ts` file with this complete version.

import type { FastifyInstance } from 'fastify'
export async function platformRoutes(app: FastifyInstance): Promise<void> { app.get('/healthz/live', async () => ({ status: 'ok' })) }

Compare your `src/server.ts` file with this complete version.

import { buildApp } from './app.ts'
const app = buildApp()
try { await app.listen({ port: 3000, host: '127.0.0.1' }) } catch (error) { app.log.error(error); process.exit(1) }

Your application now has production-minded boundaries with runtime evidence that they work. Next, you will turn those behaviors into repeatable contract tests and record why this architecture fits the requirements.

Prove Contracts and Record the Decision

Your modular monolith now keeps each responsibility behind a clear boundary. Manual requests have shown that those boundaries work.

Manual success is not repeatable evidence. A future change could break an API contract without an obvious warning.

Automated tests preserve the behavior. An architecture decision record preserves the reasoning behind the design.

In this step, get ready to:
  • Test the API contracts through HTTP injection.
  • Record the modular monolith decision and its trade-offs.
  • Run the complete test suite and type checker.
Prove behavior with HTTP injection

Fastify HTTP injection runs registered plugins without binding a network port. The Node.js test runner discovers the tests in test/app.test.ts.

Why injection instead of network requests?

Injection exercises the application request lifecycle in memory. This keeps each test independent from the manually running server.

Each test creates a fresh application instance. That prevents stock changes or accepted orders from leaking into another test.

  • In the VS Code Explorer sidebar, select the file creation icon.
  • Enter test/app.test.ts as the file path.

You'll see the new test folder with app.test.ts inside it.

  • Add the imports and liveness contract to test/app.test.ts by pasting this code:
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildApp } from '../src/app.ts'

test('reports process liveness', async (t) => {
  const app = buildApp({ logger: false })
  t.after(() => app.close())

  const response = await app.inject({
    method: 'GET',
    url: '/healthz/live'
  })

  assert.equal(response.statusCode, 200)
  assert.deepEqual(response.json(), { status: 'ok' })
})

What does this test prove?

  • The imports load strict assertions, the test runner, and the application factory.
  • The fresh application disables request logging so the test output stays focused.
  • HTTP injection sends a request to /healthz/live without opening a port.
  • The assertions prove that liveness returns HTTP 200 with status: ok.
  • Save test/app.test.ts.
  • Run the current test suite in the request PowerShell terminal from earlier by running:
npm test

What does this command check?

The test script starts the Node.js test runner. At this point it discovers the liveness contract in test/app.test.ts.

You'll see one passing test for process liveness. That's your first repeatable API contract in place.

Does the liveness test fail?

  • Check that app.test.ts is inside the test folder.
  • Check that the import path points to ../src/app.ts.
  • Compare the injected URL with /healthz/live in the platform route.

Help me diagnose the failing liveness injection test.

  • Add the validation contract below the liveness test by pasting this code:
test('rejects a zero quantity before domain logic runs', async (t) => {
  const app = buildApp({ logger: false })
  t.after(() => app.close())

  const response = await app.inject({
    method: 'POST',
    url: '/orders',
    payload: { productId: 'keyboard', quantity: 0 }
  })

  assert.equal(response.statusCode, 400)
  assert.equal(response.json().error.code, 'VALIDATION_ERROR')
})

How does this protect validation?

  • The payload deliberately sends a quantity of 0.
  • The first assertion proves that the schema rejects the request with HTTP 400.
  • The second assertion protects the stable VALIDATION_ERROR response contract.
  • Save test/app.test.ts.
  • Run both contracts by running:
npm test

What does this run add?

The runner now exercises liveness and invalid input as separate contracts. Each contract receives a fresh application instance.

You'll see two passing tests. The zero-quantity behavior is now protected against regression.

Does the validation test fail?

  • Check that the payload uses quantity: 0.
  • Check that the route schema sets the minimum quantity to 1.
  • Check that the global error handler maps validation failures to VALIDATION_ERROR.

Help me trace why the invalid quantity contract is failing.

  • Add the stock conflict contract below the validation test by pasting this code:
test('rejects an order that exceeds available stock', async (t) => {
  const app = buildApp({ logger: false })
  t.after(() => app.close())

  const response = await app.inject({
    method: 'POST',
    url: '/orders',
    payload: { productId: 'keyboard', quantity: 3 }
  })

  assert.equal(response.statusCode, 409)
  assert.equal(response.json().error.code, 'OUT_OF_STOCK')
})

How does this protect domain policy?

  • The request asks for three keyboards while the inventory contains two.
  • The status assertion protects the HTTP 409 conflict contract.
  • The error assertion proves that OUT_OF_STOCK crosses the module boundary without losing its domain meaning.
  • Save test/app.test.ts.
  • Run all current contracts by running:
npm test

What does the third test add?

The suite now reaches the inventory policy through the public order route. It proves that the domain conflict becomes the promised HTTP response.

You'll see three passing tests. The out-of-stock rule now has automated evidence.

Does the stock test fail?

  • Check that the request asks for three keyboards.
  • Check that the inventory starts with two keyboards.
  • Check that DomainError carries OUT_OF_STOCK with status code 409.

Help me trace the failing out-of-stock contract through the modules.

  • Add the accepted-order contract below the stock test by pasting this code:
test('creates and lists an accepted order', async (t) => {
  const app = buildApp({ logger: false })
  t.after(() => app.close())

  const createResponse = await app.inject({
    method: 'POST',
    url: '/orders',
    payload: { productId: 'keyboard', quantity: 1 }
  })

  assert.equal(createResponse.statusCode, 201)
  assert.deepEqual(createResponse.json(), {
    id: 'order-1',
    productId: 'keyboard',
    quantity: 1,
    status: 'accepted'
  })

  const listResponse = await app.inject({
    method: 'GET',
    url: '/orders'
  })

  assert.equal(listResponse.statusCode, 200)
  assert.equal(listResponse.json().length, 1)
})

How does this protect the happy path?

  • The first injection creates an order for one keyboard.
  • The response assertions protect the generated ID and accepted status.
  • The second injection uses the same application instance to list stored orders.
  • The final assertion proves that the repository contains the accepted order.
  • Save test/app.test.ts.
  • Run the four contracts by running:
npm test

What does the complete suite cover?

The suite now covers process liveness, edge validation, domain conflicts, and successful persistence. These contracts describe the API behavior from the caller's perspective.

You'll see four passing tests. Your most important API promises are now repeatable.

Does the accepted-order test fail?

  • Check that both injections use the same app instance.
  • Check that the create payload requests one keyboard.
  • Check that the expected order ID is order-1.

Help me diagnose the accepted-order creation and listing test.

✔️ Awesome, I've got everything!

Your test/app.test.ts file now contains four passing HTTP-injection contracts.

ⓧ I'd like to double check the full code

Here is the complete test/app.test.ts file for comparison.

import assert from 'node:assert/strict'
import test from 'node:test'
import { buildApp } from '../src/app.ts'

test('reports process liveness', async (t) => {
  const app = buildApp({ logger: false })
  t.after(() => app.close())

  const response = await app.inject({
    method: 'GET',
    url: '/healthz/live'
  })

  assert.equal(response.statusCode, 200)
  assert.deepEqual(response.json(), { status: 'ok' })
})

test('rejects a zero quantity before domain logic runs', async (t) => {
  const app = buildApp({ logger: false })
  t.after(() => app.close())

  const response = await app.inject({
    method: 'POST',
    url: '/orders',
    payload: { productId: 'keyboard', quantity: 0 }
  })

  assert.equal(response.statusCode, 400)
  assert.equal(response.json().error.code, 'VALIDATION_ERROR')
})

test('rejects an order that exceeds available stock', async (t) => {
  const app = buildApp({ logger: false })
  t.after(() => app.close())

  const response = await app.inject({
    method: 'POST',
    url: '/orders',
    payload: { productId: 'keyboard', quantity: 3 }
  })

  assert.equal(response.statusCode, 409)
  assert.equal(response.json().error.code, 'OUT_OF_STOCK')
})

test('creates and lists an accepted order', async (t) => {
  const app = buildApp({ logger: false })
  t.after(() => app.close())

  const createResponse = await app.inject({
    method: 'POST',
    url: '/orders',
    payload: { productId: 'keyboard', quantity: 1 }
  })

  assert.equal(createResponse.statusCode, 201)
  assert.deepEqual(createResponse.json(), {
    id: 'order-1',
    productId: 'keyboard',
    quantity: 1,
    status: 'accepted'
  })

  const listResponse = await app.inject({
    method: 'GET',
    url: '/orders'
  })

  assert.equal(listResponse.statusCode, 200)
  assert.equal(listResponse.json().length, 1)
})

What does this reference contain?

This reference combines the imports with all four contracts. Each test owns a fresh application lifecycle.

Record the architecture decision

Tests record what the application promises. The decision record captures why one process with explicit module interfaces fits the current requirements.

  • In the VS Code Explorer sidebar, select the file creation icon.
  • Enter docs/architecture-decision.md as the file path.

You'll see the new docs folder with architecture-decision.md inside it.

  • Record ADR 001 in docs/architecture-decision.md by pasting this content:
# ADR 001: Start with a modular monolith

Status: Accepted

## Context

The first release needs one team, one local deployment unit, synchronous order creation, simple stock rules, and a short feedback loop. It also needs clear contracts, tests, logs, and a path to change without operating a distributed system.

## Decision

Use one Fastify process organized into order, inventory, platform, and composition modules. Modules communicate through TypeScript interfaces and function calls. Keep transport code out of domain services.

## Alternatives considered

- Unstructured monolith: Faster for the first route, but responsibilities would remain mixed and future changes would create avoidable coupling.
- Microservices: Independent deployment could help once modules have different scaling, availability, ownership, or release requirements. Those needs do not exist yet, so network calls and multiple deployments would add cost without solving a current problem.
- Serverless functions: Useful for event-driven or uneven workloads with minimal server management. This local project needs to make module boundaries and request flow visible without adding a cloud dependency.

## Consequences

- One process is simple to run, test, and debug.
- Explicit module interfaces make responsibilities visible and keep future extraction possible.
- The in-memory repository and inventory are learning implementations. Restarting the process loses state.
- Real production use still requires durable storage, authentication, authorization, rate limiting, deployment automation, external monitoring, secrets management, backups, and load testing.

## Extraction trigger

Consider extracting a module only when evidence shows that it needs independent ownership, deployment cadence, scaling, availability, or technology. Preserve its contract and replace the in-process call with a network or event boundary at that point.

What does this decision record capture?

  • The context names the current requirements before choosing an architecture.
  • The decision keeps module communication behind TypeScript interfaces and function calls.
  • The alternatives explain why an unstructured monolith, microservices, and serverless functions do not fit this release.
  • The consequences document current benefits and the missing capabilities required for real production use.
  • The extraction trigger requires evidence before introducing a network or event boundary.
  • Save docs/architecture-decision.md.
  • Confirm that the saved file includes the Context, Decision, Alternatives considered, Consequences, and Extraction trigger headings.

You'll see the selected architecture beside its trade-offs and future extraction trigger. The decision is now reviewable without relying on memory.

Is the decision record incomplete?

  • Check that the file is inside the docs folder.
  • Check that the status is recorded as accepted.
  • Check that the extraction trigger appears after the consequences section.

Help me compare my ADR with the required architecture decision sections.

✔️ Awesome, I've got everything!

Your ADR now explains the selected architecture and the evidence required before a module becomes a service.

ⓧ I'd like to double check the full code

Here is the complete docs/architecture-decision.md file for comparison.

# ADR 001: Start with a modular monolith

Status: Accepted

## Context

The first release needs one team, one local deployment unit, synchronous order creation, simple stock rules, and a short feedback loop. It also needs clear contracts, tests, logs, and a path to change without operating a distributed system.

## Decision

Use one Fastify process organized into order, inventory, platform, and composition modules. Modules communicate through TypeScript interfaces and function calls. Keep transport code out of domain services.

## Alternatives considered

- Unstructured monolith: Faster for the first route, but responsibilities would remain mixed and future changes would create avoidable coupling.
- Microservices: Independent deployment could help once modules have different scaling, availability, ownership, or release requirements. Those needs do not exist yet, so network calls and multiple deployments would add cost without solving a current problem.
- Serverless functions: Useful for event-driven or uneven workloads with minimal server management. This local project needs to make module boundaries and request flow visible without adding a cloud dependency.

## Consequences

- One process is simple to run, test, and debug.
- Explicit module interfaces make responsibilities visible and keep future extraction possible.
- The in-memory repository and inventory are learning implementations. Restarting the process loses state.
- Real production use still requires durable storage, authentication, authorization, rate limiting, deployment automation, external monitoring, secrets management, backups, and load testing.

## Extraction trigger

Consider extracting a module only when evidence shows that it needs independent ownership, deployment cadence, scaling, availability, or technology. Preserve its contract and replace the in-process call with a network or event boundary at that point.

What does this reference contain?

This reference contains the complete accepted decision. It connects the current requirements to the chosen boundary strategy.

Run the final evidence checks

The contracts and ADR now describe the intended system from two angles. The final checks confirm that the implementation still agrees with both.

  • Switch back to the PowerShell terminal where the API server is running.
  • Press Ctrl+C to stop the manually started server.

Before you run the final checks, do you expect all four contracts to agree with the architecture you documented?

  • Run the complete contract suite in PowerShell by running:
npm test

What does the final test run prove?

The test runner boots the application through injection for each contract. It verifies liveness, validation, stock conflicts, and accepted-order persistence.

  • Check the complete TypeScript project by running:
npm run check

What does the type check add?

Node.js executes the TypeScript files through built-in type stripping. The separate compiler check catches type errors before they reach runtime.

You'll see four passing tests. The type checker returns to the PowerShell prompt without errors.

You now have repeatable behavioral evidence and a reviewable architecture decision. Your API foundation can explain both what works and why it is shaped this way.

Does a final check fail?

  • Use the failing test name to identify which API contract changed.
  • Use the type checker's file path to locate the mismatched interface or value.
  • Confirm that all edited files are saved before running the checks again.

Help me diagnose my failing final test or TypeScript check.

Secret mission

Expose Inventory Without Breaking the Boundary

Your inventory module already protects stock during order creation. In this mission, you will expose current stock through its existing interface. A fifth contract test will prove that the new endpoint preserves the module boundary.

Clean Up Your Resources

Clean Up Your Resources

Everything you built lives locally inside production-order-api, so there are no ongoing costs. Choose whether to keep the workspace, pause its server, or delete the folder.

Resources you used:

  • The local API process in PowerShell, if it is still running.
  • The production-order-api folder containing src/, test/app.test.ts, docs/architecture-decision.md, node_modules, and package-lock.json.

Keep everything running

No action needed. Choose this if you want to use the workspace as the starting point for the persistence follow-on project.

  • Leave the production-order-api folder unchanged.
  • Your source files, installed dependencies, five tests, and architecture decision record remain ready for future work.
  • Your local API uses no paid resources while idle or running.

Pause - I'll come back to this later

Pausing stops the local API process while preserving the complete workspace.

  • Return to the PowerShell terminal from earlier.
  • Press Ctrl+C if the server still occupies the terminal.
  • Confirm the PowerShell prompt is available again.
  • Leave the production-order-api folder in place for your next session.

Delete - I don't want to use this again

Deleting this folder is permanent. Every project file sits inside production-order-api.

  • Check whether the PowerShell prompt is available.
  • Press Ctrl+C if the API still occupies the terminal.
  • Close Visual Studio Code.
  • Use the Windows folder browser to go to the location where you created the project.
  • Select the production-order-api folder.
  • Delete the selected folder.
  • Confirm production-order-api no longer appears in that location.

Nice Work!

Nice Work!

You made it! Your local Fastify Order API is now a tested production-minded foundation built with TypeScript.

You've learned how to:

  • Enforce API contracts through JSON Schema validation. Invalid quantities now receive a stable error response. Unexpected failures stay hidden behind a sanitized error contract.
  • Structure the application as a modular monolith with explicit module boundaries. An architecture decision record explains the choice. It also identifies the evidence that would justify service extraction.
  • Test the API through HTTP injection without binding a network port. The Node.js test runner checks liveness. Separate tests prove validation errors. Further tests cover stock conflicts. The accepted-order flow is tested too. Structured logging makes live requests traceable through request IDs.
  • Secret Mission: Expose an inventory lookup contract through the existing InventoryService boundary. A fifth automated test verifies the inventory response.

Ready to quiz yourself?