Auto PR + Auto Deploy workflow using CI/CD Pipline

Here’s a real production-style Auto PR + Auto Deploy workflow using GitHub Actions. This is the kind of setup used in teams to keep main stable and deployments automatic.

We’ll build it in 3 parts:

  1. 🤖 Auto-create Pull Requests (from feature → main)
  2. 🧪 Run CI checks on PRs
  3. 🚀 Auto-deploy when merged to main

🧠 0. What we are building

“`plaintext id=”flow1″
feature branch push
↓
Auto PR created (GitHub Action)
↓
CI runs (tests, lint)
↓
PR merged to main
↓
Auto deploy to production




---

# 🤖 1. Auto Create Pull Request Workflow

This automatically creates a PR when you push a feature branch.

## 📁 `.github/workflows/auto-pr.yml`



```yaml id="pr1"
name: Auto Create Pull Request

on:
  push:
    branches-ignore:
      - main

jobs:
  create-pr:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Create Pull Request
        uses: repo-sync/pull-request@v2
        with:
          destination_branch: main
          github_token: ${{ secrets.GITHUB_TOKEN }}
          pr_title: "Auto PR: ${{ github.ref_name }}"
          pr_body: |
            ## 🤖 Auto-generated PR

            Branch: `${{ github.ref_name }}`
            Please review changes before merging.

🧠 What this does:

  • Push to feature/login
  • GitHub Actions triggers
  • Automatically opens PR → feature/login → main

🧪 2. CI Pipeline (runs on PRs)

This ensures only clean code gets merged.

📁 .github/workflows/ci.yml

“`yaml id=”ci1″
name: CI Checks

on:
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest

steps:
  - uses: actions/checkout@v4

  - name: Setup Node
    uses: actions/setup-node@v4
    with:
      node-version: 20

  - name: Install dependencies
    run: npm install

  - name: Run lint
    run: npm run lint

  - name: Run tests
    run: npm test



---

## 🧠 What this does:

Before merge:

* Runs lint checks
* Runs tests
* Blocks bad code from merging

---

# 🚀 3. Auto Deploy on Merge to Main

This deploys your app when PR is merged.

## 📁 `.github/workflows/deploy.yml`

### Example: Deploy React + Node (Vercel + Render)



```yaml id="deploy1"
name: Auto Deploy

on:
  push:
    branches: [ main ]

jobs:
  deploy-frontend:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install frontend
        working-directory: client
        run: npm install

      - name: Build frontend
        working-directory: client
        run: npm run build

      - name: Deploy to Vercel
        run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}

  deploy-backend:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install backend
        working-directory: server
        run: npm install

      - name: Deploy backend (Render webhook)
        run: curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK }}

🔐 4. GitHub Secrets Setup

Go to:

👉 Repo → Settings → Secrets → Actions

Add:

“`plaintext id=”sec1″
VERCEL_TOKEN=your_vercel_token
RENDER_DEPLOY_HOOK=https://api.render.com/deploy/xxx




---

# 🧭 5. Full Workflow in action

## Developer flow:



```plaintext id="flow2"
git checkout -b feature-login
git push origin feature-login

Then GitHub automatically:

  1. 🤖 Creates Pull Request
  2. 🧪 Runs CI checks
  3. 👀 Waits for review (optional)
  4. 🔀 Merge to main
  5. 🚀 Auto deploys frontend + backend

🔥 6. Upgrade (what real companies add)

🟢 Add approval rule (recommended)

In GitHub:

  • Require 1–2 reviewers
  • Require CI to pass before merge

🟡 Add branch protection

Settings → Branches → main

Enable:

  • Require PR before merging
  • Require status checks
  • Block direct pushes

🔵 Add notifications (Slack/Discord)

“`yaml id=”notif1″

  • name: Notify Slack
    run: |
    curl -X POST -H ‘Content-type: application/json’
    –data ‘{“text”:”New deployment to production 🚀”}’
    ${{ secrets.SLACK_WEBHOOK }}
    “`

🟣 Add version tagging

“`bash id=”tag1″
git tag v1.0.0
git push origin v1.0.0




---

# ⚠️ Common mistakes

### ❌ Auto-deploy without tests

→ leads to broken production

### ❌ No branch protection

→ anyone can push to main

### ❌ Missing secrets

→ deployment fails silently

---

# 🧠 Final Architecture (Pro level)



```plaintext id="final1"
Feature Branch
   ↓
Auto PR Created
   ↓
CI (tests + lint)
   ↓
Review + Approval
   ↓
Merge to main
   ↓
CD Pipeline
   ↓
Frontend deploy (Vercel)
Backend deploy (Render/AWS)
   ↓
Slack/Discord notification

CSS in 2026: Container Queries, Cascade Layers, and the End of Utility-Class Bloat

CSS in 2026: Container Queries, Cascade Layers, and the End of Utility-Class Bloat

CSS in 2026 has fundamentally changed how we write styles. The combination of Container Queries, Cascade Layers, and modern selectors has eliminated the need for many JavaScript-dependent styling patterns. Let’s talk about what’s actually changed and what it means for your codebase.

Container Queries: The Feature That Changes Everything

For years, we wrote responsive styles based on the viewport. But components don’t care about the viewport — they care about their container. Container Queries finally solve this properly.

The Old Way (Viewport-Based)

/* This card component behaves differently based on viewport width */
/* But what if the same component appears in a narrow sidebar AND a wide main area? */
.card {
  display: flex;
  flex-direction: column;
}

@media (min-width: 768px) {
  .card {
    flex-direction: row; /* Works for main content, breaks for sidebar */
  }
}

The New Way (Container-Based)

/* First: define a container */
.card-grid {
  container-type: inline-size;
  container-name: card-grid;
}

/* The card responds to its CONTAINER, not the viewport */
.card {
  display: flex;
  flex-direction: column;
}

@container card-grid (min-width: 400px) {
  .card {
    flex-direction: row; /* Works in both sidebar and main! */
  }
}

Real-World Example: Responsive Card Component

/* Define containers at different levels */
.page-layout {
  container-type: inline-size;
}

.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}

.main-content {
  container-type: inline-size;
  container-name: main;
}

/* Article card responds to ANY container */
.article-card {
  display: grid;
  grid-template-areas:
    "image"
    "title"
    "meta"
    "excerpt";
  gap: 1rem;
}

@container (min-width: 300px) {
  .article-card {
    grid-template-areas:
      "image title"
      "image meta"
      "image excerpt";
    grid-template-columns: 120px 1fr;
  }
}

@container main (min-width: 600px) {
  .article-card {
    grid-template-areas:
      "image title meta"
      "image excerpt excerpt";
    grid-template-columns: 200px 1fr auto;
  }
}

Browser Support in 2026

Container Queries now have universal support across all modern browsers. The old “not ready for production” excuse is gone. If you’re still not using them, you’re writing outdated CSS.

Cascade Layers: Taking Back Control

Cascade Layers solve the specificity wars that have plagued CSS for years. Instead of fighting with increasingly specific selectors, you define explicit layer priorities.

The Problem: Specificity Hell

/* You're fighting specificity everywhere */
.button.primary {
  background: blue;
}

.header .nav .button.primary {
  background: darkblue; /* Have to be MORE specific */
}

.nav-container .header .nav .button.primary {
  background: navy; /* This is exhausting */
}

/* Eventually you resort to !important */
.button.primary {
  background: blue !important; /* This is a code smell */
}

The Solution: Cascade Layers

/* Define layers in order of priority (lowest to highest) */
@layer reset, base, components, utilities, overrides;

@layer reset {
  * { box-sizing: border-box; }
}

@layer base {
  h1 { font-size: 2rem; }
  a { color: blue; }
}

@layer components {
  .button {
    padding: 0.75rem 1.5rem;
    border-radius: 0.375rem;
    font-weight: 500;
  }

  .button.primary {
    background: blue;
    color: white;
  }
}

@layer utilities {
  .text-center { text-align: center; }
  .mt-4 { margin-top: 1rem; }
}

/* Anything in overrides ALWAYS wins */
@layer overrides {
  .button.primary {
    background: darkblue; /* This beats components without !important */
  }
}

The Key Insight: Specificity Within Layers

Within a layer, normal CSS specificity rules apply. Between layers, later layers always beat earlier layers — regardless of specificity.

@layer components {
  /* This has high specificity but is in a lower layer */
  .header .nav .button.primary {
    background: blue; /* Lower layer */
  }
}

@layer overrides {
  /* This has LOW specificity but wins because it's in a higher layer */
  .button.primary {
    background: darkblue; /* Higher layer wins */
  }
}

This means you can write simple selectors in higher layers and they will always beat complex selectors in lower layers.

Third-Party CSS + Layers = Finally Solved

/* Bootstrap/Tailwind in a low layer, your code in a higher layer */
@layer vendors, base, components, custom;

@layer vendors {
  /* Import Bootstrap but put it in vendors layer */
  @import url('bootstrap.css') layer(vendors);
}

@layer base {
  /* Your base styles */
}

@layer components {
  /* Your components */
}

@layer custom {
  /* Overrides everything — put your customizations here */
}

/* Any .button override in custom layer beats Bootstrap's .button */
@layer custom {
  .button {
    background: custom-blue; /* Always wins */
  }
}

The :has() Selector: Parent Selection and Beyond

The :has() selector is now universally supported and fundamentally changes what’s possible in CSS.

Parent Selection

/* Style a form group if it contains an invalid input */
.form-group:has(:invalid) {
  border-color: red;
  background: rgba(255, 0, 0, 0.05);
}

/* Style a card differently if it has an image */
.card:has(.card-image) {
  grid-template-columns: 200px 1fr;
}

/* Style a nav differently if it has a dropdown */
.nav:has(.dropdown) {
  position: relative;
}

Relational Logic

/* Style an article if the previous sibling is also an article */
.article:not(:has(+ .article)) {
  border-bottom: none; /* Last article in a list */
}

/* Style a label if its input is checked */
.checkbox-label:has(input:checked) {
  font-weight: bold;
  color: green;
}

Counter-Patterns

/* Style a list that has more than 5 items */
.list:has(> :nth-child(5)) {
  columns: 2; /* Multi-column for long lists */
}

/* Style a grid differently based on item count */
.grid:has(> :nth-child(4)) {
  grid-template-columns: repeat(2, 1fr);
}

The @property Rule: Typed Custom Properties

CSS custom properties were always untyped. @property changes that.

@property --gradient-angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

@property --gradient-position {
  syntax: '<percentage>';
  initial-value: 0%;
  inherits: false;
}

.gradient-animated {
  background: linear-gradient(
    var(--gradient-angle),
    red,
    blue
  );
  animation: rotate-gradient 3s linear infinite;
}

@keyframes rotate-gradient {
  to {
    --gradient-angle: 360deg;
  }
}

/* Now animations work because TypeScript knows it's an angle */

Modern CSS Patterns That Replace JavaScript

Sticky Headers Without JavaScript

.header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: white;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

Scroll Snap (Replaces Carousel Libraries)

.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  gap: 1rem;

  /* Hide scrollbar but keep functionality */
  scrollbar-width: none;
  &::-webkit-scrollbar { display: none; }
}

.carousel-item {
  flex: 0 0 300px;
  scroll-snap-align: start;
}

Field Validation States (No JavaScript)

input:valid {
  border-color: green;
}

input:invalid:not(:placeholder-shown) {
  border-color: red;
}

input:out-of-range {
  background: rgba(255, 0, 0, 0.1);
}

The Death of Utility-Class Bloat

Here’s the pattern I’ve seen work in 2026: Cascade Layers + Container Queries + Modern Selectors = No Tailwind Required.

Before (Tailwind-Heavy)

<div class="flex flex-col md:flex-row gap-4 p-4 md:p-6 lg:p-8 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow">
  <div class="w-full md:w-1/3">
    <img class="w-full h-48 md:h-full object-cover rounded-t-lg md:rounded-l-lg md:rounded-tr-none" src="..." />
  </div>
  <div class="flex-1 p-4">
    <h3 class="text-xl font-bold text-gray-900 mb-2">Title</h3>
    <p class="text-gray-600 text-sm mb-4">Content</p>
  </div>
</div>

After (Native CSS with Layers)

@layer components {
  .feature-card {
    display: grid;
    grid-template-areas:
      "image"
      "content";
    background: white;
    border-radius: 0.5rem;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
    transition: box-shadow 0.2s;

    &:hover {
      box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    }
  }

  @container (min-width: 400px) {
    .feature-card {
      grid-template-areas: "image content";
      grid-template-columns: 1fr 2fr;
    }
  }

  .feature-card-image {
    grid-area: image;
    width: 100%;
    height: 12rem;
    object-fit: cover;

    @container (min-width: 400px) {
      height: 100%;
      border-radius: 0.5rem 0 0 0.5rem;
    }
  }

  .feature-card-content {
    grid-area: content;
    padding: 1rem;
  }
}
<div class="feature-card">
  <img class="feature-card-image" src="..." />
  <div class="feature-card-content">
    <h3>Title</h3>
    <p>Content</p>
  </div>
</div>

The CSS Stack in 2026

Modern CSS (2026):
├── Container Queries     → Component-level responsiveness
├── Cascade Layers        → Specificity control
├── :has() Selector       → Relational styling
├── @property            → Typed animations
├── @layer               → Import ordering
├── gap in flexbox       → No more margin hacks
├── subgrid              → Aligned nested grids
└── color-mix()          → Dynamic color utilities

Supporting Tools:
├── PostCSS with autoprefixer (minimal)
├── Lightning CSS (10x faster than PostCSS)
└── Vanilla Extract (type-safe CSS-in-TS, when needed)

My Recommendation

Stop reaching for Tailwind by default. For most projects, modern CSS with Cascade Layers gives you:

  • Better maintainability (readable class names)
  • Smaller bundle (no utility CSS to download)
  • No build step for basic projects
  • True cascade inheritance

Use Tailwind when:

  • You need rapid prototyping with a team unfamiliar with CSS
  • You’re building a design system with a dedicated CSS expert
  • The project genuinely needs 500+ utility classes

Use native CSS when:

  • You’re building a product that needs maintainable styles
  • Your team knows CSS
  • You want smaller bundles and no runtime dependency

The era of “CSS is too hard, let’s use a utility framework” is over. Modern CSS has caught up.

What’s your CSS stack in 2026? Have you moved back to vanilla CSS, or are you still all-in on utility frameworks?

Multi-tenant SaaS for $30/mo: wildcard SSL + nginx vhost generator + Flask routing

Multi-tenant SaaS for $30/mo: the actual architecture

Every “build a SaaS” tutorial reaches for Kubernetes, managed Postgres, and three cloud services before the first user signs up. You don’t need that. Here’s a multi-tenant setup that runs 50+ tenants on subdomains, on one $30/mo VPS.

The requirement

White-label partnership program. Each partner gets a storefront at partner-{slug}.guardlabs.online with our catalog, their referral IDs baked in. New partner → automatic provisioning after payment webhook.

Phase 1 (0-50 tenants): wildcard SSL + nginx + Flask

Wildcard SSL via Let’s Encrypt — one cert covers *.guardlabs.online:

certbot certonly --manual --preferred-challenges dns 
  -d "*.guardlabs.online" -d "guardlabs.online"

(DNS-01 challenge — add the TXT record your DNS provider, certbot validates.)

nginx server block routing by $host:

server {
    listen 443 ssl;
    server_name ~^partner-(?<partner_slug>.+).guardlabs.online$;
    ssl_certificate     /etc/letsencrypt/live/guardlabs.online/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/guardlabs.online/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8090;
        proxy_set_header Host $host;
        proxy_set_header X-Partner-Slug $partner_slug;
    }
}

Flask reads the header, scopes everything to that partner:

@app.before_request
def load_partner():
    slug = request.headers.get("X-Partner-Slug")
    if slug:
        g.partner = db.execute(
            "SELECT * FROM partners WHERE slug=?", (slug,)
        ).fetchone()
        if not g.partner:
            abort(404)

@app.route("/")
def storefront():
    products = get_catalog()  # global catalog
    return render_template("store.html",
        products=products,
        ref_id=g.partner["ref_id"],  # injected into every buy link
        partner_brand=g.partner["brand_name"])

Provisioning on webhook — when Whop sends “new subscription”:

@app.route("/webhook/whop", methods=["POST"])
def whop_webhook():
    event = verify_and_parse(request)
    if event["type"] == "subscription.created":
        slug = slugify(event["user"]["username"])
        # 1. DB row
        db.execute("INSERT INTO partners (slug, ref_id, ...) VALUES (...)")
        # 2. nginx — no per-partner config needed! Regex server_name catches it.
        # 3. Done. partner-{slug}.guardlabs.online works immediately.
    return "", 200

That’s the trick: the regex server_name means zero new nginx config per partner. Wildcard SSL means zero new certs. The DB row is the only write.

Capacity: one VPS, monolith Flask, SQLite. ~50 partners with 10 concurrent users each = 500 concurrent. A $30/mo Hetzner CCX handles that without breathing hard.

Phase 2 (50-200 tenants): per-tenant SQLite

Move from shared DB to /data/partners/{partner_id}/store.db. Flask switches connection by X-Partner-Slug. ~16 hours of refactor. SQLite holds 10k+ rows per file fine; 200 partners × 10k = 2M rows total, no problem.

Phase 3 (200-500+): PostgreSQL schema-per-tenant

Now you migrate. One Postgres, schema per partner. 40 hours. Now you might want Kubernetes. Not before.

The point

I provisioned this whole thing in about 8 hours. Total infra budget for 12 months: under $5K. The “you need microservices and managed everything” advice is for companies with funding and a platform team. For a solo founder: nginx regex + wildcard SSL + a Flask before_request hook gets you to 50 paying tenants.

Try it

The partnership it powers: 7-day free trial, no card, $29/mo. guardlabs.online/partner. And the bot the whole thing grew out of: Phantom paper-trader (384 trades, 57% win-rate, public).

Question

What’s your “we over-engineered this” story? When did you reach for Kubernetes before you needed it?

Code and full launch log in public. Following along.

📥 Free chapter — 20 no-budget growth tactics

This launch log runs on a playbook. If you want the actual tactics — Google-ecosystem hacks, trend-jacking, the HARO authority play — grab two free sections of the Blueprint. No PDF wall, no login: it opens in your browser. Real numbers, real code, no fluff.

→ guardlabs.online/free-pdf

JWT Auth in Express with TS

Project Structure

└── src/
    ├── @types/
    ├── controllers/
    ├── middlewares/
    ├── models/
    ├── routes/
    ├── utils/
    ├── app.ts
    └── index.ts

Install Required Packages

npm install jsonwebtoken bcrypt mongoose cookie-parser
npm install -D @types/jsonwebtoken @types/bcrypt

Environment Variables

Create a .env file:

ACCESS_TOKEN_SECRET=
ACCESS_TOKEN_EXPIRY=

REFRESH_TOKEN_SECRET=
REFRESH_TOKEN_EXPIRY=

Custom ApiResponse & ApiError

Before building authentication, I use custom helper classes for consistent API responses and error handling.

Benefits:

  • Cleaner controller code
  • Consistent API structure
  • Easier frontend handling
  • Better debugging
  • Scalable architecture

ApiError Helper

src/utils/ApiError.ts

export class ApiError extends Error {
  statusCode: number;
  message: string;
  errors: any[];
  stack?: string;
  data: any;
  success: boolean;

  constructor(
    statusCode: number,
    message = "Something went wrong",
    errors = [],
    stack = ""
  ) {
    super(message)
    this.statusCode = statusCode
    this.data = null
    this.message = message
    this.success = false
    this.errors = errors

    if (stack) {
      this.stack = stack
    } else {
      Error.captureStackTrace(this, this.constructor)
    }
  }
}

ApiResponse Helper

src/utils/ApiResponse.ts

export class ApiResponse {
  statusCode: number;
  data: any;
  message: string;
  success: boolean;

  constructor(
    statusCode: number,
    data: any,
    message: string = "Success"
  ) {
    this.statusCode = statusCode
    this.data = data
    this.message = message
    this.success = statusCode < 400
  }
}

Why Use This Pattern?

Instead of manually writing responses like:

return res.status(200).json({
  success: true,
  message: "Logged in",
  data,
});

everywhere, helper classes keep responses standardized across the entire backend.

Why Use Access & Refresh Tokens?

A common mistake beginners make is using only one JWT token.

Instead, we use:

  • Access Token → short-lived authentication
  • Refresh Token → generates new access tokens

Benefits:

  • Better security
  • Users stay logged in
  • Easier session invalidation
  • Refresh token rotation support

This is the same architecture used in many production apps.

User Model

src/models/User.model.ts

import { model, Schema, type HydratedDocument } from "mongoose";
import jwt, { type Secret, type SignOptions } from "jsonwebtoken";
import bcrypt from "bcrypt";

export interface IUser {
  username: string;
  fullName: string;
  email: string;
  password: string;
  refreshToken?: string;

  generateAccessToken: () => string;
  generateRefreshToken: () => string;
  isPasswordCorrect: (password: string) => Promise<boolean>;
}

export type IUserDocument = HydratedDocument<IUser>;

const userSchema = new Schema<IUser>({
  username: String
  fullName: String
  email: String
  password: String
  refreshToken: String
});

userSchema.pre("save", async function (): Promise<void> {
  if (!this.isModified("password")) return;

  this.password = await bcrypt.hash(this.password, 10);
});

userSchema.methods.isPasswordCorrect = async function (
  password: string
): Promise<boolean> {
  return await bcrypt.compare(password, this.password);
};

userSchema.methods.generateAccessToken = function (): string {
  return jwt.sign(
    {
      _id: this._id,
      email: this.email,
      username: this.username,
      fullName: this.fullName,
    },
    process.env.ACCESS_TOKEN_SECRET!! as Secret,
    {
      expiresIn: process.env.ACCESS_TOKEN_EXPIRY!!,
    } as SignOptions
  );
};

userSchema.methods.generateRefreshToken = function (): string {
  return jwt.sign(
    {
      _id: this._id,
    },
    process.env.REFRESH_TOKEN_SECRET!! as Secret,
    {
      expiresIn: process.env.REFRESH_TOKEN_EXPIRY!!,
    } as SignOptions
  );
};

export const User = model<IUser>("User", userSchema);

Why Use Mongoose Methods?

Instead of creating separate utility functions, we attach methods directly to the schema.

Benefits:

  • Cleaner code
  • Better TypeScript support
  • Easier reusability
  • Logic stays close to the model

Password Hashing with Mongoose Middleware

userSchema.pre("save", async function (): Promise<void> {
  if (!this.isModified("password")) return;

  this.password = await bcrypt.hash(this.password, 10);
});

Why this is good:

  • Password hashing becomes automatic
  • Prevents accidental plaintext passwords
  • Avoids duplicate hashing logic

Compare Passwords

userSchema.methods.isPasswordCorrect = async function (
  password: string
): Promise<boolean> {
  return await bcrypt.compare(password, this.password);
};

We use bcrypt.compare() because hashed passwords cannot be decrypted.

Token Generation Helper

const generateTokens = async (userId: Types.ObjectId | string): Promise<{ accessToken: string; refreshToken: string }> => {
  try {
    const user = await User.findById(userId);
    if (!user) {
      throw new ApiError(404, "User not found");
    }

    const refreshToken = user.generateRefreshToken();
    const accessToken = user.generateAccessToken();

    user.refreshToken = refreshToken;
    await user.save({ validateBeforeSave: false });

    return { accessToken, refreshToken };
  } catch (err) {
    console.log(err)
    throw new ApiError(500, "Error while generating tokens");
  }
}

Why Store Refresh Tokens in Database?

Many tutorials skip this.

Storing refresh tokens allows us to:

  • Logout users properly
  • Invalidate sessions
  • Rotate refresh tokens
  • Detect token reuse attacks

This is much safer than purely stateless JWT auth.

Register User Controller

export const registerUser = async (req: Request, res: Response) => {
  const { username, email, fullName, password } = req.body;

  if (
    [fullName, email, username, password].some((field) => field?.trim() === "")
  ) {
    throw new ApiError(400, "All fields are required");
  }

  const userExists = await User.findOne({
    $or: [{ username }, { email }]
  });

  if (userExists) {
    throw new ApiError(409, "User with the same username or email already exists");
  }


  const user = await User.create({
    fullName,
    email,
    password,
    username: username.toLowerCase(),
  });

  const createdUser = await User.findById(user._id).select("-password -refreshToken");

  if (!createdUser) {
    throw new ApiError(500, "Error while creating user");
  }

  return res.status(201).json(
    new ApiResponse(201, createdUser, "User registered successfully")
  )

};

Why Remove Password & Refresh Token?

.select("-password -refreshToken")

Never send sensitive fields back to the client.

Even hashed passwords should never leave the backend.

Login Controller

export const loginUser = async (req: Request, res: Response) => {

  const { username, email, password } = req.body;

  if (!username && !email) {
    throw new ApiError(400, "Username or email is required");
  } 

  if (!password) {
    throw new ApiError(400, "Password is required");
  }

  const user = await User.findOne({
    $or: [{ username }, { email }]
  });

  if (!user) {
    throw new ApiError(404, "User not found");
  }

  const isPassValid: boolean = await user.isPasswordCorrect(password);
  if (!isPassValid) {
    throw new ApiError(401, "Invalid password");
  }

  const { accessToken, refreshToken } = await generateTokens(user._id);

  const userData = {
    _id: user._id,
    fullName: user.fullName,
    username: user.username,
    email: user.email,
    avatarUrl: user.avatarUrl,
  }

  return res
    .status(200)
    .cookie("accessToken", accessToken, cookieOptions)
    .cookie("refreshToken", refreshToken, cookieOptions)
    .json(
      new ApiResponse(200, {
        user: userData,
        accessToken,
        refreshToken
      }, "User logged in successfully")
    );
};

Why Use Cookies Instead of LocalStorage?

We use:

httpOnly: true

Benefits:

  • Protects against XSS attacks
  • JavaScript cannot access tokens
  • More secure than localStorage

Cookie Options

const cookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "strict" as const,
};

Verify JWT Middleware

src/middlewares/auth.middleware.ts

import type { NextFunction, Request, Response } from "express";
import { User } from "../models/User.model.js";
import { ApiError } from "../utils/ApiError.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import jwt, { type JwtPayload } from "jsonwebtoken";

export const verifyJWT = async (req: Request, _: Response, next: NextFunction) => {
  try {
    const accessToken = req.cookies?.accessToken || req.header("Authorization")?.replace("Bearer ", "");

    if (!accessToken) {
      throw new ApiError(401, "Access token is missing");
    }

    const decodedToken = jwt.verify(accessToken, process.env.ACCESS_TOKEN_SECRET!!) as JwtPayload;

    const user = await User.findById(decodedToken._id).select("-password -refreshToken");

    if (!user) {
      throw new ApiError(401, "Invalid Access Token");
    }

    req.user = user;
    next();
  } catch (error: any) {
    throw new ApiError(401,  error?.message || "Invalid Access Token");
  }
};

Why Check Database After JWT Verification?

Some tutorials only verify the JWT.

We additionally verify if the user still exists.

Benefits:

  • Prevents deleted-user access
  • Safer authentication
  • Better security

Refresh Access Token Controller

export const refreshAccessToken = async (req: Request, res: Response) => {
  const incomingRefreshToken = req.cookies.refreshToken || req.body.refreshToken

  if (!incomingRefreshToken) {
    throw new ApiError(401, "Refresh token is missing");
  }

  try {
    const decodedToken = jwt.verify(incomingRefreshToken, process.env.REFRESH_TOKEN_SECRET!!) as JwtPayload;

    const user = await User.findById(decodedToken?._id);

    if (!user) {
      throw new ApiError(401, "Invalid refresh token - user not found");
    }

    if (user.refreshToken !== incomingRefreshToken) { 
      throw new ApiError(401, "Invalid refresh token");
    }

    const { accessToken, refreshToken: newRefreshToken } = await generateTokens(user._id);

    return res
      .status(200)
      .cookie("accessToken", accessToken, cookieOptions)
      .cookie("refreshToken", newRefreshToken, cookieOptions)
      .json(
        new ApiResponse(200, {
          accessToken,
          refreshToken: newRefreshToken,
        }, "Access token refreshed successfully"
        )
      );
  } catch (err: any) {
    throw new ApiError(401, err?.message || "Invalid refresh token");
  }
};

Logout Controller

export const logoutUser = async (req: Request, res: Response) => {
  const userId = req.user?._id;

  if (!userId) {
    throw new ApiError(400, "User ID is required");
  }

  await User.findByIdAndUpdate(userId, {
    $unset: { refreshToken: 1 }
  }, { returnDocument: "after" });  

  return res
    .status(200)
    .clearCookie("accessToken", cookieOptions)  
    .clearCookie("refreshToken", cookieOptions)
    .json(new ApiResponse(200, {}, "User logged out successfully"));
};

Extend Express Request Types

src/@types/express/index.d.ts

import type { IUserDocument } from "../../models/User.model.js";

declare module "express-serve-static-core" {
  interface Request {
    user?: IUserDocument;
  }
}

export {};

tsconfig.json

{
  "compilerOptions": {
    "typeRoots": [
      "./node_modules/@types",
      "./src/@types"
    ]
  },

  "include": [
    "src/**/*",
    "src/@types/**/*.d.ts"
  ]
}

Final Thoughts

You now have a production-style JWT Authentication system with:

  • Access Tokens
  • Refresh Tokens
  • HTTP-only cookies
  • Protected routes
  • Password hashing
  • Token rotation
  • Secure logout

You can further improve this by adding:

  • Email verification
  • Password reset
  • Rate limiting
  • 2FA authentication
  • OAuth logins
  • Redis session storage

Happy coding 🚀

JWT Auth in Express with TS

Project Structure

└── src/
    ├── @types/
    ├── controllers/
    ├── middlewares/
    ├── models/
    ├── routes/
    ├── utils/
    ├── app.ts
    └── index.ts

Install Required Packages

npm install jsonwebtoken bcrypt mongoose cookie-parser
npm install -D @types/jsonwebtoken @types/bcrypt

Environment Variables

Create a .env file:

ACCESS_TOKEN_SECRET=
ACCESS_TOKEN_EXPIRY=

REFRESH_TOKEN_SECRET=
REFRESH_TOKEN_EXPIRY=

Custom ApiResponse & ApiError

Before building authentication, I use custom helper classes for consistent API responses and error handling.

Benefits:

  • Cleaner controller code
  • Consistent API structure
  • Easier frontend handling
  • Better debugging
  • Scalable architecture

ApiError Helper

src/utils/ApiError.ts

export class ApiError extends Error {
  statusCode: number;
  message: string;
  errors: any[];
  stack?: string;
  data: any;
  success: boolean;

  constructor(
    statusCode: number,
    message = "Something went wrong",
    errors = [],
    stack = ""
  ) {
    super(message)
    this.statusCode = statusCode
    this.data = null
    this.message = message
    this.success = false
    this.errors = errors

    if (stack) {
      this.stack = stack
    } else {
      Error.captureStackTrace(this, this.constructor)
    }
  }
}

ApiResponse Helper

src/utils/ApiResponse.ts

export class ApiResponse {
  statusCode: number;
  data: any;
  message: string;
  success: boolean;

  constructor(
    statusCode: number,
    data: any,
    message: string = "Success"
  ) {
    this.statusCode = statusCode
    this.data = data
    this.message = message
    this.success = statusCode < 400
  }
}

Why Use This Pattern?

Instead of manually writing responses like:

return res.status(200).json({
  success: true,
  message: "Logged in",
  data,
});

everywhere, helper classes keep responses standardized across the entire backend.

Why Use Access & Refresh Tokens?

A common mistake beginners make is using only one JWT token.

Instead, we use:

  • Access Token → short-lived authentication
  • Refresh Token → generates new access tokens

Benefits:

  • Better security
  • Users stay logged in
  • Easier session invalidation
  • Refresh token rotation support

This is the same architecture used in many production apps.

User Model

src/models/User.model.ts

import { model, Schema, type HydratedDocument } from "mongoose";
import jwt, { type Secret, type SignOptions } from "jsonwebtoken";
import bcrypt from "bcrypt";

export interface IUser {
  username: string;
  fullName: string;
  email: string;
  password: string;
  refreshToken?: string;

  generateAccessToken: () => string;
  generateRefreshToken: () => string;
  isPasswordCorrect: (password: string) => Promise<boolean>;
}

export type IUserDocument = HydratedDocument<IUser>;

const userSchema = new Schema<IUser>({
  username: String
  fullName: String
  email: String
  password: String
  refreshToken: String
});

userSchema.pre("save", async function (): Promise<void> {
  if (!this.isModified("password")) return;

  this.password = await bcrypt.hash(this.password, 10);
});

userSchema.methods.isPasswordCorrect = async function (
  password: string
): Promise<boolean> {
  return await bcrypt.compare(password, this.password);
};

userSchema.methods.generateAccessToken = function (): string {
  return jwt.sign(
    {
      _id: this._id,
      email: this.email,
      username: this.username,
      fullName: this.fullName,
    },
    process.env.ACCESS_TOKEN_SECRET!! as Secret,
    {
      expiresIn: process.env.ACCESS_TOKEN_EXPIRY!!,
    } as SignOptions
  );
};

userSchema.methods.generateRefreshToken = function (): string {
  return jwt.sign(
    {
      _id: this._id,
    },
    process.env.REFRESH_TOKEN_SECRET!! as Secret,
    {
      expiresIn: process.env.REFRESH_TOKEN_EXPIRY!!,
    } as SignOptions
  );
};

export const User = model<IUser>("User", userSchema);

Why Use Mongoose Methods?

Instead of creating separate utility functions, we attach methods directly to the schema.

Benefits:

  • Cleaner code
  • Better TypeScript support
  • Easier reusability
  • Logic stays close to the model

Password Hashing with Mongoose Middleware

userSchema.pre("save", async function (): Promise<void> {
  if (!this.isModified("password")) return;

  this.password = await bcrypt.hash(this.password, 10);
});

Why this is good:

  • Password hashing becomes automatic
  • Prevents accidental plaintext passwords
  • Avoids duplicate hashing logic

Compare Passwords

userSchema.methods.isPasswordCorrect = async function (
  password: string
): Promise<boolean> {
  return await bcrypt.compare(password, this.password);
};

We use bcrypt.compare() because hashed passwords cannot be decrypted.

Token Generation Helper

const generateTokens = async (userId: Types.ObjectId | string): Promise<{ accessToken: string; refreshToken: string }> => {
  try {
    const user = await User.findById(userId);
    if (!user) {
      throw new ApiError(404, "User not found");
    }

    const refreshToken = user.generateRefreshToken();
    const accessToken = user.generateAccessToken();

    user.refreshToken = refreshToken;
    await user.save({ validateBeforeSave: false });

    return { accessToken, refreshToken };
  } catch (err) {
    console.log(err)
    throw new ApiError(500, "Error while generating tokens");
  }
}

Why Store Refresh Tokens in Database?

Many tutorials skip this.

Storing refresh tokens allows us to:

  • Logout users properly
  • Invalidate sessions
  • Rotate refresh tokens
  • Detect token reuse attacks

This is much safer than purely stateless JWT auth.

Register User Controller

export const registerUser = async (req: Request, res: Response) => {
  const { username, email, fullName, password } = req.body;

  if (
    [fullName, email, username, password].some((field) => field?.trim() === "")
  ) {
    throw new ApiError(400, "All fields are required");
  }

  const userExists = await User.findOne({
    $or: [{ username }, { email }]
  });

  if (userExists) {
    throw new ApiError(409, "User with the same username or email already exists");
  }


  const user = await User.create({
    fullName,
    email,
    password,
    username: username.toLowerCase(),
  });

  const createdUser = await User.findById(user._id).select("-password -refreshToken");

  if (!createdUser) {
    throw new ApiError(500, "Error while creating user");
  }

  return res.status(201).json(
    new ApiResponse(201, createdUser, "User registered successfully")
  )

};

Why Remove Password & Refresh Token?

.select("-password -refreshToken")

Never send sensitive fields back to the client.

Even hashed passwords should never leave the backend.

Login Controller

export const loginUser = async (req: Request, res: Response) => {

  const { username, email, password } = req.body;

  if (!username && !email) {
    throw new ApiError(400, "Username or email is required");
  } 

  if (!password) {
    throw new ApiError(400, "Password is required");
  }

  const user = await User.findOne({
    $or: [{ username }, { email }]
  });

  if (!user) {
    throw new ApiError(404, "User not found");
  }

  const isPassValid: boolean = await user.isPasswordCorrect(password);
  if (!isPassValid) {
    throw new ApiError(401, "Invalid password");
  }

  const { accessToken, refreshToken } = await generateTokens(user._id);

  const userData = {
    _id: user._id,
    fullName: user.fullName,
    username: user.username,
    email: user.email,
    avatarUrl: user.avatarUrl,
  }

  return res
    .status(200)
    .cookie("accessToken", accessToken, cookieOptions)
    .cookie("refreshToken", refreshToken, cookieOptions)
    .json(
      new ApiResponse(200, {
        user: userData,
        accessToken,
        refreshToken
      }, "User logged in successfully")
    );
};

Why Use Cookies Instead of LocalStorage?

We use:

httpOnly: true

Benefits:

  • Protects against XSS attacks
  • JavaScript cannot access tokens
  • More secure than localStorage

Cookie Options

const cookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "strict" as const,
};

Verify JWT Middleware

src/middlewares/auth.middleware.ts

import type { NextFunction, Request, Response } from "express";
import { User } from "../models/User.model.js";
import { ApiError } from "../utils/ApiError.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import jwt, { type JwtPayload } from "jsonwebtoken";

export const verifyJWT = async (req: Request, _: Response, next: NextFunction) => {
  try {
    const accessToken = req.cookies?.accessToken || req.header("Authorization")?.replace("Bearer ", "");

    if (!accessToken) {
      throw new ApiError(401, "Access token is missing");
    }

    const decodedToken = jwt.verify(accessToken, process.env.ACCESS_TOKEN_SECRET!!) as JwtPayload;

    const user = await User.findById(decodedToken._id).select("-password -refreshToken");

    if (!user) {
      throw new ApiError(401, "Invalid Access Token");
    }

    req.user = user;
    next();
  } catch (error: any) {
    throw new ApiError(401,  error?.message || "Invalid Access Token");
  }
};

Why Check Database After JWT Verification?

Some tutorials only verify the JWT.

We additionally verify if the user still exists.

Benefits:

  • Prevents deleted-user access
  • Safer authentication
  • Better security

Refresh Access Token Controller

export const refreshAccessToken = async (req: Request, res: Response) => {
  const incomingRefreshToken = req.cookies.refreshToken || req.body.refreshToken

  if (!incomingRefreshToken) {
    throw new ApiError(401, "Refresh token is missing");
  }

  try {
    const decodedToken = jwt.verify(incomingRefreshToken, process.env.REFRESH_TOKEN_SECRET!!) as JwtPayload;

    const user = await User.findById(decodedToken?._id);

    if (!user) {
      throw new ApiError(401, "Invalid refresh token - user not found");
    }

    if (user.refreshToken !== incomingRefreshToken) { 
      throw new ApiError(401, "Invalid refresh token");
    }

    const { accessToken, refreshToken: newRefreshToken } = await generateTokens(user._id);

    return res
      .status(200)
      .cookie("accessToken", accessToken, cookieOptions)
      .cookie("refreshToken", newRefreshToken, cookieOptions)
      .json(
        new ApiResponse(200, {
          accessToken,
          refreshToken: newRefreshToken,
        }, "Access token refreshed successfully"
        )
      );
  } catch (err: any) {
    throw new ApiError(401, err?.message || "Invalid refresh token");
  }
};

Logout Controller

export const logoutUser = async (req: Request, res: Response) => {
  const userId = req.user?._id;

  if (!userId) {
    throw new ApiError(400, "User ID is required");
  }

  await User.findByIdAndUpdate(userId, {
    $unset: { refreshToken: 1 }
  }, { returnDocument: "after" });  

  return res
    .status(200)
    .clearCookie("accessToken", cookieOptions)  
    .clearCookie("refreshToken", cookieOptions)
    .json(new ApiResponse(200, {}, "User logged out successfully"));
};

Extend Express Request Types

src/@types/express/index.d.ts

import type { IUserDocument } from "../../models/User.model.js";

declare module "express-serve-static-core" {
  interface Request {
    user?: IUserDocument;
  }
}

export {};

tsconfig.json

{
  "compilerOptions": {
    "typeRoots": [
      "./node_modules/@types",
      "./src/@types"
    ]
  },

  "include": [
    "src/**/*",
    "src/@types/**/*.d.ts"
  ]
}

Final Thoughts

You now have a production-style JWT Authentication system with:

  • Access Tokens
  • Refresh Tokens
  • HTTP-only cookies
  • Protected routes
  • Password hashing
  • Token rotation
  • Secure logout

You can further improve this by adding:

  • Email verification
  • Password reset
  • Rate limiting
  • 2FA authentication
  • OAuth logins
  • Redis session storage

Happy coding 🚀

JWT Auth in Express with TS

Project Structure

└── src/
    ├── @types/
    ├── controllers/
    ├── middlewares/
    ├── models/
    ├── routes/
    ├── utils/
    ├── app.ts
    └── index.ts

Install Required Packages

npm install jsonwebtoken bcrypt mongoose cookie-parser
npm install -D @types/jsonwebtoken @types/bcrypt

Environment Variables

Create a .env file:

ACCESS_TOKEN_SECRET=
ACCESS_TOKEN_EXPIRY=

REFRESH_TOKEN_SECRET=
REFRESH_TOKEN_EXPIRY=

Custom ApiResponse & ApiError

Before building authentication, I use custom helper classes for consistent API responses and error handling.

Benefits:

  • Cleaner controller code
  • Consistent API structure
  • Easier frontend handling
  • Better debugging
  • Scalable architecture

ApiError Helper

src/utils/ApiError.ts

export class ApiError extends Error {
  statusCode: number;
  message: string;
  errors: any[];
  stack?: string;
  data: any;
  success: boolean;

  constructor(
    statusCode: number,
    message = "Something went wrong",
    errors = [],
    stack = ""
  ) {
    super(message)
    this.statusCode = statusCode
    this.data = null
    this.message = message
    this.success = false
    this.errors = errors

    if (stack) {
      this.stack = stack
    } else {
      Error.captureStackTrace(this, this.constructor)
    }
  }
}

ApiResponse Helper

src/utils/ApiResponse.ts

export class ApiResponse {
  statusCode: number;
  data: any;
  message: string;
  success: boolean;

  constructor(
    statusCode: number,
    data: any,
    message: string = "Success"
  ) {
    this.statusCode = statusCode
    this.data = data
    this.message = message
    this.success = statusCode < 400
  }
}

Why Use This Pattern?

Instead of manually writing responses like:

return res.status(200).json({
  success: true,
  message: "Logged in",
  data,
});

everywhere, helper classes keep responses standardized across the entire backend.

Why Use Access & Refresh Tokens?

A common mistake beginners make is using only one JWT token.

Instead, we use:

  • Access Token → short-lived authentication
  • Refresh Token → generates new access tokens

Benefits:

  • Better security
  • Users stay logged in
  • Easier session invalidation
  • Refresh token rotation support

This is the same architecture used in many production apps.

User Model

src/models/User.model.ts

import { model, Schema, type HydratedDocument } from "mongoose";
import jwt, { type Secret, type SignOptions } from "jsonwebtoken";
import bcrypt from "bcrypt";

export interface IUser {
  username: string;
  fullName: string;
  email: string;
  password: string;
  refreshToken?: string;

  generateAccessToken: () => string;
  generateRefreshToken: () => string;
  isPasswordCorrect: (password: string) => Promise<boolean>;
}

export type IUserDocument = HydratedDocument<IUser>;

const userSchema = new Schema<IUser>({
  username: String
  fullName: String
  email: String
  password: String
  refreshToken: String
});

userSchema.pre("save", async function (): Promise<void> {
  if (!this.isModified("password")) return;

  this.password = await bcrypt.hash(this.password, 10);
});

userSchema.methods.isPasswordCorrect = async function (
  password: string
): Promise<boolean> {
  return await bcrypt.compare(password, this.password);
};

userSchema.methods.generateAccessToken = function (): string {
  return jwt.sign(
    {
      _id: this._id,
      email: this.email,
      username: this.username,
      fullName: this.fullName,
    },
    process.env.ACCESS_TOKEN_SECRET!! as Secret,
    {
      expiresIn: process.env.ACCESS_TOKEN_EXPIRY!!,
    } as SignOptions
  );
};

userSchema.methods.generateRefreshToken = function (): string {
  return jwt.sign(
    {
      _id: this._id,
    },
    process.env.REFRESH_TOKEN_SECRET!! as Secret,
    {
      expiresIn: process.env.REFRESH_TOKEN_EXPIRY!!,
    } as SignOptions
  );
};

export const User = model<IUser>("User", userSchema);

Why Use Mongoose Methods?

Instead of creating separate utility functions, we attach methods directly to the schema.

Benefits:

  • Cleaner code
  • Better TypeScript support
  • Easier reusability
  • Logic stays close to the model

Password Hashing with Mongoose Middleware

userSchema.pre("save", async function (): Promise<void> {
  if (!this.isModified("password")) return;

  this.password = await bcrypt.hash(this.password, 10);
});

Why this is good:

  • Password hashing becomes automatic
  • Prevents accidental plaintext passwords
  • Avoids duplicate hashing logic

Compare Passwords

userSchema.methods.isPasswordCorrect = async function (
  password: string
): Promise<boolean> {
  return await bcrypt.compare(password, this.password);
};

We use bcrypt.compare() because hashed passwords cannot be decrypted.

Token Generation Helper

const generateTokens = async (userId: Types.ObjectId | string): Promise<{ accessToken: string; refreshToken: string }> => {
  try {
    const user = await User.findById(userId);
    if (!user) {
      throw new ApiError(404, "User not found");
    }

    const refreshToken = user.generateRefreshToken();
    const accessToken = user.generateAccessToken();

    user.refreshToken = refreshToken;
    await user.save({ validateBeforeSave: false });

    return { accessToken, refreshToken };
  } catch (err) {
    console.log(err)
    throw new ApiError(500, "Error while generating tokens");
  }
}

Why Store Refresh Tokens in Database?

Many tutorials skip this.

Storing refresh tokens allows us to:

  • Logout users properly
  • Invalidate sessions
  • Rotate refresh tokens
  • Detect token reuse attacks

This is much safer than purely stateless JWT auth.

Register User Controller

export const registerUser = async (req: Request, res: Response) => {
  const { username, email, fullName, password } = req.body;

  if (
    [fullName, email, username, password].some((field) => field?.trim() === "")
  ) {
    throw new ApiError(400, "All fields are required");
  }

  const userExists = await User.findOne({
    $or: [{ username }, { email }]
  });

  if (userExists) {
    throw new ApiError(409, "User with the same username or email already exists");
  }


  const user = await User.create({
    fullName,
    email,
    password,
    username: username.toLowerCase(),
  });

  const createdUser = await User.findById(user._id).select("-password -refreshToken");

  if (!createdUser) {
    throw new ApiError(500, "Error while creating user");
  }

  return res.status(201).json(
    new ApiResponse(201, createdUser, "User registered successfully")
  )

};

Why Remove Password & Refresh Token?

.select("-password -refreshToken")

Never send sensitive fields back to the client.

Even hashed passwords should never leave the backend.

Login Controller

export const loginUser = async (req: Request, res: Response) => {

  const { username, email, password } = req.body;

  if (!username && !email) {
    throw new ApiError(400, "Username or email is required");
  } 

  if (!password) {
    throw new ApiError(400, "Password is required");
  }

  const user = await User.findOne({
    $or: [{ username }, { email }]
  });

  if (!user) {
    throw new ApiError(404, "User not found");
  }

  const isPassValid: boolean = await user.isPasswordCorrect(password);
  if (!isPassValid) {
    throw new ApiError(401, "Invalid password");
  }

  const { accessToken, refreshToken } = await generateTokens(user._id);

  const userData = {
    _id: user._id,
    fullName: user.fullName,
    username: user.username,
    email: user.email,
    avatarUrl: user.avatarUrl,
  }

  return res
    .status(200)
    .cookie("accessToken", accessToken, cookieOptions)
    .cookie("refreshToken", refreshToken, cookieOptions)
    .json(
      new ApiResponse(200, {
        user: userData,
        accessToken,
        refreshToken
      }, "User logged in successfully")
    );
};

Why Use Cookies Instead of LocalStorage?

We use:

httpOnly: true

Benefits:

  • Protects against XSS attacks
  • JavaScript cannot access tokens
  • More secure than localStorage

Cookie Options

const cookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "strict" as const,
};

Verify JWT Middleware

src/middlewares/auth.middleware.ts

import type { NextFunction, Request, Response } from "express";
import { User } from "../models/User.model.js";
import { ApiError } from "../utils/ApiError.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import jwt, { type JwtPayload } from "jsonwebtoken";

export const verifyJWT = async (req: Request, _: Response, next: NextFunction) => {
  try {
    const accessToken = req.cookies?.accessToken || req.header("Authorization")?.replace("Bearer ", "");

    if (!accessToken) {
      throw new ApiError(401, "Access token is missing");
    }

    const decodedToken = jwt.verify(accessToken, process.env.ACCESS_TOKEN_SECRET!!) as JwtPayload;

    const user = await User.findById(decodedToken._id).select("-password -refreshToken");

    if (!user) {
      throw new ApiError(401, "Invalid Access Token");
    }

    req.user = user;
    next();
  } catch (error: any) {
    throw new ApiError(401,  error?.message || "Invalid Access Token");
  }
};

Why Check Database After JWT Verification?

Some tutorials only verify the JWT.

We additionally verify if the user still exists.

Benefits:

  • Prevents deleted-user access
  • Safer authentication
  • Better security

Refresh Access Token Controller

export const refreshAccessToken = async (req: Request, res: Response) => {
  const incomingRefreshToken = req.cookies.refreshToken || req.body.refreshToken

  if (!incomingRefreshToken) {
    throw new ApiError(401, "Refresh token is missing");
  }

  try {
    const decodedToken = jwt.verify(incomingRefreshToken, process.env.REFRESH_TOKEN_SECRET!!) as JwtPayload;

    const user = await User.findById(decodedToken?._id);

    if (!user) {
      throw new ApiError(401, "Invalid refresh token - user not found");
    }

    if (user.refreshToken !== incomingRefreshToken) { 
      throw new ApiError(401, "Invalid refresh token");
    }

    const { accessToken, refreshToken: newRefreshToken } = await generateTokens(user._id);

    return res
      .status(200)
      .cookie("accessToken", accessToken, cookieOptions)
      .cookie("refreshToken", newRefreshToken, cookieOptions)
      .json(
        new ApiResponse(200, {
          accessToken,
          refreshToken: newRefreshToken,
        }, "Access token refreshed successfully"
        )
      );
  } catch (err: any) {
    throw new ApiError(401, err?.message || "Invalid refresh token");
  }
};

Logout Controller

export const logoutUser = async (req: Request, res: Response) => {
  const userId = req.user?._id;

  if (!userId) {
    throw new ApiError(400, "User ID is required");
  }

  await User.findByIdAndUpdate(userId, {
    $unset: { refreshToken: 1 }
  }, { returnDocument: "after" });  

  return res
    .status(200)
    .clearCookie("accessToken", cookieOptions)  
    .clearCookie("refreshToken", cookieOptions)
    .json(new ApiResponse(200, {}, "User logged out successfully"));
};

Extend Express Request Types

src/@types/express/index.d.ts

import type { IUserDocument } from "../../models/User.model.js";

declare module "express-serve-static-core" {
  interface Request {
    user?: IUserDocument;
  }
}

export {};

tsconfig.json

{
  "compilerOptions": {
    "typeRoots": [
      "./node_modules/@types",
      "./src/@types"
    ]
  },

  "include": [
    "src/**/*",
    "src/@types/**/*.d.ts"
  ]
}

Final Thoughts

You now have a production-style JWT Authentication system with:

  • Access Tokens
  • Refresh Tokens
  • HTTP-only cookies
  • Protected routes
  • Password hashing
  • Token rotation
  • Secure logout

You can further improve this by adding:

  • Email verification
  • Password reset
  • Rate limiting
  • 2FA authentication
  • OAuth logins
  • Redis session storage

Happy coding 🚀

Drawing the Blueprint: Flowchart, Functional Diagram, and Sequence Diagram

Figure 1. The Foundation Is Ready

Lesson 3 of Build a Twitter Clone – A Practical Guide to Software Modelling

In Lesson 2, we defined what Bird must remember: two databases, tables within, every field traced back to a use case. Now it’s time to draw what Bird must do. Using "Post a message" as our single worked example, we construct all three diagrams in sequence – first a flowchart that traces the user action step by step, then a functional diagram that reads the required components directly off that flowchart, then a sequence diagram that shows exactly how those components talk. By the end, the use case that started as a sentence on a sticky note has a complete, traceable blueprint.

Introduction – The Foundation Is Ready

Lesson 2 ended with a promise, and it’s worth quoting precisely:

“That’s where Lesson 3 picks up: with the data model as the foundation, we draw all three diagrams.”

That moment has arrived. But it’s worth pausing for a second to notice what’s different now compared to where we started.

In Lesson 1, we introduced the idea of three diagrams – a flowchart, a functional diagram, and a sequence diagram – as three lenses on a single system. We explained the mental model, argued for the order, and left it there. No diagrams were actually drawn, and that was deliberate: a diagram drawn before the project is bounded and the data understood is a diagram you’ll redraw.

Figure 1. The Foundation Is ReadyFigure 1. The Foundation Is Ready

In Lesson 2, we did the bounding. We read Bird‘s three use cases for data clues, named two entities (User and Message), defined five tables across two databases (ums and twitter), and established the relationships that tie them together. The schema is real now: a messages row has a specific shape – an id, a content field capped at 280 characters, an author_idpointing to a row in ums.users, and a created_at timestamp. The users row has a shape too. Every “save the message” step in every diagram we draw will refer to something concrete.

That concreteness is what changes everything. A diagram drawn on top of a real data model isn’t a sketch – it’s a specification. The boxes mean something. The arrows carry named fields. When a step says “validate the message,” we already know what the validation is checking (which is called validation criteria): a non-empty content string, no longer than 280 characters, attached to an authenticated author_id. Nothing is left as a placeholder.

This lesson draws all three diagrams. We’ll work in order – behaviour first, then the structure that behaviour implies, then the detailed conversation inside that structure – for the same reason we always work in that order: each diagram is the input to the next, and the input has to exist before the output can be correct.

A Quick Reminder: The Three-Lens Progression

Before we pick up the pen, one short bridge back to Lesson 1 – not a full re-teach, just enough to make sure the scaffold is standing.

The core idea is this: any software system can be understood from three distinct angles, and each angle demands a different kind of diagram.

Lens The question it answers The diagram What you see
Behaviour What should the app do? Flowchart Steps, decisions, and outcomes – the system as a process unfolding in time
Structure What parts must exist to make that behaviour happen? Functional diagram Components and their connections – the system as a thing made of pieces
Interaction How do those parts talk to each other at runtime? Sequence diagram Messages exchanged between components – the system as a conversation

None of these is more correct than the others. A flowchart can’t tell you what components to build. A functional diagram can’t tell you what happens when a user submits an empty message. A sequence diagram can’t tell you whether you’ve correctly scoped the feature. They are complementary, not competing – and you need all three because no single one is complete.

The critical habit is the order. You move behaviour → structure → interaction, and each step is derived from the one before:

Figure 2. The three-lens progression - each diagram derives from the one beforeFigure 2. The three-lens progression – each diagram derives from the one before

The reason the order is fixed: you can’t know what components to build until you know what the system must do. And you can’t describe the conversation between components until you know which components exist. Behaviour is the requirement; structure serves it; interaction lives inside structure.

An analogy for the progression. Imagine designing a kitchen. First you decide what the kitchen must do– prepare meals, store food, clean dishes. That’s behaviour. Then you ask what parts must exist to support each of those tasks: a cooktop, a refrigerator, a sink. That’s structure, falling out of behaviour. Finally, you trace the specific workflow – the chef moves from the refrigerator to the prep surface to the cooktop, in that order, passing ingredients along. That’s interaction, living inside the structure. You couldn’t have designed the workflow without knowing the layout. You couldn’t have designed the layout without knowing the tasks. The order is the method.

Lens 1: Behaviour – Drawing the Flowchart

A flowchart answers one question: what does the system do, step by step? Not what parts it has. Not which component calls which. Just the sequence of actions and decisions that carry a user from “I want to post a message” to “it’s done” – or to an error, if something goes wrong along the way.

That makes the flowchart the right place to start. It captures the requirement in its purest form, before any implementation choices have been made.

Building it step by step

Step 1 – Name the start and end points.

The flow begins the moment the user presses “Post” – that is, when the system receives a submission. It ends either with a new row in twitter.messages and a confirmation to the user, or with an error message and no row written. Name those endpoints first, before filling in anything in between.

Step 2 – List the actions in order.

Walk the happy path first – what happens when everything goes right? For “Post a message,” that sequence is:

  1. Submit a message
  2. Pass the authentication check – is the user logged in?
  3. Pass the permissions check – is the user allowed to post?
  4. Pass content validation – is the message valid?
  5. Save the message to the database
  6. Confirm success to the user

Six steps. Two are actions, three are decision gates, one is a confirmation. That’s a healthy breakdown – the logic is explicit without becoming an implementation manual.

Step 3 – Add the decision points.

Three decision diamonds gate the happy path:

  • Is the user logged in? If no → redirect to the login page. After a successful login the user is returned to the submission step – a loop, not a dead end.
  • Is the user allowed to post? Not every account has posting rights – a read-only or suspended role is blocked here. If no → shared Error node → END.
  • Is the message valid? (Non-empty? Within 280 characters?) If invalid → shared Error node → END.

Notice that the two error branches converge on a single Error node before reaching END. This is an intentional design choice in the diagram: the system’s response to a permissions failure and a content failure is the same kind of thing – an error returned to the user. Merging them keeps the diagram clean and makes that equivalence visible.

Step 4 – Connect and label.

Arrange the steps top to bottom, connect them with arrows, and label every arrow leaving a decision diamond. The labels on the branches (“yes / no”, or more descriptively “valid / invalid”) are what make the diagram readable at a glance.

The flowchart

Figure 3. Flowchart for Figure 3. Flowchart for “Post a message”

What this diagram tells us – and what it doesn’t

The flowchart is intentionally silent on how each step is implemented. It doesn’t say which component checks authentication, or what library validates the string length, or whether the database write is synchronous. Those details don’t belong here. What the flowchart does – and does well – is make the logic visible: the decisions, the branches, the outcomes. That’s the entire job of Lens 1.

Notice that the data model already sharpens the language. The “Save message” step doesn’t just say “save the message”– it names the fields: author_id, content, created_at, writing into twitter.messages. That specificity comes directly from Lesson 2. The flowchart is still high-level, but it’s no longer vague.

With the behaviour fully drawn, the next question asks itself: what parts must exist to make each of these steps happen? That’s Lens 2 – and the answer comes directly from reading this flowchart.

Lens 2: Structure – Reading the Functional Diagram off the Flowchart

The flowchart is done. We know what “Post a message” must do – the steps, the decisions, the branches. Now a different question takes over: what parts must exist inside the system to make each of those steps happen?

This is where most beginners get into trouble. They open a new diagram and start inventing components – a generic AuthService, an APIGateway, a DatabaseLayer – based on instinct or prior experience. The result is a diagram that reflects what the developer already knew, not what the use case actually requires.

There is a better method. The functional diagram doesn’t need to be invented. It can be read directly off the flowchart.

The method: scan every step, ask one question

For each step in the flowchart, ask: what component must own this responsibility? The answer names a block. Do that for every step, merge the ones that belong together, and the structure falls out of the behaviour.

Let’s walk through the flowchart step by step.

Flowchart step Responsibility Component
User submits message Sends the HTTP request Frontend Client
Is the user logged in? Identifies who is making the request Twitter Service
Redirect to login Returns the login page Frontend Client
Is the user allowed to post? Fetches the user record and checks for the PRODUCERrole Twitter Service → UMS Service
Is the message valid? Enforces content rules (non-empty, ≤ 280 chars) Twitter Service
Save message Writes the row to twitter.messages Twitter Service → MySQL Database
Confirm success Returns 201 with the created message Twitter Service → Frontend Client
Return error Returns 403 Forbidden to the client Twitter Service → Frontend Client

Two things stand out immediately. First, the Twitter Service carries the most weight: it receives the request, performs content validation, and orchestrates the database write. But it doesn’t hold user data – that lives in UMS. So for the permission check, the Twitter Service calls the UMS Service via an internal HTTP request, retrieves the user’s roles, and decides whether to proceed. This cross-service call is the architectural heart of the feature.

Second, there is no separate API Gateway or Auth Service in Bird. The Frontend Client talks directly to the Twitter Service on port 9001. Role checking is a responsibility of the Twitter Service itself, delegated to UMS on demand – not a standalone component sitting in between.

This gives us four components: the Frontend Client, the Twitter Service, the UMS Service, and MySQL Database.

The functional diagram

Figure 4. Functional diagram for  raw `Bird` endraw Figure 4. Functional diagram for Bird

The diagram reads as a set of named blocks connected by labelled arrows. The arrows carry the nature of the relationship – not the step-by-step logic (that’s the flowchart’s job) but the structural dependency: the Frontend Client sends requests to the Twitter Service; the Twitter Service calls UMS to validate the user; the Twitter Service reads and writes MySQL directly. Each arrow represents a real, named communication channel in the codebase – HTTP on :9001 for the UI-to-service call, WebClient for the service-to-service call, JDBC for the database connection.

What this diagram is – and isn’t

A functional diagram is a map of components, not a map of steps. It answers “what pieces exist and how are they connected?” – not “what happens first?” The flowchart and the functional diagram are not redundant; they answer different questions and each is incomplete without the other.

Notice also what the functional diagram deliberately omits: there are no arrows labelled with field names, no decision branches, no error paths, no sequence numbers. Those details live in the sequence diagram. Here, the goal is structural clarity – a reader should be able to see at a glance what Bird is made of and how the pieces relate.

With the components named and connected, one question remains: how exactly do they talk to each other during “Post a message”? That’s the sequence diagram – and now that we know which components exist, we can finally draw it precisely.

Lens 3: Interaction – The Sequence Diagram

The flowchart told us what “Post a message” does. The functional diagram told us what components exist to do it. The sequence diagram answers the last question: what do those components say to each other, in what order, when the feature actually runs?

This is where the previous two diagrams earn their investment. Every component named in the functional diagram becomes a lifeline. Every step in the flowchart becomes a message on one of those lifelines. Nothing is invented – it’s assembled from what we already know.

From components to lifelines

A sequence diagram is built around lifelines – vertical columns, one per component, representing each participant in the interaction. Reading the functional diagram directly, our lifelines are:

  • Frontend Client – the browser, initiating the request
  • MessageController – the entry point inside the Twitter Service, receiving the HTTP call
  • MessagesService – the orchestrator: calls UMS, checks roles, triggers the database write
  • UMSConnector – the thin HTTP client that talks to UMS via WebClient
  • UMS Service – the external service that owns user identity and roles
  • JdbcMessageRepository – the DAO that executes the SQL insert
  • MySQL – the database that persists the row

Reading the flow

The messages between lifelines follow the exact path the codebase executes. Let’s trace it:

  1. The Frontend Client sends POST /messages/message with { author, content } to the MessageController.
  2. MessageController deserializes the body into a Message DTO and delegates to MessagesService via createMessage(message).
  3. MessagesService needs to verify the author – it doesn’t hold user data, so it calls UMSConnector with retrieveUmsData(/users/user/{id}).
  4. UMSConnector fires a non-blocking WebClient GET to the UMS Service.
  5. UMS Service returns a UserDto containing the user’s roles.
  6. MessagesService unpacks the response via HttpResponseExtractor and inspects the Roles. If the user is not a PRODUCER – the flow terminates here with a 403 Forbidden back to the client.
  7. If the role check passes, MessagesService calls createMessage(message) on JdbcMessageRepository.
  8. JdbcMessageRepository executes INSERT INTO messages against MySQL (converting the UUID via DaoHelper).
  9. MySQL returns ok.
  10. The saved Message travels back up: JdbcMessageRepository → MessagesService → MessageController → Frontend Client as 201 { message }.

The sequence diagram

Figure 5. Sequence diagram for Figure 5. Sequence diagram for “Post a message”

What this diagram shows that the others couldn’t

Look at the alt block in the middle of the diagram. That’s the permission check – the same diamond in the flowchart, the same role-checking responsibility assigned to MessagesService in the functional diagram. But now it has an exact form: a comparison against the PRODUCER role, returning 403 Forbidden if it fails, with the response passing back through MessageController to the client.

This is traceability working in practice. The validation step in the flowchart → the MessagesService block in the functional diagram → the HttpResponseExtractor → User → Roles call chain in the sequence diagram. One idea, three levels of resolution.

Notice also what the sequence diagram adds that neither previous diagram could: the reactive chain. The UMSConnectorcall is non-blocking – MessagesService issues the WebClient request and the rest of the chain executes inside a .flatMap(). The sequence diagram makes this visible through the activation boxes: MS stays active while UC and UMS do their work, then resumes. That concurrency detail is invisible in both the flowchart and the functional diagram.

With all three diagrams drawn, one use case – “Post a message” – now has a complete, layered blueprint. The next section puts all three side by side and shows what traceability looks like end to end.

Let’s pause before moving on and do something the three diagrams individually don’t do: put them side by side and trace a single idea across all of them.

Take the permission check – the moment the system decides whether the author is allowed to post. Here is where it lives in each diagram:

Diagram Where the permission check appears What you can see
Flowchart The diamond “Is user allowed to post?” branching to Erroror continuing to validation The logic – two outcomes, one decision
Functional diagram The arrow from MessagesService labelled “check roles contains PRODUCER” pointing to Roles The ownership – which component holds this responsibility
Sequence diagram The alt block: HttpResponseExtractor → User → Roles, branching to 403 Forbidden or INSERT The mechanics – what data moves, in what order, producing what result

Same check. Three levels of resolution. You could point at the diamond in the flowchart, follow it to the MessagesServicearrow in the functional diagram, and land on the alt block in the sequence diagram – and at each step you’d learn something the previous diagram couldn’t tell you.

That property is traceability: the ability to follow a single requirement from its highest-level expression down to its lowest-level implementation without losing the thread. It’s what separates a set of diagrams that decorate a document from a set that actually functions as a blueprint.

Figure 6. One feature, three views - the permission check traced across all three diagramsFigure 6. One feature, three views – the permission check traced across all three diagrams

Notice also what traceability reveals in the other direction. The sequence diagram shows that the 403 Forbidden response travels back through MessageController before reaching the client – a detail invisible in both the flowchart (which just says “Error → END”) and the functional diagram (which shows MessageController connected to GlobalExceptionHandler without explaining when). The diagrams don’t just repeat each other. Each one completes the picture in a direction the others leave open.

Limitations and Open Questions

Three diagrams drawn, one use case traced. Before closing, it’s worth being clear about what this process is – and what it isn’t.

Diagrams go stale. Every diagram in this lesson reflects the codebase as it stands today. The moment a new field is added to messages, or the permission model changes, or UMSConnector is replaced by a shared auth library – the diagrams need updating. A diagram that isn’t maintained becomes misleading faster than no diagram at all. The discipline of drawing diagrams is only half the work; the other half is keeping them honest.

This is a snapshot, not a specification. The diagrams here describe what Bird does, not what it must do. A specification would go further – stating invariants, error contracts, performance bounds, and edge cases. These diagrams are the foundation of a spec, not the spec itself. They answer “how does it work?” not “how must it always work?”

Over-modelling is a real risk. Drawing all three diagrams for every feature in a large system would consume more time than it saves. The three-lens approach is most valuable at decision points: when designing a new feature from scratch, when onboarding someone to an unfamiliar part of the system, or when debugging behaviour that contradicts the mental model. Applied indiscriminately, it becomes ceremony rather than engineering.

These diagrams are not UML. Readers familiar with the Unified Modelling Language (UML) will notice that the diagrams in this lesson borrow UML conventions – sequence diagram lifelines, flowchart decision diamonds – without following the full UML standard. That’s deliberate. UML is precise and complete, and its precision comes at the cost of accessibility. However when you move to production systems with formal specification requirements, UML or C4 may be the right choice.

What’s Next

Three lessons in, and Bird has a bounded scope, a real data model, and a complete three-lens blueprint for its core feature.

What it doesn’t have yet is a working API. The diagrams show that a POST /messages/message endpoint should exist, that it must accept { author, content }, that it must call UMS before writing to the database – but none of that is running code yet.

Lesson 4 is where the diagrams meet the wire. We’ll define the API contracts and endpoints that connect Bird‘s components – what each service exposes, what it expects, and what it returns. The sequence diagram already told us that POST /messages/message must exist, that it calls GET /users/user/{id} on UMS, and that both exchanges carry specific payloads. Lesson 4 makes those contracts explicit and formal: request shapes, response shapes, status codes, and the communication protocol between the Twitter Service and UMS.

The diagrams were never the destination. They were the clearest possible way to know what to build before building it.

Drawing the Blueprint: Flowchart, Functional Diagram, and Sequence Diagram

Figure 1. The Foundation Is Ready

Lesson 3 of Build a Twitter Clone – A Practical Guide to Software Modelling

In Lesson 2, we defined what Bird must remember: two databases, tables within, every field traced back to a use case. Now it’s time to draw what Bird must do. Using "Post a message" as our single worked example, we construct all three diagrams in sequence – first a flowchart that traces the user action step by step, then a functional diagram that reads the required components directly off that flowchart, then a sequence diagram that shows exactly how those components talk. By the end, the use case that started as a sentence on a sticky note has a complete, traceable blueprint.

Introduction – The Foundation Is Ready

Lesson 2 ended with a promise, and it’s worth quoting precisely:

“That’s where Lesson 3 picks up: with the data model as the foundation, we draw all three diagrams.”

That moment has arrived. But it’s worth pausing for a second to notice what’s different now compared to where we started.

In Lesson 1, we introduced the idea of three diagrams – a flowchart, a functional diagram, and a sequence diagram – as three lenses on a single system. We explained the mental model, argued for the order, and left it there. No diagrams were actually drawn, and that was deliberate: a diagram drawn before the project is bounded and the data understood is a diagram you’ll redraw.

Figure 1. The Foundation Is ReadyFigure 1. The Foundation Is Ready

In Lesson 2, we did the bounding. We read Bird‘s three use cases for data clues, named two entities (User and Message), defined five tables across two databases (ums and twitter), and established the relationships that tie them together. The schema is real now: a messages row has a specific shape – an id, a content field capped at 280 characters, an author_idpointing to a row in ums.users, and a created_at timestamp. The users row has a shape too. Every “save the message” step in every diagram we draw will refer to something concrete.

That concreteness is what changes everything. A diagram drawn on top of a real data model isn’t a sketch – it’s a specification. The boxes mean something. The arrows carry named fields. When a step says “validate the message,” we already know what the validation is checking (which is called validation criteria): a non-empty content string, no longer than 280 characters, attached to an authenticated author_id. Nothing is left as a placeholder.

This lesson draws all three diagrams. We’ll work in order – behaviour first, then the structure that behaviour implies, then the detailed conversation inside that structure – for the same reason we always work in that order: each diagram is the input to the next, and the input has to exist before the output can be correct.

A Quick Reminder: The Three-Lens Progression

Before we pick up the pen, one short bridge back to Lesson 1 – not a full re-teach, just enough to make sure the scaffold is standing.

The core idea is this: any software system can be understood from three distinct angles, and each angle demands a different kind of diagram.

Lens The question it answers The diagram What you see
Behaviour What should the app do? Flowchart Steps, decisions, and outcomes – the system as a process unfolding in time
Structure What parts must exist to make that behaviour happen? Functional diagram Components and their connections – the system as a thing made of pieces
Interaction How do those parts talk to each other at runtime? Sequence diagram Messages exchanged between components – the system as a conversation

None of these is more correct than the others. A flowchart can’t tell you what components to build. A functional diagram can’t tell you what happens when a user submits an empty message. A sequence diagram can’t tell you whether you’ve correctly scoped the feature. They are complementary, not competing – and you need all three because no single one is complete.

The critical habit is the order. You move behaviour → structure → interaction, and each step is derived from the one before:

Figure 2. The three-lens progression - each diagram derives from the one beforeFigure 2. The three-lens progression – each diagram derives from the one before

The reason the order is fixed: you can’t know what components to build until you know what the system must do. And you can’t describe the conversation between components until you know which components exist. Behaviour is the requirement; structure serves it; interaction lives inside structure.

An analogy for the progression. Imagine designing a kitchen. First you decide what the kitchen must do– prepare meals, store food, clean dishes. That’s behaviour. Then you ask what parts must exist to support each of those tasks: a cooktop, a refrigerator, a sink. That’s structure, falling out of behaviour. Finally, you trace the specific workflow – the chef moves from the refrigerator to the prep surface to the cooktop, in that order, passing ingredients along. That’s interaction, living inside the structure. You couldn’t have designed the workflow without knowing the layout. You couldn’t have designed the layout without knowing the tasks. The order is the method.

Lens 1: Behaviour – Drawing the Flowchart

A flowchart answers one question: what does the system do, step by step? Not what parts it has. Not which component calls which. Just the sequence of actions and decisions that carry a user from “I want to post a message” to “it’s done” – or to an error, if something goes wrong along the way.

That makes the flowchart the right place to start. It captures the requirement in its purest form, before any implementation choices have been made.

Building it step by step

Step 1 – Name the start and end points.

The flow begins the moment the user presses “Post” – that is, when the system receives a submission. It ends either with a new row in twitter.messages and a confirmation to the user, or with an error message and no row written. Name those endpoints first, before filling in anything in between.

Step 2 – List the actions in order.

Walk the happy path first – what happens when everything goes right? For “Post a message,” that sequence is:

  1. Submit a message
  2. Pass the authentication check – is the user logged in?
  3. Pass the permissions check – is the user allowed to post?
  4. Pass content validation – is the message valid?
  5. Save the message to the database
  6. Confirm success to the user

Six steps. Two are actions, three are decision gates, one is a confirmation. That’s a healthy breakdown – the logic is explicit without becoming an implementation manual.

Step 3 – Add the decision points.

Three decision diamonds gate the happy path:

  • Is the user logged in? If no → redirect to the login page. After a successful login the user is returned to the submission step – a loop, not a dead end.
  • Is the user allowed to post? Not every account has posting rights – a read-only or suspended role is blocked here. If no → shared Error node → END.
  • Is the message valid? (Non-empty? Within 280 characters?) If invalid → shared Error node → END.

Notice that the two error branches converge on a single Error node before reaching END. This is an intentional design choice in the diagram: the system’s response to a permissions failure and a content failure is the same kind of thing – an error returned to the user. Merging them keeps the diagram clean and makes that equivalence visible.

Step 4 – Connect and label.

Arrange the steps top to bottom, connect them with arrows, and label every arrow leaving a decision diamond. The labels on the branches (“yes / no”, or more descriptively “valid / invalid”) are what make the diagram readable at a glance.

The flowchart

Figure 3. Flowchart for Figure 3. Flowchart for “Post a message”

What this diagram tells us – and what it doesn’t

The flowchart is intentionally silent on how each step is implemented. It doesn’t say which component checks authentication, or what library validates the string length, or whether the database write is synchronous. Those details don’t belong here. What the flowchart does – and does well – is make the logic visible: the decisions, the branches, the outcomes. That’s the entire job of Lens 1.

Notice that the data model already sharpens the language. The “Save message” step doesn’t just say “save the message”– it names the fields: author_id, content, created_at, writing into twitter.messages. That specificity comes directly from Lesson 2. The flowchart is still high-level, but it’s no longer vague.

With the behaviour fully drawn, the next question asks itself: what parts must exist to make each of these steps happen? That’s Lens 2 – and the answer comes directly from reading this flowchart.

Lens 2: Structure – Reading the Functional Diagram off the Flowchart

The flowchart is done. We know what “Post a message” must do – the steps, the decisions, the branches. Now a different question takes over: what parts must exist inside the system to make each of those steps happen?

This is where most beginners get into trouble. They open a new diagram and start inventing components – a generic AuthService, an APIGateway, a DatabaseLayer – based on instinct or prior experience. The result is a diagram that reflects what the developer already knew, not what the use case actually requires.

There is a better method. The functional diagram doesn’t need to be invented. It can be read directly off the flowchart.

The method: scan every step, ask one question

For each step in the flowchart, ask: what component must own this responsibility? The answer names a block. Do that for every step, merge the ones that belong together, and the structure falls out of the behaviour.

Let’s walk through the flowchart step by step.

Flowchart step Responsibility Component
User submits message Sends the HTTP request Frontend Client
Is the user logged in? Identifies who is making the request Twitter Service
Redirect to login Returns the login page Frontend Client
Is the user allowed to post? Fetches the user record and checks for the PRODUCERrole Twitter Service → UMS Service
Is the message valid? Enforces content rules (non-empty, ≤ 280 chars) Twitter Service
Save message Writes the row to twitter.messages Twitter Service → MySQL Database
Confirm success Returns 201 with the created message Twitter Service → Frontend Client
Return error Returns 403 Forbidden to the client Twitter Service → Frontend Client

Two things stand out immediately. First, the Twitter Service carries the most weight: it receives the request, performs content validation, and orchestrates the database write. But it doesn’t hold user data – that lives in UMS. So for the permission check, the Twitter Service calls the UMS Service via an internal HTTP request, retrieves the user’s roles, and decides whether to proceed. This cross-service call is the architectural heart of the feature.

Second, there is no separate API Gateway or Auth Service in Bird. The Frontend Client talks directly to the Twitter Service on port 9001. Role checking is a responsibility of the Twitter Service itself, delegated to UMS on demand – not a standalone component sitting in between.

This gives us four components: the Frontend Client, the Twitter Service, the UMS Service, and MySQL Database.

The functional diagram

Figure 4. Functional diagram for  raw `Bird` endraw Figure 4. Functional diagram for Bird

The diagram reads as a set of named blocks connected by labelled arrows. The arrows carry the nature of the relationship – not the step-by-step logic (that’s the flowchart’s job) but the structural dependency: the Frontend Client sends requests to the Twitter Service; the Twitter Service calls UMS to validate the user; the Twitter Service reads and writes MySQL directly. Each arrow represents a real, named communication channel in the codebase – HTTP on :9001 for the UI-to-service call, WebClient for the service-to-service call, JDBC for the database connection.

What this diagram is – and isn’t

A functional diagram is a map of components, not a map of steps. It answers “what pieces exist and how are they connected?” – not “what happens first?” The flowchart and the functional diagram are not redundant; they answer different questions and each is incomplete without the other.

Notice also what the functional diagram deliberately omits: there are no arrows labelled with field names, no decision branches, no error paths, no sequence numbers. Those details live in the sequence diagram. Here, the goal is structural clarity – a reader should be able to see at a glance what Bird is made of and how the pieces relate.

With the components named and connected, one question remains: how exactly do they talk to each other during “Post a message”? That’s the sequence diagram – and now that we know which components exist, we can finally draw it precisely.

Lens 3: Interaction – The Sequence Diagram

The flowchart told us what “Post a message” does. The functional diagram told us what components exist to do it. The sequence diagram answers the last question: what do those components say to each other, in what order, when the feature actually runs?

This is where the previous two diagrams earn their investment. Every component named in the functional diagram becomes a lifeline. Every step in the flowchart becomes a message on one of those lifelines. Nothing is invented – it’s assembled from what we already know.

From components to lifelines

A sequence diagram is built around lifelines – vertical columns, one per component, representing each participant in the interaction. Reading the functional diagram directly, our lifelines are:

  • Frontend Client – the browser, initiating the request
  • MessageController – the entry point inside the Twitter Service, receiving the HTTP call
  • MessagesService – the orchestrator: calls UMS, checks roles, triggers the database write
  • UMSConnector – the thin HTTP client that talks to UMS via WebClient
  • UMS Service – the external service that owns user identity and roles
  • JdbcMessageRepository – the DAO that executes the SQL insert
  • MySQL – the database that persists the row

Reading the flow

The messages between lifelines follow the exact path the codebase executes. Let’s trace it:

  1. The Frontend Client sends POST /messages/message with { author, content } to the MessageController.
  2. MessageController deserializes the body into a Message DTO and delegates to MessagesService via createMessage(message).
  3. MessagesService needs to verify the author – it doesn’t hold user data, so it calls UMSConnector with retrieveUmsData(/users/user/{id}).
  4. UMSConnector fires a non-blocking WebClient GET to the UMS Service.
  5. UMS Service returns a UserDto containing the user’s roles.
  6. MessagesService unpacks the response via HttpResponseExtractor and inspects the Roles. If the user is not a PRODUCER – the flow terminates here with a 403 Forbidden back to the client.
  7. If the role check passes, MessagesService calls createMessage(message) on JdbcMessageRepository.
  8. JdbcMessageRepository executes INSERT INTO messages against MySQL (converting the UUID via DaoHelper).
  9. MySQL returns ok.
  10. The saved Message travels back up: JdbcMessageRepository → MessagesService → MessageController → Frontend Client as 201 { message }.

The sequence diagram

Figure 5. Sequence diagram for Figure 5. Sequence diagram for “Post a message”

What this diagram shows that the others couldn’t

Look at the alt block in the middle of the diagram. That’s the permission check – the same diamond in the flowchart, the same role-checking responsibility assigned to MessagesService in the functional diagram. But now it has an exact form: a comparison against the PRODUCER role, returning 403 Forbidden if it fails, with the response passing back through MessageController to the client.

This is traceability working in practice. The validation step in the flowchart → the MessagesService block in the functional diagram → the HttpResponseExtractor → User → Roles call chain in the sequence diagram. One idea, three levels of resolution.

Notice also what the sequence diagram adds that neither previous diagram could: the reactive chain. The UMSConnectorcall is non-blocking – MessagesService issues the WebClient request and the rest of the chain executes inside a .flatMap(). The sequence diagram makes this visible through the activation boxes: MS stays active while UC and UMS do their work, then resumes. That concurrency detail is invisible in both the flowchart and the functional diagram.

With all three diagrams drawn, one use case – “Post a message” – now has a complete, layered blueprint. The next section puts all three side by side and shows what traceability looks like end to end.

Let’s pause before moving on and do something the three diagrams individually don’t do: put them side by side and trace a single idea across all of them.

Take the permission check – the moment the system decides whether the author is allowed to post. Here is where it lives in each diagram:

Diagram Where the permission check appears What you can see
Flowchart The diamond “Is user allowed to post?” branching to Erroror continuing to validation The logic – two outcomes, one decision
Functional diagram The arrow from MessagesService labelled “check roles contains PRODUCER” pointing to Roles The ownership – which component holds this responsibility
Sequence diagram The alt block: HttpResponseExtractor → User → Roles, branching to 403 Forbidden or INSERT The mechanics – what data moves, in what order, producing what result

Same check. Three levels of resolution. You could point at the diamond in the flowchart, follow it to the MessagesServicearrow in the functional diagram, and land on the alt block in the sequence diagram – and at each step you’d learn something the previous diagram couldn’t tell you.

That property is traceability: the ability to follow a single requirement from its highest-level expression down to its lowest-level implementation without losing the thread. It’s what separates a set of diagrams that decorate a document from a set that actually functions as a blueprint.

Figure 6. One feature, three views - the permission check traced across all three diagramsFigure 6. One feature, three views – the permission check traced across all three diagrams

Notice also what traceability reveals in the other direction. The sequence diagram shows that the 403 Forbidden response travels back through MessageController before reaching the client – a detail invisible in both the flowchart (which just says “Error → END”) and the functional diagram (which shows MessageController connected to GlobalExceptionHandler without explaining when). The diagrams don’t just repeat each other. Each one completes the picture in a direction the others leave open.

Limitations and Open Questions

Three diagrams drawn, one use case traced. Before closing, it’s worth being clear about what this process is – and what it isn’t.

Diagrams go stale. Every diagram in this lesson reflects the codebase as it stands today. The moment a new field is added to messages, or the permission model changes, or UMSConnector is replaced by a shared auth library – the diagrams need updating. A diagram that isn’t maintained becomes misleading faster than no diagram at all. The discipline of drawing diagrams is only half the work; the other half is keeping them honest.

This is a snapshot, not a specification. The diagrams here describe what Bird does, not what it must do. A specification would go further – stating invariants, error contracts, performance bounds, and edge cases. These diagrams are the foundation of a spec, not the spec itself. They answer “how does it work?” not “how must it always work?”

Over-modelling is a real risk. Drawing all three diagrams for every feature in a large system would consume more time than it saves. The three-lens approach is most valuable at decision points: when designing a new feature from scratch, when onboarding someone to an unfamiliar part of the system, or when debugging behaviour that contradicts the mental model. Applied indiscriminately, it becomes ceremony rather than engineering.

These diagrams are not UML. Readers familiar with the Unified Modelling Language (UML) will notice that the diagrams in this lesson borrow UML conventions – sequence diagram lifelines, flowchart decision diamonds – without following the full UML standard. That’s deliberate. UML is precise and complete, and its precision comes at the cost of accessibility. However when you move to production systems with formal specification requirements, UML or C4 may be the right choice.

What’s Next

Three lessons in, and Bird has a bounded scope, a real data model, and a complete three-lens blueprint for its core feature.

What it doesn’t have yet is a working API. The diagrams show that a POST /messages/message endpoint should exist, that it must accept { author, content }, that it must call UMS before writing to the database – but none of that is running code yet.

Lesson 4 is where the diagrams meet the wire. We’ll define the API contracts and endpoints that connect Bird‘s components – what each service exposes, what it expects, and what it returns. The sequence diagram already told us that POST /messages/message must exist, that it calls GET /users/user/{id} on UMS, and that both exchanges carry specific payloads. Lesson 4 makes those contracts explicit and formal: request shapes, response shapes, status codes, and the communication protocol between the Twitter Service and UMS.

The diagrams were never the destination. They were the clearest possible way to know what to build before building it.

Drawing the Blueprint: Flowchart, Functional Diagram, and Sequence Diagram

Figure 1. The Foundation Is Ready

Lesson 3 of Build a Twitter Clone – A Practical Guide to Software Modelling

In Lesson 2, we defined what Bird must remember: two databases, tables within, every field traced back to a use case. Now it’s time to draw what Bird must do. Using "Post a message" as our single worked example, we construct all three diagrams in sequence – first a flowchart that traces the user action step by step, then a functional diagram that reads the required components directly off that flowchart, then a sequence diagram that shows exactly how those components talk. By the end, the use case that started as a sentence on a sticky note has a complete, traceable blueprint.

Introduction – The Foundation Is Ready

Lesson 2 ended with a promise, and it’s worth quoting precisely:

“That’s where Lesson 3 picks up: with the data model as the foundation, we draw all three diagrams.”

That moment has arrived. But it’s worth pausing for a second to notice what’s different now compared to where we started.

In Lesson 1, we introduced the idea of three diagrams – a flowchart, a functional diagram, and a sequence diagram – as three lenses on a single system. We explained the mental model, argued for the order, and left it there. No diagrams were actually drawn, and that was deliberate: a diagram drawn before the project is bounded and the data understood is a diagram you’ll redraw.

Figure 1. The Foundation Is ReadyFigure 1. The Foundation Is Ready

In Lesson 2, we did the bounding. We read Bird‘s three use cases for data clues, named two entities (User and Message), defined five tables across two databases (ums and twitter), and established the relationships that tie them together. The schema is real now: a messages row has a specific shape – an id, a content field capped at 280 characters, an author_idpointing to a row in ums.users, and a created_at timestamp. The users row has a shape too. Every “save the message” step in every diagram we draw will refer to something concrete.

That concreteness is what changes everything. A diagram drawn on top of a real data model isn’t a sketch – it’s a specification. The boxes mean something. The arrows carry named fields. When a step says “validate the message,” we already know what the validation is checking (which is called validation criteria): a non-empty content string, no longer than 280 characters, attached to an authenticated author_id. Nothing is left as a placeholder.

This lesson draws all three diagrams. We’ll work in order – behaviour first, then the structure that behaviour implies, then the detailed conversation inside that structure – for the same reason we always work in that order: each diagram is the input to the next, and the input has to exist before the output can be correct.

A Quick Reminder: The Three-Lens Progression

Before we pick up the pen, one short bridge back to Lesson 1 – not a full re-teach, just enough to make sure the scaffold is standing.

The core idea is this: any software system can be understood from three distinct angles, and each angle demands a different kind of diagram.

Lens The question it answers The diagram What you see
Behaviour What should the app do? Flowchart Steps, decisions, and outcomes – the system as a process unfolding in time
Structure What parts must exist to make that behaviour happen? Functional diagram Components and their connections – the system as a thing made of pieces
Interaction How do those parts talk to each other at runtime? Sequence diagram Messages exchanged between components – the system as a conversation

None of these is more correct than the others. A flowchart can’t tell you what components to build. A functional diagram can’t tell you what happens when a user submits an empty message. A sequence diagram can’t tell you whether you’ve correctly scoped the feature. They are complementary, not competing – and you need all three because no single one is complete.

The critical habit is the order. You move behaviour → structure → interaction, and each step is derived from the one before:

Figure 2. The three-lens progression - each diagram derives from the one beforeFigure 2. The three-lens progression – each diagram derives from the one before

The reason the order is fixed: you can’t know what components to build until you know what the system must do. And you can’t describe the conversation between components until you know which components exist. Behaviour is the requirement; structure serves it; interaction lives inside structure.

An analogy for the progression. Imagine designing a kitchen. First you decide what the kitchen must do– prepare meals, store food, clean dishes. That’s behaviour. Then you ask what parts must exist to support each of those tasks: a cooktop, a refrigerator, a sink. That’s structure, falling out of behaviour. Finally, you trace the specific workflow – the chef moves from the refrigerator to the prep surface to the cooktop, in that order, passing ingredients along. That’s interaction, living inside the structure. You couldn’t have designed the workflow without knowing the layout. You couldn’t have designed the layout without knowing the tasks. The order is the method.

Lens 1: Behaviour – Drawing the Flowchart

A flowchart answers one question: what does the system do, step by step? Not what parts it has. Not which component calls which. Just the sequence of actions and decisions that carry a user from “I want to post a message” to “it’s done” – or to an error, if something goes wrong along the way.

That makes the flowchart the right place to start. It captures the requirement in its purest form, before any implementation choices have been made.

Building it step by step

Step 1 – Name the start and end points.

The flow begins the moment the user presses “Post” – that is, when the system receives a submission. It ends either with a new row in twitter.messages and a confirmation to the user, or with an error message and no row written. Name those endpoints first, before filling in anything in between.

Step 2 – List the actions in order.

Walk the happy path first – what happens when everything goes right? For “Post a message,” that sequence is:

  1. Submit a message
  2. Pass the authentication check – is the user logged in?
  3. Pass the permissions check – is the user allowed to post?
  4. Pass content validation – is the message valid?
  5. Save the message to the database
  6. Confirm success to the user

Six steps. Two are actions, three are decision gates, one is a confirmation. That’s a healthy breakdown – the logic is explicit without becoming an implementation manual.

Step 3 – Add the decision points.

Three decision diamonds gate the happy path:

  • Is the user logged in? If no → redirect to the login page. After a successful login the user is returned to the submission step – a loop, not a dead end.
  • Is the user allowed to post? Not every account has posting rights – a read-only or suspended role is blocked here. If no → shared Error node → END.
  • Is the message valid? (Non-empty? Within 280 characters?) If invalid → shared Error node → END.

Notice that the two error branches converge on a single Error node before reaching END. This is an intentional design choice in the diagram: the system’s response to a permissions failure and a content failure is the same kind of thing – an error returned to the user. Merging them keeps the diagram clean and makes that equivalence visible.

Step 4 – Connect and label.

Arrange the steps top to bottom, connect them with arrows, and label every arrow leaving a decision diamond. The labels on the branches (“yes / no”, or more descriptively “valid / invalid”) are what make the diagram readable at a glance.

The flowchart

Figure 3. Flowchart for Figure 3. Flowchart for “Post a message”

What this diagram tells us – and what it doesn’t

The flowchart is intentionally silent on how each step is implemented. It doesn’t say which component checks authentication, or what library validates the string length, or whether the database write is synchronous. Those details don’t belong here. What the flowchart does – and does well – is make the logic visible: the decisions, the branches, the outcomes. That’s the entire job of Lens 1.

Notice that the data model already sharpens the language. The “Save message” step doesn’t just say “save the message”– it names the fields: author_id, content, created_at, writing into twitter.messages. That specificity comes directly from Lesson 2. The flowchart is still high-level, but it’s no longer vague.

With the behaviour fully drawn, the next question asks itself: what parts must exist to make each of these steps happen? That’s Lens 2 – and the answer comes directly from reading this flowchart.

Lens 2: Structure – Reading the Functional Diagram off the Flowchart

The flowchart is done. We know what “Post a message” must do – the steps, the decisions, the branches. Now a different question takes over: what parts must exist inside the system to make each of those steps happen?

This is where most beginners get into trouble. They open a new diagram and start inventing components – a generic AuthService, an APIGateway, a DatabaseLayer – based on instinct or prior experience. The result is a diagram that reflects what the developer already knew, not what the use case actually requires.

There is a better method. The functional diagram doesn’t need to be invented. It can be read directly off the flowchart.

The method: scan every step, ask one question

For each step in the flowchart, ask: what component must own this responsibility? The answer names a block. Do that for every step, merge the ones that belong together, and the structure falls out of the behaviour.

Let’s walk through the flowchart step by step.

Flowchart step Responsibility Component
User submits message Sends the HTTP request Frontend Client
Is the user logged in? Identifies who is making the request Twitter Service
Redirect to login Returns the login page Frontend Client
Is the user allowed to post? Fetches the user record and checks for the PRODUCERrole Twitter Service → UMS Service
Is the message valid? Enforces content rules (non-empty, ≤ 280 chars) Twitter Service
Save message Writes the row to twitter.messages Twitter Service → MySQL Database
Confirm success Returns 201 with the created message Twitter Service → Frontend Client
Return error Returns 403 Forbidden to the client Twitter Service → Frontend Client

Two things stand out immediately. First, the Twitter Service carries the most weight: it receives the request, performs content validation, and orchestrates the database write. But it doesn’t hold user data – that lives in UMS. So for the permission check, the Twitter Service calls the UMS Service via an internal HTTP request, retrieves the user’s roles, and decides whether to proceed. This cross-service call is the architectural heart of the feature.

Second, there is no separate API Gateway or Auth Service in Bird. The Frontend Client talks directly to the Twitter Service on port 9001. Role checking is a responsibility of the Twitter Service itself, delegated to UMS on demand – not a standalone component sitting in between.

This gives us four components: the Frontend Client, the Twitter Service, the UMS Service, and MySQL Database.

The functional diagram

Figure 4. Functional diagram for  raw `Bird` endraw Figure 4. Functional diagram for Bird

The diagram reads as a set of named blocks connected by labelled arrows. The arrows carry the nature of the relationship – not the step-by-step logic (that’s the flowchart’s job) but the structural dependency: the Frontend Client sends requests to the Twitter Service; the Twitter Service calls UMS to validate the user; the Twitter Service reads and writes MySQL directly. Each arrow represents a real, named communication channel in the codebase – HTTP on :9001 for the UI-to-service call, WebClient for the service-to-service call, JDBC for the database connection.

What this diagram is – and isn’t

A functional diagram is a map of components, not a map of steps. It answers “what pieces exist and how are they connected?” – not “what happens first?” The flowchart and the functional diagram are not redundant; they answer different questions and each is incomplete without the other.

Notice also what the functional diagram deliberately omits: there are no arrows labelled with field names, no decision branches, no error paths, no sequence numbers. Those details live in the sequence diagram. Here, the goal is structural clarity – a reader should be able to see at a glance what Bird is made of and how the pieces relate.

With the components named and connected, one question remains: how exactly do they talk to each other during “Post a message”? That’s the sequence diagram – and now that we know which components exist, we can finally draw it precisely.

Lens 3: Interaction – The Sequence Diagram

The flowchart told us what “Post a message” does. The functional diagram told us what components exist to do it. The sequence diagram answers the last question: what do those components say to each other, in what order, when the feature actually runs?

This is where the previous two diagrams earn their investment. Every component named in the functional diagram becomes a lifeline. Every step in the flowchart becomes a message on one of those lifelines. Nothing is invented – it’s assembled from what we already know.

From components to lifelines

A sequence diagram is built around lifelines – vertical columns, one per component, representing each participant in the interaction. Reading the functional diagram directly, our lifelines are:

  • Frontend Client – the browser, initiating the request
  • MessageController – the entry point inside the Twitter Service, receiving the HTTP call
  • MessagesService – the orchestrator: calls UMS, checks roles, triggers the database write
  • UMSConnector – the thin HTTP client that talks to UMS via WebClient
  • UMS Service – the external service that owns user identity and roles
  • JdbcMessageRepository – the DAO that executes the SQL insert
  • MySQL – the database that persists the row

Reading the flow

The messages between lifelines follow the exact path the codebase executes. Let’s trace it:

  1. The Frontend Client sends POST /messages/message with { author, content } to the MessageController.
  2. MessageController deserializes the body into a Message DTO and delegates to MessagesService via createMessage(message).
  3. MessagesService needs to verify the author – it doesn’t hold user data, so it calls UMSConnector with retrieveUmsData(/users/user/{id}).
  4. UMSConnector fires a non-blocking WebClient GET to the UMS Service.
  5. UMS Service returns a UserDto containing the user’s roles.
  6. MessagesService unpacks the response via HttpResponseExtractor and inspects the Roles. If the user is not a PRODUCER – the flow terminates here with a 403 Forbidden back to the client.
  7. If the role check passes, MessagesService calls createMessage(message) on JdbcMessageRepository.
  8. JdbcMessageRepository executes INSERT INTO messages against MySQL (converting the UUID via DaoHelper).
  9. MySQL returns ok.
  10. The saved Message travels back up: JdbcMessageRepository → MessagesService → MessageController → Frontend Client as 201 { message }.

The sequence diagram

Figure 5. Sequence diagram for Figure 5. Sequence diagram for “Post a message”

What this diagram shows that the others couldn’t

Look at the alt block in the middle of the diagram. That’s the permission check – the same diamond in the flowchart, the same role-checking responsibility assigned to MessagesService in the functional diagram. But now it has an exact form: a comparison against the PRODUCER role, returning 403 Forbidden if it fails, with the response passing back through MessageController to the client.

This is traceability working in practice. The validation step in the flowchart → the MessagesService block in the functional diagram → the HttpResponseExtractor → User → Roles call chain in the sequence diagram. One idea, three levels of resolution.

Notice also what the sequence diagram adds that neither previous diagram could: the reactive chain. The UMSConnectorcall is non-blocking – MessagesService issues the WebClient request and the rest of the chain executes inside a .flatMap(). The sequence diagram makes this visible through the activation boxes: MS stays active while UC and UMS do their work, then resumes. That concurrency detail is invisible in both the flowchart and the functional diagram.

With all three diagrams drawn, one use case – “Post a message” – now has a complete, layered blueprint. The next section puts all three side by side and shows what traceability looks like end to end.

Let’s pause before moving on and do something the three diagrams individually don’t do: put them side by side and trace a single idea across all of them.

Take the permission check – the moment the system decides whether the author is allowed to post. Here is where it lives in each diagram:

Diagram Where the permission check appears What you can see
Flowchart The diamond “Is user allowed to post?” branching to Erroror continuing to validation The logic – two outcomes, one decision
Functional diagram The arrow from MessagesService labelled “check roles contains PRODUCER” pointing to Roles The ownership – which component holds this responsibility
Sequence diagram The alt block: HttpResponseExtractor → User → Roles, branching to 403 Forbidden or INSERT The mechanics – what data moves, in what order, producing what result

Same check. Three levels of resolution. You could point at the diamond in the flowchart, follow it to the MessagesServicearrow in the functional diagram, and land on the alt block in the sequence diagram – and at each step you’d learn something the previous diagram couldn’t tell you.

That property is traceability: the ability to follow a single requirement from its highest-level expression down to its lowest-level implementation without losing the thread. It’s what separates a set of diagrams that decorate a document from a set that actually functions as a blueprint.

Figure 6. One feature, three views - the permission check traced across all three diagramsFigure 6. One feature, three views – the permission check traced across all three diagrams

Notice also what traceability reveals in the other direction. The sequence diagram shows that the 403 Forbidden response travels back through MessageController before reaching the client – a detail invisible in both the flowchart (which just says “Error → END”) and the functional diagram (which shows MessageController connected to GlobalExceptionHandler without explaining when). The diagrams don’t just repeat each other. Each one completes the picture in a direction the others leave open.

Limitations and Open Questions

Three diagrams drawn, one use case traced. Before closing, it’s worth being clear about what this process is – and what it isn’t.

Diagrams go stale. Every diagram in this lesson reflects the codebase as it stands today. The moment a new field is added to messages, or the permission model changes, or UMSConnector is replaced by a shared auth library – the diagrams need updating. A diagram that isn’t maintained becomes misleading faster than no diagram at all. The discipline of drawing diagrams is only half the work; the other half is keeping them honest.

This is a snapshot, not a specification. The diagrams here describe what Bird does, not what it must do. A specification would go further – stating invariants, error contracts, performance bounds, and edge cases. These diagrams are the foundation of a spec, not the spec itself. They answer “how does it work?” not “how must it always work?”

Over-modelling is a real risk. Drawing all three diagrams for every feature in a large system would consume more time than it saves. The three-lens approach is most valuable at decision points: when designing a new feature from scratch, when onboarding someone to an unfamiliar part of the system, or when debugging behaviour that contradicts the mental model. Applied indiscriminately, it becomes ceremony rather than engineering.

These diagrams are not UML. Readers familiar with the Unified Modelling Language (UML) will notice that the diagrams in this lesson borrow UML conventions – sequence diagram lifelines, flowchart decision diamonds – without following the full UML standard. That’s deliberate. UML is precise and complete, and its precision comes at the cost of accessibility. However when you move to production systems with formal specification requirements, UML or C4 may be the right choice.

What’s Next

Three lessons in, and Bird has a bounded scope, a real data model, and a complete three-lens blueprint for its core feature.

What it doesn’t have yet is a working API. The diagrams show that a POST /messages/message endpoint should exist, that it must accept { author, content }, that it must call UMS before writing to the database – but none of that is running code yet.

Lesson 4 is where the diagrams meet the wire. We’ll define the API contracts and endpoints that connect Bird‘s components – what each service exposes, what it expects, and what it returns. The sequence diagram already told us that POST /messages/message must exist, that it calls GET /users/user/{id} on UMS, and that both exchanges carry specific payloads. Lesson 4 makes those contracts explicit and formal: request shapes, response shapes, status codes, and the communication protocol between the Twitter Service and UMS.

The diagrams were never the destination. They were the clearest possible way to know what to build before building it.