Technical Writing and Documentation
For .NET engineers who know: XML doc comments, Sandcastle, Azure DevOps Wiki, Confluence, and the Microsoft documentation style used across MSDN and learn.microsoft.com You’ll learn: How documentation works in open-source JS/TS projects — README-driven development, TSDoc, ADRs, and the docs-as-code philosophy — and our specific conventions for new projects Time: 10-15 min read
The .NET Way (What You Already Know)
Microsoft has a mature, consistent documentation culture built around a few tools. XML doc comments in C# (/// <summary>, /// <param>, /// <returns>) are first-class language constructs that IDEs render on hover and that Sandcastle or DocFX compile into browsable HTML documentation. Azure DevOps Wiki provides a centralized wiki with Markdown support, often used for runbooks, onboarding guides, and architectural notes. Confluence is common in larger organizations. Swagger/Swashbuckle generates API reference from controller annotations.
The pattern: documentation lives in dedicated tools or generated from structured comments. It is authoritative when someone goes looking for it, but it is separate from the code and can drift.
/// <summary>
/// Calculates the total price for an order, including applicable taxes
/// and discounts. Returns zero if the order has no line items.
/// </summary>
/// <param name="order">The order to calculate. Must not be null.</param>
/// <param name="applyDiscount">When true, applies the customer's loyalty discount.</param>
/// <returns>The total price in USD as a decimal, rounded to two decimal places.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="order"/> is null.</exception>
public decimal CalculateTotal(Order order, bool applyDiscount = true)
This is good documentation. The hover tooltip in Visual Studio is genuinely useful. The generated HTML documentation is browsable. The problem is maintenance: when you change the behavior of CalculateTotal, the docs do not automatically fail CI. They silently drift.
The JS/TS Way
Open-source JS/TS projects use a fundamentally different documentation model: docs-as-code. Documentation is:
- Written in Markdown, stored in the same repository as the code
- Versioned with Git alongside the code it documents
- Reviewed in pull requests alongside code changes
- Checked for broken links and formatting in CI
The philosophy is pragmatic: documentation that is hard to update will not be updated. Keeping docs in the repo, in the same PR as code changes, is the only reliable way to keep them current.
The README as the Front Door
Every repository has a README.md that serves as the primary entry point for anyone who encounters the project. On GitHub, it renders automatically on the repository home page. A README is not optional decoration — it is the first thing a new engineer reads, and its quality signals the maturity of the project.
A complete README includes:
# Project Name
One sentence describing what this project does and who it is for.
## Prerequisites
- Node.js 22+ (use `.nvmrc` or `volta pin`)
- pnpm 9+
- Docker (for local database)
## Getting Started
\`\`\`bash
git clone git@github.com:org/project.git
cd project
pnpm install
cp .env.example .env
# Edit .env with your local values
pnpm db:migrate
pnpm dev
\`\`\`
Open http://localhost:3000.
## Project Structure
\`\`\`
src/
modules/ # NestJS feature modules
common/ # Shared utilities and guards
config/ # Environment configuration
prisma/ # Database schema and migrations
tests/
unit/
e2e/
\`\`\`
## Available Commands
| Command | Description |
|---------|-------------|
| `pnpm dev` | Start development server with hot reload |
| `pnpm build` | Compile for production |
| `pnpm test` | Run unit tests |
| `pnpm test:e2e` | Run end-to-end tests |
| `pnpm db:migrate` | Apply pending migrations |
| `pnpm db:studio` | Open Prisma Studio (visual DB browser) |
## Environment Variables
See `.env.example` for all required variables with descriptions.
## Architecture
Brief description of the key design decisions. Link to ADRs in `/docs/decisions/` for detailed rationale.
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md) for branch naming, PR process, and coding standards.
What a README should not contain:
- Comprehensive API reference (put this in code comments or a separate docs directory)
- Architectural rationale for decisions made months ago (put this in ADRs)
- Exhaustive troubleshooting guides (put this in runbooks)
- A history of changes (that is what git log is for)
TSDoc: Documenting TypeScript APIs
TSDoc is the TypeScript equivalent of XML doc comments. It uses /** */ block comments with standardized tags. VS Code, WebStorm, and most TypeScript-aware tools render these as hover documentation.
/**
* Calculates the total price for an order, including applicable taxes
* and discounts. Returns `0` if the order has no line items.
*
* @param order - The order to calculate. Must have at least one line item.
* @param options - Optional calculation parameters.
* @param options.applyDiscount - When `true`, applies the customer's loyalty discount.
* @returns The total price in USD, rounded to two decimal places.
* @throws {@link OrderValidationError} When the order fails validation.
*
* @example
* ```typescript
* const total = calculateOrderTotal(order, { applyDiscount: true });
* console.log(total); // 42.99
* ```
*/
export function calculateOrderTotal(
order: Order,
options: { applyDiscount?: boolean } = {}
): number {
// ...
}
TSDoc differs from XML docs in important ways:
- Tags use
@param name - descriptioninstead of<param name="x">description</param> - The
@exampletag takes a fenced code block, not a<code>element - There is no equivalent to
<exception>— use@throwswith a type reference @internalmarks a symbol as not part of the public API (tools can strip it)@deprecatedmarks a symbol with a reason and replacement
TSDoc is enforced by ESLint with eslint-plugin-tsdoc:
pnpm add -D eslint-plugin-tsdoc
// eslint.config.js
import tsdoc from 'eslint-plugin-tsdoc';
export default [
{
plugins: { tsdoc },
rules: {
'tsdoc/syntax': 'warn',
},
},
];
When to write TSDoc:
- All exported functions, classes, and interfaces in a library or shared module
- All public methods on NestJS services that other services call
- Anything with non-obvious parameters, side effects, or error conditions
- Public API endpoints (supplement Swagger, do not replace it)
When to skip TSDoc:
- Private implementation details that are not part of any API surface
- Simple one-line functions where the name and types are self-documenting
- Internal helpers used only within the same file
Swagger / OpenAPI for API Reference
NestJS generates Swagger documentation from decorators. This is the authoritative API reference for anyone calling your backend.
// main.ts
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
const config = new DocumentBuilder()
.setTitle('Orders API')
.setDescription('Order management API for the checkout system')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
// In your controller and DTOs
import { ApiOperation, ApiResponse, ApiTags, ApiProperty } from '@nestjs/swagger';
export class CreateOrderDto {
@ApiProperty({ description: 'Product ID from the catalog', example: 'prod_123' })
productId: string;
@ApiProperty({ description: 'Quantity to order', minimum: 1, maximum: 100 })
quantity: number;
}
@ApiTags('orders')
@Controller('orders')
export class OrdersController {
@Post()
@ApiOperation({ summary: 'Place a new order' })
@ApiResponse({ status: 201, description: 'Order created', type: OrderResponseDto })
@ApiResponse({ status: 400, description: 'Invalid order data' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
async createOrder(@Body() dto: CreateOrderDto) { ... }
}
Swagger UI is available at /api/docs in development and staging. In production, either disable it or protect it behind authentication — it is a complete map of your API surface.
Architecture Decision Records (ADRs)
An ADR documents a significant architectural decision: why you made it, what alternatives you considered, and what the trade-offs are. ADRs are the institutional memory of a project — they answer the question “why is it done this way?” without requiring anyone to be in the room when the decision was made.
File location and naming:
docs/
decisions/
0001-use-prisma-over-typeorm.md
0002-use-nestjs-for-api-layer.md
0003-postgres-as-primary-database.md
0004-use-pnpm-workspaces-for-monorepo.md
ADR template:
# ADR 0004: Use pnpm Workspaces for Monorepo
**Date:** 2026-01-15
**Status:** Accepted
**Deciders:** Chris Therriault, [team members]
## Context
We are building a project with a NestJS API, a Next.js frontend, and shared
TypeScript types. We need a strategy for managing these three packages in a
single repository with shared dependencies.
## Decision
We will use pnpm workspaces to manage the monorepo. The workspace root will
contain a `pnpm-workspace.yaml` defining the packages. Shared types live in
`packages/types`. Build orchestration uses Turborepo.
## Alternatives Considered
**npm workspaces** — Available since npm 7. Rejected because pnpm's symlink
strategy avoids phantom dependencies and the store reduces disk usage.
**Nx** — More powerful build graph and code generation. Rejected because the
learning curve and configuration complexity exceeds our needs for 3 packages.
Revisit if the monorepo grows beyond 10 packages.
**Separate repositories** — Simpler per-repo tooling. Rejected because shared
type changes would require coordinated cross-repo PRs.
## Consequences
**Positive:**
- Single `pnpm install` installs all package dependencies
- TypeScript path aliases work across packages without publishing to npm
- Shared CI/CD pipeline configuration
**Negative:**
- pnpm-specific workspace syntax (not portable to npm/yarn without changes)
- Turborepo adds configuration complexity
## References
- [pnpm Workspaces documentation](https://pnpm.io/workspaces)
- [Turborepo documentation](https://turbo.build/repo/docs)
ADR rules:
- Write the ADR at the time of the decision, not retrospectively
- Status values:
Proposed,Accepted,Deprecated,Superseded by ADR-XXXX - Supersede, do not delete. A deprecated decision is still history.
- Keep ADRs short — if you are writing more than 500 words, you are probably documenting implementation details, not a decision
Runbooks
A runbook documents how to respond to a known operational situation: a recurring incident, a deployment procedure, a database maintenance task. Runbooks are for the human on call at 2am who needs to act quickly without reading code.
File location:
docs/
runbooks/
restart-background-jobs.md
rotate-database-credentials.md
handle-payment-webhook-failures.md
emergency-rollback.md
Runbook template:
# Runbook: Emergency Rollback on Render
**Trigger:** A deployment causes errors, and the fix cannot be deployed immediately.
**Owner:** On-call engineer
**Time to complete:** ~5 minutes
## Prerequisites
- Access to the Render dashboard
- Access to Sentry to confirm error rates
## Steps
1. Open Render dashboard → [Service Name] → Deploys
2. Find the last known-good deploy (before the incident started)
3. Click "Rollback to this deploy"
4. Wait ~60 seconds for the rollback to complete
5. Verify in Sentry that the error rate drops
6. Post in #incidents Slack channel: "Rolled back [service] to [commit SHA]"
7. Create a post-incident task to fix the root cause before re-deploying
## Verification
After rollback, call `GET /health` and confirm the response is `{"status":"ok"}`.
Check Sentry: the incident's error rate should drop to zero within 2 minutes.
## Notes
Rollback does not revert database migrations. If the deployment included a
migration that is incompatible with the previous code version, a rollback alone
may not be sufficient. Escalate to senior engineer if the health check fails
after rollback.
The docs-as-code Workflow
The practical implication of keeping docs in the repository:
-
New feature PR includes documentation. The PR that adds a new API endpoint also updates the README if the architecture changes, adds TSDoc to the new service, and adds/updates the relevant runbook if operational behavior changes.
-
ADRs are reviewed in PRs. An ADR for a significant architectural decision is committed as a PR for team review before it is accepted.
-
Broken documentation fails CI. Use tools to catch drift:
# .github/workflows/docs.yml
- name: Check for broken markdown links
uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
folder-path: './docs'
- Old documentation is updated, not appended. Do not write “UPDATE 2026-02-15: this no longer applies” in the middle of a document. Update the document to reflect current reality. Git history preserves the old version.
Key Differences
| .NET/Azure Approach | JS/TS Approach |
|---|---|
XML doc comments (///) | TSDoc (/** */) with @param, @returns, @throws |
| Sandcastle / DocFX for generated docs | TypeDoc or raw TSDoc rendered in IDE |
| Azure DevOps Wiki | Markdown files in docs/ directory in the repo |
| Confluence for architecture docs | ADRs in docs/decisions/ |
| Swagger generated from Swashbuckle XML | Swagger generated from @nestjs/swagger decorators |
| Documentation in a separate system | Documentation in the repository, reviewed in PRs |
| Wiki page can be updated without a PR | Doc change requires a PR (intentional friction) |
<exception cref="T"> for error docs | @throws {@link ErrorType} in TSDoc |
Gotchas for .NET Engineers
Gotcha 1: Markdown formatting is more precise than it looks.
Two spaces at the end of a line create a line break. One blank line creates a paragraph break. Four spaces at the start of a line create a code block. These rules vary slightly between Markdown parsers (CommonMark, GitHub Flavored Markdown, MDX). If your documentation renders incorrectly on GitHub, the cause is almost always whitespace or indentation in the Markdown source. Use a linter (markdownlint) to catch formatting issues before they reach the PR.
Gotcha 2: TSDoc is not JSDoc.
You may encounter JSDoc in older codebases: @param {string} name - description. TypeScript projects should use TSDoc instead: @param name - description. The types come from TypeScript annotations, not JSDoc {type} syntax. Mixing them causes confusion and may produce incorrect hover documentation. ESLint with eslint-plugin-tsdoc enforces correct TSDoc syntax.
Gotcha 3: README rot is the most common documentation failure. A README written during initial setup and never updated is worse than no README — it actively misleads new engineers. Getting Started instructions that no longer work, command names that have changed, prerequisites that are out of date. Designate one person per project to own the README, and add “does the README reflect this change?” to your PR checklist. Better: include a README accuracy check as part of your onboarding checklist (Article 8.8), because a new engineer running the Getting Started steps will immediately identify drift.
Gotcha 4: ADRs are written once, not maintained.
An ADR documents a decision at a point in time. You do not update an ADR when circumstances change — you write a new ADR that supersedes it. If you update an old ADR to reflect a new decision, you lose the historical record of what was true when the original decision was made and why it was later changed. When a decision is reversed, write Status: Superseded by ADR-0012 in the old ADR and reference the old one in the new one.
Gotcha 5: Swagger in production exposes your API surface publicly.
In .NET, Swagger middleware is commonly disabled in production via if (env.IsDevelopment()). In NestJS projects, it is easy to forget this guard. Your Swagger UI at /api/docs is a complete, interactive documentation of every endpoint, parameter type, and response schema. In production, either disable it entirely or protect it with authentication middleware. This is not about security through obscurity — Swagger is genuinely a productivity tool for frontend engineers. It is about not handing attackers a structured attack surface map.
Hands-On Exercise
Create the complete documentation structure for a new NestJS project.
Part 1: README
- Start a new NestJS project or use an existing one.
- Write a
README.mdusing the template above. Fill in every section for your actual project — do not use placeholder text. - Verify: a new engineer who has never seen the project can follow the “Getting Started” section and get the application running without additional help.
- Add
markdownlintto the project (pnpm add -D markdownlint-cli2) and add a lint check topackage.json:"lint:docs": "markdownlint-cli2 '**/*.md' '#node_modules'"
Part 2: TSDoc
- Find three functions in the project that are exported and have non-obvious behavior.
- Write full TSDoc for each:
@param,@returns,@throws(if applicable), and at least one@example. - Install
eslint-plugin-tsdocand configure it. Fix any TSDoc syntax warnings. - Hover over the documented functions in VS Code and verify the hover tooltip renders the documentation correctly.
Part 3: ADR
- Write an ADR for one significant decision already made in the project (which ORM you chose, which auth provider, which hosting platform). Use the ADR template above.
- Create
docs/decisions/0001-your-decision.md. - Have a colleague or AI reviewer check that the ADR answers: what was the decision, what alternatives were considered, and what are the consequences?
Part 4: Runbook
- Write a runbook for the most likely operational scenario in your project: “what do you do if the application returns 500 errors and you need to roll back?”
- Include specific steps with exact UI element names or CLI commands.
- Have someone unfamiliar with the infrastructure follow the runbook in staging and identify any steps that are unclear or missing.
Quick Reference
Documentation Locations
| Document type | Location | Tool |
|---|---|---|
| Project overview and setup | README.md (root) | Markdown |
| API code documentation | Inline TSDoc on exported symbols | TSDoc / eslint-plugin-tsdoc |
| REST API reference | Auto-generated at /api/docs | @nestjs/swagger |
| Architectural decisions | docs/decisions/NNNN-slug.md | Markdown (ADR format) |
| Operational runbooks | docs/runbooks/slug.md | Markdown |
| Component/function examples | docs/examples/ or inline @example | TSDoc / Storybook |
| Changelog | CHANGELOG.md | Conventional Commits + changelogen |
| Contributing guide | CONTRIBUTING.md | Markdown |
TSDoc Cheat Sheet
/**
* Short summary sentence (appears in hover tooltip).
*
* Optional longer description. Can span multiple paragraphs.
* Use code formatting: `variableName` or code blocks below.
*
* @param paramName - Description. Note the dash after the name.
* @param options - Options object.
* @param options.fieldName - Individual option field.
* @returns What the function returns. Describe the shape for complex types.
* @throws {@link ErrorClassName} When this error is thrown and why.
* @deprecated Use {@link newFunction} instead. Removed in v3.0.
* @internal Not part of the public API. Subject to change without notice.
* @see {@link relatedFunction} for a related operation.
*
* @example
* ```typescript
* const result = myFunction('input', { option: true });
* // result: 'expected output'
* ```
*/
ADR Statuses
| Status | Meaning |
|---|---|
Proposed | Under discussion, not yet committed |
Accepted | Decision has been made and implemented |
Deprecated | No longer relevant (technology was removed) |
Superseded by ADR-XXXX | Replaced by a newer decision |
Our Project Templates
New project documentation checklist:
-
README.mdwith all sections filled in (Prerequisites, Getting Started, Commands, Environment Variables, Architecture) -
.env.examplewith every variable listed and documented inline -
docs/decisions/0001-*.mdfor the primary technology choices (why this framework, why this database, why this hosting) -
docs/runbooks/emergency-rollback.mdbefore the first production deployment - TSDoc on all exported service methods and utility functions
- Swagger configured and accessible at
/api/docsin development and staging -
markdownlint-cli2in dev dependencies and in CI
PR checklist additions:
- Does the README reflect any changes to setup, commands, or environment variables?
- Are new exported functions documented with TSDoc?
- Does this decision warrant an ADR?
- Is there a runbook to add or update?
Further Reading
- TSDoc Specification — the canonical reference for TSDoc tags and syntax
- ADR GitHub Organization — Michael Nygard’s original ADR proposal and tooling
- Diátaxis Documentation Framework — a systematic framework for documentation types (tutorials, how-tos, reference, explanation)
- NestJS Swagger Documentation — decorator-by-decorator reference
- markdownlint Rules — complete reference for Markdown linting rules
- Google Technical Writing Courses — free, practical writing courses specifically for engineers