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.

Google I/O 2026 Wasn’t About AI Models — It Was About Agent Execution Layers

title: Google I/O 2026 Wasn’t About AI Models — It Was About Agent Execution Layers
published: true
tags: ai, googleio, agents, architecture

This is a submission for the Google I/O Writing Challenge

Google I/O 2026 Wasn’t About AI Models — It Was About Agent Execution Layers

Most discussions around Google I/O 2026 focused on model capabilities.

Gemini got smarter.
AI Studio improved.
Agent workflows became easier.
On-device AI became more practical.

But I think the real shift happened somewhere deeper.

Google I/O 2026 was not just about better AI models.

It was about the emergence of an Agent Execution Layer.

And once you start building multi-agent systems in the real world, you quickly discover something uncomfortable:

The hardest problem is no longer intelligence.

It is state management.

⸻

The Problem Nobody Talks About

When developers first build AI systems, the architecture usually looks simple:

User -> LLM -> Response

But the moment you move into agent workflows, everything changes.

Now you suddenly have:

  • multiple agents
  • tool execution
  • memory systems
  • long context histories
  • role switching
  • state inheritance
  • retrieval pipelines
  • security boundaries
  • autonomous actions

And eventually, the architecture becomes something closer to:

User
↓
Coordinator Agent
↓
Execution Agents
↓
Memory Layer
↓
Tool Runtime
↓
External APIs / Environment

At this point, prompts stop being “messages.”

They become something closer to an operating system.

⸻

Context Is Becoming the New Bottleneck

Most people still think model performance is the primary scaling problem.

I don’t think that’s true anymore.

The bigger problem is this:

Context grows faster than reasoning quality.

The more capable agents become, the more memory, instructions, logs, and coordination data they accumulate.

This creates several failure modes:

  • context bloat
  • instruction conflicts
  • memory drift
  • role collapse
  • hidden prompt inheritance
  • prompt injection propagation
  • state contamination between agents

Ironically, smarter agents amplify orchestration problems.

This is where I think the next generation of AI infrastructure will emerge.

⸻

From “Prompt Engineering” to “State Engineering”

For the last two years, the industry focused heavily on prompt engineering.

But prompt engineering assumes something important:

That interaction is temporary.

Agent systems break this assumption.

Agents persist.
Agents inherit memory.
Agents maintain roles.
Agents accumulate behavioral state over time.

That means the problem changes from:

“What should the AI say?”

to:

“What state should the AI exist in?”

This is a fundamentally different design philosophy.

⸻

Building Around the Problem

Over the past year, I started building several experimental concepts around this issue while working on multi-agent workflows, memory systems, and autonomous orchestration experiments.

Some examples:

Context Pointer OS

Instead of continuously passing gigantic raw histories into models, agents should reference contextual structures through lightweight pointers.

In other words:

Don’t pass the entire world.
Pass references to the world.

This reduces token waste while making long-term coordination more stable.

Project:
https://github.com/kagioneko/context-pointer-os

⸻

AI Instruction Tape (AIT)

Human language is extremely expensive for agent-to-agent communication.

AIT experiments with compressed instruction transfer between AI systems.

Instead of repeatedly sending huge natural language prompts, agents exchange compact operational context.

Project:
https://github.com/kagioneko/ai-instruction-tape

⸻

Esoteric AI Protocol (EAP)

As multi-agent ecosystems grow, natural language alone becomes inefficient as an execution protocol.

EAP explores lightweight structured communication for agent coordination.

Project:
https://github.com/kagioneko/esoteric-ai-protocol

⸻

Google I/O 2026 Confirmed Something Important

What Google showed this year was not just AI tooling.

It was the beginning of infrastructure for persistent AI execution.

The moment agents become:

  • autonomous
  • stateful
  • collaborative
  • tool-connected
  • environment-aware

the industry stops being purely about model quality.

It becomes about:

  • orchestration
  • memory integrity
  • state synchronization
  • execution governance
  • agent operating systems

In other words:

The future of AI is not just model architecture.
It is runtime architecture.

⸻

The Security Side Is Going to Matter More Than People Think

One thing I learned from real-world VPS incidents and autonomous agent experiments:

The more authority agents gain, the more dangerous context corruption becomes.

A compromised context is effectively a compromised execution environment.

This means future AI systems will likely require:

  • memory validation
  • state auditing
  • execution boundaries
  • agent isolation
  • instruction provenance
  • behavioral monitoring

AI security may gradually evolve into something closer to operating system security.

And honestly, I think we are still very early.

⸻

Final Thoughts

Google I/O 2026 felt like a transition point.

Not because AI suddenly became intelligent.

But because the ecosystem started shifting from:

AI as conversation

to:

AI as infrastructure

And once that happens, developers will need new abstractions.

Not just better prompts.

But:

  • state layers
  • memory architectures
  • execution runtimes
  • agent protocols
  • orchestration operating systems

I think that’s where the next major wave of AI development is heading.

Monorepos in 2026: Turborepo vs Nx vs Bazel — What Actually Works

Monorepos in 2026: What Actually Works

The monorepo debate in 2026 has settled into something more mature. Turborepo became the default for most JS/TS teams, Nx found its niche in enterprises with complex dependency graphs, and Bazel still dominates at Google scale. Here’s what I’ve learned from running all three in production.

Why Monorepos Won

Let’s be clear about why monorepos became the default in 2026:

  1. Atomic commits across services — Change an API contract and update all consumers in one PR
  2. Shared tooling — One ESLint config, one Prettier config, one TypeScript config
  3. Easy refactoring — Rename a function used across 20 packages without coordination
  4. Unified CI/CD — One pipeline that understands dependency relationships
  5. Developer experience — One git clone, one npm install, everything works

The alternative — polyrepo hell where a simple rename requires 15 PRs across 15 repos — killed productivity. Teams migrated.

Turborepo: The Default Choice

Turborepo won because it has sensible defaults, a gentle learning curve, and just works for most JavaScript/TypeScript projects. It’s not the most powerful, but it’s the most practical.

Setting Up a Turborepo Project in 2026

// package.json (root)
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "test": "turbo test"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.4.0"
  }
}
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
    }
  }
}

Real Package Dependencies

apps/
  web/          → depends on @repo/ui, @repo/api-client
  admin/        → depends on @repo/ui, @repo/api-client
  docs/         → depends on @repo/ui, @repo/docs-utils
packages/
  ui/           → no internal deps
  api-client/   → no internal deps
  docs-utils/   → no internal deps
  config/       → no internal deps (ESLint, TypeScript configs)

The Cache That Actually Works

# First build (slow)
$ turbo build
• building web
• building admin
• building docs
• building ui
• building api-client
• building docs-utils

→ Tasks run by turbo: 6 (64ms each, 384ms total)

# Second build (instant - cache hit)
$ turbo build
• building web (cache hit)
• building admin (cache hit)
• building docs (cache hit)
• building ui (cache hit)
• building api-client (cache hit)
• building docs-utils (cache hit)

→ Tasks run by turbo: 0 (6 in cache, 12ms total)

Nx: When You Need the Power

Nx is Turborepo on steroids. It understands your project graph at a deeper level, has built-in code generators, affected commands, and powerful visualization tools. The tradeoff: more complexity, steeper learning curve.

Nx Project Setup

# Create Nx workspace
npx create-nx-workspace@latest myorg --preset=ts

# Add a new application
npx nx generate @nx/nextjs:app admin

# Add a library
npx nx generate @nx/js:library api-client --directory=packages/api-client

The Affected Command (Nx’s Killer Feature)

# Only build/test/lint what changed (and what depends on what changed)
npx nx affected --target=build --base=origin/main

# This is the game-changer for large monorepos
# Instead of rebuilding everything, Nx computes the dependency graph
# and only rebuilds the affected packages

Visualizing the Project Graph

# Opens browser with interactive dependency graph
npx nx graph

This generates a visual graph of your entire project. You can see exactly which packages depend on which, spot circular dependencies, and understand the impact of proposed changes.

Nx Computation Cache (Distributed)

// nx.json
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    "production": ["!{projectRoot}/**/*.spec.ts"]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "cache": true,
      "inputs": ["production", "^production"]
    }
  },
  "nxCloudAccessToken": "your-nx-cloud-token"
}

Nx Cloud provides distributed caching — your CI server and local machine share the same build cache. Cold CI builds become warm builds instantly.

Bazel: At Google Scale

Bazel is the nuclear option. It’s infinitely scalable, hermetically sealed (reproducible builds), and can handle truly massive codebases. The tradeoff: significant operational overhead, Starlark learning curve, and ecosystem fragmentation outside the Google ecosystem.

When Bazel Makes Sense

  • 1,000+ engineers in a single codebase
  • Multiple languages (TypeScript + Python + Go + Rust + Java)
  • Need per-language optimized tooling
  • hermetic builds are a hard requirement
  • You’re okay with dedicated build infrastructure team

For most teams, Bazel is overkill. The rule of thumb: if you’re asking whether you need Bazel, you don’t.

The Migration Path

From Polyrepo to Turborepo

Step 1: Create the monorepo structure

mkdir my-monorepo && cd my-monorepo
git init
npm init -y
npm install -D turbo
mkdir apps packages

Step 2: Move packages one at a time

# Move one package
mv ../old-repo/packages/ui apps/ui
cd apps/ui
npm init -y
# Update package.json name to @myorg/ui
cd ../..
git add -A
git commit -m "feat: move ui package"

Step 3: Fix dependency issues

// Before (old package.json)
{
  "name": "ui",
  "dependencies": {
    "react": "^18.0.0"
  }
}

// After (in monorepo)
{
  "name": "@myorg/ui",
  "peerDependencies": {
    "react": "^18.0.0"
  }
}

Step 4: Add shared configs

# packages/config-eslint/index.js
module.exports = {
  extends: ['next/core-web-vitals', 'turbo'],
  rules: {
    '@typescript-eslint/no-unused-vars': 'error'
  }
};

# packages/config-typescript/base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

The Hidden Costs Nobody Warns You About

Build Tool Lockstep

When you share a config package across 20 apps, updating TypeScript becomes a coordinated event. You can’t have App A on TypeScript 5.3 and App B on 5.4 while they’re in the same repo.

Solution: Use pnpm workspaces with version pinning:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

# .npmrc
save-exact=true

The Big Git History Question

Do you keep git history when migrating?

# Option 1: Keep history (preserve all commit messages)
git subtree add --prefix=apps/web https://github.com/old/web.git main

# Option 2: Fresh start (cleaner, lose history)
mv old-repo/web apps/web

I recommend Option 2 for most migrations. Git history for individual packages in a monorepo is rarely useful, and the migration complexity isn’t worth it.

CI/CD Complexity

# GitHub Actions with Turborepo
name: CI

on: [push, pull_request]

jobs:
  affected:
    name: Check affected
    runs-on: ubuntu-latest
    outputs:
      affected: ${{ steps.turbo.outputs.affected }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - id: turbo
        run: echo "affected=$(pnpm turbo run build --dry-run=json | jq -r '.packages | if length > 0 then "true" else "false" end')" >> $GITHUB_OUTPUT

  build:
    needs: affected
    if: needs.affected.outputs.affected == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo build --filter=...affected packages...

2026 Tooling Ecosystem

Tool Language Agnostic Learning Curve Scale Best For
Turborepo JS/TS mostly Gentle < 100 packages Most teams
Nx Multiple Medium < 500 packages Enterprises
Bazel Any Steep Unlimited Google-scale
Lerna JS/TS Gentle < 50 packages Legacy (deprecated)

My Recommendation

For most teams in 2026: Start with Turborepo. It’s the right default. You get 80% of the benefit for 20% of the complexity.

If you’re in an enterprise with complex dependencies, code generators, and a dedicated platform team: Nx is worth the investment.

If you’re at Google scale: Bazel. But you already knew that.

The worst choice: using no monorepo tooling at all. Running a monorepo with just npm workspaces and manual dependency tracking is technical debt that compounds.

Running a monorepo? What’s your tool of choice in 2026? Let’s hear real-world experiences.

Further reading: Turborepo docs, Nx docs, Bazel migration guide

How I Built a Real-Time Robot Battle Simulator from Scratch — Logic Arena

A CS student’s journey from a blank repo to a production platform with a custom scripting language, 60fps physics engine, and 3,000+ lines of battle-tested TypeScript.

The Idea

I wanted to build something that made competitive programming fun. Not another LeetCode clone. Something where your algorithm doesn’t just pass a test case — it fights.

The concept: players write code to control robots. The robots battle in real-time. Your logic vs the world.

That’s Logic Arena.

Starting From Zero — The Engine

The first challenge was building a game engine from scratch. No Unity, no Phaser — pure TypeScript.

Version 0.2.0 was just two robots moving in a canvas. But getting there required solving the first real technical scar:

The Singleton Problem. Two NestJS services were sharing the game engine — or so I thought. They each had their own instance, so state was completely desynced. The fix: @Global() decorator to enforce a single shared engine across the entire module.

@Global()
@Module({
  providers: [GameService],
  exports: [GameService],
})
export class GameModule {}

One decorator. Three hours of debugging.

Building AliScript — A Custom Scripting Language

This was the craziest decision I made. Instead of using JavaScript or Lua for robot scripting, I built my own language.

Why? I wanted players to think algorithmically, not just copy-paste code. AliScript had to be simple enough for beginners but powerful enough to reward expert thinking.

The pipeline:

String Script → Lexer → Tokens → Parser → AST → Server Evaluator → Robot Actions

First version supported basic conditionals:

IF CAN_SEE_ENEMY
  FIRE
END
MOVE

By v2.4, it became Turing-complete-ish:

WHILE TRUE DO
  IF CAN_SEE_ENEMY AND MY_ENERGY > 30 DO
    BROADCAST(NEAREST_VISIBLE_X)
    BURST_FIRE
  ELSE IF IN_STASIS DO
    WAIT
  ELSE DO
    SCAN
    PATHFIND
  END
END

The hardest bug? Operator precedence.

2 + 3 * 4 was evaluating to 20 instead of 14. Every multiplication-heavy script was silently computing wrong values. The fix required splitting parseBinaryExpression() into two separate precedence levels — parseAddition() and parseMultiply().

The Performance Crisis

By v1.3.0, the arena was running at ~15fps. Profiling revealed the culprit:

3,862ms scripting bottleneck — the main thread was choking on useState updates 60 times per second.

The fix was a dual-state architecture:

// Before: useState causes re-render on every tick
const [gameState, setGameState] = useState(null);

// After: useRef for game logic, throttled state for UI only
const gameStateRef = useRef(null); // updates at 60fps, zero re-renders
const [uiState, setUiState] = useState(null); // updates at 10fps, DOM only

Scripting time dropped from 3,862ms to near zero.

Six Performance Fixes in One Release

By v2.5.0, I ran a full profiling audit and found six simultaneous bottlenecks:

1. Obstacles in every WebSocket payload — static data sent 10x/sec for no reason. Fix: initialize once, strip from all subsequent payloads.

2. 30 WebGL draw calls for obstacles — rewrote to THREE.InstancedMesh. 30 draw calls → 4 draw calls.

3. 10 useFrame JS callbacks for obstacle animations — moved all pulse math to GPU via fragment shaders. 0 JS callbacks per frame.

4. Server memory leak — replay snapshots were deep-cloning unboundedly. Added ring buffer capped at 300 objects.

5. The Ghost Match Massacre — when players closed their browser, the physics engine kept running at full speed indefinitely. Hundreds of ghost matches accumulating CPU in silence. Fix: wired disconnect lifecycle to stop the engine the moment the last player leaves.

The Deployment Nightmare

Going from localhost to production at logicarena.dev was a gauntlet.

The 2.57GB Docker Context Bomb — first build transferred 2.57GB to the Docker daemon because node_modules wasn’t excluded. Fixed .dockerignore, dropped build context to 15MB.

The Prisma Ghost Engine — Alpine Linux refused to execute the Windows-compiled Prisma binary. Added linux-musl to binaryTargets.

The WebSocket CORS Wall — every Socket.IO connection rejected because the gateway was hardcoded to localhost:3000. Extended CORS array, fixed Nginx WebSocket proxy headers.

The Silent Postman — DigitalOcean silently blocks outbound SMTP on new Droplets. Zero errors, zero delivery, just void.

The Architecture Today

logic-arena/
├── apps/
│   ├── client/     # Next.js 16, React Three Fiber, PWA
│   └── server/     # NestJS 11, Socket.io, JWT
└── packages/
    ├── engine/     # Custom TypeScript physics engine
    └── logic-parser/ # AliScript lexer, parser, evaluator

What I Learned

1. Architecture decisions compound. Every “quick fix” that bypassed the module system created three bugs later.

2. Profile before optimizing. The 3,862ms bottleneck was invisible until I actually measured it.

3. Custom languages are feasible. An AST evaluator is just a recursive tree walker — intimidating name, straightforward implementation.

4. Ship early, iterate fast. Logic Arena went from 2 robots on a canvas to a 60-level algorithmic campaign in 4 months of solo development.

Try It

Live: logicarena.dev — no account required, join as guest.

GitHub: Ali-Haggag7/logic-arena

Write your first script, watch your robot fight, and tell me what you think in the comments!

Rust in 2026: The Systems Language That Finally Became Approachable

Rust in 2026: The Systems Language That Finally Became Approachable

Rust crossed the chasm. In 2024 it was “the language everyone’s excited about but few use in production.” In 2026, it’s running in production at Microsoft, Google, Amazon, Cloudflare, and a generation of startups. The borrow checker that seemed impenetrable is now understood intuitively by hundreds of thousands of developers. Here’s what changed.

The Adoption Curve

The Rust user survey tells the story:

  • 2023: 28% of respondents used Rust in production
  • 2025: 47% of respondents used Rust in production
  • Most importantly: the “learning Rust” satisfaction rate went from 42% to 71%

The language stopped being “hard to learn” and started being “hard to learn but worth it.”

What Made Rust Approachable in 2026

1. Better Error Messages

The borrow checker error messages are legendary now. They don’t just say “no” — they explain why and suggest how to fix it.

fn main() {
    let s = String::from("hello");
    let r1 = &s;
    let r2 = &s;
    println!("{} and {}", r1, r2);
}

The compiler output:

error[E0502]: cannot borrow `s` as immutable because it is also
borrowed as mutable
  --> src/main.rs:4:13
   |
3  |     let r1 = &s;
   |              -- first borrow occurs here
4  |     let r2 = &s;
   |              ^^ second borrow occurs here
5  |     println!("{} and {}", r1, r2);
   |                            -- first borrow needs to be valid
   |                            for the duration of the borrow

help: consider using the reference count for shared ownership

help: consider using `Rc<String>` if you don't need exclusive ownership

Compare this to C++ segfaults that just say “Segmentation fault (core dumped).”

2. Async Rust Stabilized

The async/await syntax stabilized and the ecosystem matured significantly.

// Before (2024): Complex futures, Pin, Box, manual polling
// After (2026): Clean async/await like Go/Python

use tokio;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let data = fetch_data("https://api.example.com/data").await?;
    let processed = process(data).await;
    save_to_db(processed).await?;
    Ok(())
}

async fn fetch_data(url: &str) -> Result<String, reqwest::Error> {
    reqwest::get(url).await?.text().await
}

3. The crate ecosystem reached critical mass

The crates.io ecosystem now has everything you need for production:

# Cargo.toml — Production Rust is just dependency management
[dependencies]
# Web framework
axum = "0.7"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "compression"] }

# Async runtime
tokio = { version = "1", features = ["full"] }

# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }

# Error handling
anyhow = "1"
thiserror = "2"

# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# Testing
tokio-test = "0.4"

Real Production Example: A Web API in Axum

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::Json,
    routing::{get, post},
    Router,
};
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
use tower_http::cors::CorsLayer;

#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
struct User {
    id: i32,
    name: String,
    email: String,
}

#[derive(Clone)]
struct AppState {
    db: sqlx::PgPool,
}

async fn create_user(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<CreateUserPayload>,
) -> Result<(StatusCode, Json<User>), AppError> {
    let user = sqlx::query_as::<_, User>(
        r#"INSERT INTO users (name, email) VALUES ($1, $2)
           RETURNING id, name, email"#
    )
    .bind(&payload.name)
    .bind(&payload.email)
    .fetch_one(&state.db)
    .await?;

    Ok((StatusCode::CREATED, Json(user)))
}

async fn get_user(
    Path(user_id): Path<i32>,
    State(state): State<Arc<AppState>>,
) -> Result<Json<User>, AppError> {
    let user = sqlx::query_as::<_, User>(
        "SELECT id, name, email FROM users WHERE id = $1"
    )
    .bind(user_id)
    .fetch_optional(&state.db)
    .await?
    .ok_or(AppError::NotFound)?;

    Ok(Json(user))
}

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("User not found")]
    NotFound,
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
        };
        (status, Json(serde_json::json!({ "error": message }))).into_response()
    }
}

type Response = axum::response::Response;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt::init();

    let db = PgPoolOptions::new()
        .max_connections(5)
        .connect("postgres://user:pass@localhost/mydb")
        .await?;

    let state = Arc::new(AppState { db });

    let app = Router::new()
        .route("/users", post(create_user))
        .route("/users/:id", get(get_user))
        .layer(CorsLayer::permissive())
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await?;
    axum::serve(listener, app).await?;

    Ok(())
}

The Memory Safety Argument: No Longer Theoretical

The big shift in 2025-2026: real-world security vulnerabilities dropped in organizations that switched to Rust.

// C/C++ memory vulnerabilities (common in production):
// - Buffer overflow
// - Use after free
// - Double free
// - Race conditions

// The National Security Agency (NSA) and CISA now recommend:
// "Consider migrating to memory-safe languages like Rust"

Microsoft reported that 70% of their CVEs (security vulnerabilities) were memory safety issues. In C/C++, these are endemic. In Rust, the borrow checker makes them impossible by default.

Rust vs Go: The Honest Comparison

The Rust vs Go debate settled into a clearer consensus:

Use Case Winner Reason
Web APIs (simple) Go Faster to write, good enough performance
Web APIs (high performance) Rust 10x lower latency, predictable memory
Systems programming Rust Memory safety without GC
Networking infrastructure Rust Cloudflare, Fastly, AWS chose Rust
Microservices (throughput critical) Rust Wizer, Shuttle, Encore chose Rust
Rapid prototyping Go Smaller learning curve
CLI tools Either Both excellent
Embedded Rust No runtime, deterministic

The Compiler Performance Story

The biggest legitimate complaint about Rust was compile times. In 2026, it’s still slower than Go, but significant improvements arrived:

# Rust incremental compilation improved dramatically

# First build (cold cache)
$ cargo build --release
# Time: ~45s for a medium project

# Incremental build (one file changed)
$ cargo build --release
# Time: ~8s (was ~35s in 2024)

# rust-analyzer type checking
# VS Code + rust-analyzer gives instant feedback
# Before you even save, you see borrow errors

The trick: use cargo check during development (faster, no code generation), cargo build for final builds.

Rust’s Achilles Heel: Compile Times

# For faster iteration, use these settings in .cargo/config.toml

[build]
# Use all CPU cores for linking
rustflags = ["-C", "linker=clang", "-C", "codegen-units=1"]

# Or use mold linker (much faster than default lld)
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]

The WASM Story: Rust + WASM = Production Ready

Rust is the language of choice for WebAssembly:

// math_utils/src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u64 {
    match n {
        0 => 0,
        1 => 1,
        _ => {
            let mut a: u64 = 0;
            let mut b: u64 = 1;
            for _ in 2..=n {
                let temp = a + b;
                a = b;
                b = temp;
            }
            b
        }
    }
}
# Build for WASM
cargo install wasm-pack
wasm-pack build --target web

# 200x faster than JavaScript for Fibonacci
# Smaller binary than comparable C++ WASM
# Zero runtime overhead

What Rust Is NOT Good For

Let’s be honest:

# Data science / ML
# Python with NumPy/PyTorch is the standard
# Rust bindings exist but ecosystem is fragmented
# Not worth the learning curve for ML work

# Scripts and automation
# Python/Bash/Shell are faster to write
# Rust is overkill for one-off scripts

# Simple CRUD APIs where Go/Python is "good enough"
# Don't use Rust because it's cool
# Use it when you need the performance or safety

The Learning Curve Reality

# Realistic timeline to productivity in Rust:

Week 1: Fighting the borrow checker constantly
Week 2: Understanding ownership conceptually
Week 3: Writing simple programs without fighting
Month 2: Comfortable with lifetimes in structs
Month 3: Can navigate async Rust and the crate ecosystem
Month 6: Productive Rust developer

# The curve is real but the investment pays off
# Once you internalize ownership, you see memory bugs everywhere in C code

The Bottom Line

Rust in 2026 is production-ready for:

  • Systems where memory safety is non-negotiable
  • Networking infrastructure (proxies, CDNs, firewalls)
  • WebAssembly modules (performance-critical browser code)
  • High-throughput services where Go isn’t fast enough
  • Any codebase where you want to eliminate an entire class of bugs

It’s not ready for:

  • Rapid prototyping (use Python or Go)
  • Data science (use Python)
  • Teams without time to invest in the learning curve

The question isn’t “is Rust ready?” It’s “is Rust right for your use case?”

Learning or using Rust in 2026? What’s your experience been?