Node / Express Guide

Services

The service layer — business logic and orchestration.

Services contain all business logic. They coordinate between repositories, third-party clients, and other services. They do not touch req/res.

TL;DR — Factory functions that call repos, throw HttpError subclasses, use signJwtToken({ payload, secretKey, signOptions }) for JWT creation, and useAtlas.startSession() (static class) for transactions.

Pattern

Like controllers, services are factory functions:

// src/services/auth.service.ts
import crypto from "crypto";
import jwt from "jsonwebtoken";
import type { SignOptions } from "jsonwebtoken";
import {
  signJwtToken,
  comparePasswords,
  hashToken,
  BadRequestError,
  NotFoundError,
  useAtlas,
  useCache,
} from "@codisolutions23/node-utils";
import {
  ACCESS_TOKEN_SECRET,
  ACCESS_TOKEN_EXPIRY,
  REFRESH_TOKEN_SECRET,
  REFRESH_TOKEN_EXPIRY,
} from "../config";
import { log } from "../utils/log.util";
import { useUserRepo } from "../repositories/user.repo";
import { useRevokedTokenRepo } from "../repositories/revoked-token.repo";
import { useTokenRepo } from "../repositories/token.repo";

export function useAuthService() {
  const { getUserByEmail, getUserById } = useUserRepo();
  const { createRevokedToken, createTTLIndex } = useRevokedTokenRepo();
  const { createToken, getByToken, deleteByToken } = useTokenRepo();
  const { getCache, setCache, delCache } = useCache();

  const resource = "auth.service";

  // Both access and refresh are JWTs.
  // Access token gets a `jti` (via crypto.randomUUID()) for revocation.
  function generateTokens(
    userId: string,
    organizationId?: string,
    role?: string,
  ) {
    const payload = { user: userId, organizationId, role };

    const refreshToken = signJwtToken({
      payload,
      secretKey: REFRESH_TOKEN_SECRET,
      signOptions: {
        expiresIn: REFRESH_TOKEN_EXPIRY as SignOptions["expiresIn"],
      },
    });

    const accessToken = signJwtToken({
      payload,
      secretKey: ACCESS_TOKEN_SECRET,
      signOptions: {
        expiresIn: ACCESS_TOKEN_EXPIRY as SignOptions["expiresIn"],
        jwtid: crypto.randomUUID(), // ← jti for revocation
      },
    });

    return { accessToken, refreshToken };
  }

  async function login({
    email,
    password,
    organizationId,
  }: {
    email: string;
    password: string;
    organizationId: string;
  }) {
    const method = "login";

    const user = organizationId
      ? await getUserByEmail({ email, organizationId })
      : await getUserByEmail({ email });

    if (!user) throw new NotFoundError("Invalid credentials.");

    const isValid = await comparePasswords(password, user.password || "");
    if (!isValid) throw new BadRequestError("Invalid credentials.");

    const userId = user._id!.toString();
    const { accessToken, refreshToken } = generateTokens(
      userId,
      user.organizationId?.toString(),
      user.type,
    );

    // Store refresh token hash in tokens collection (not on user document)
    const tokenHash = hashToken(refreshToken);
    await createToken({ type: "refresh", token: tokenHash, user: userId });

    log("info", resource, method, "Successful login", { userId, email });
    return { accessToken, refreshToken, _id: userId };
  }

  // Blacklist access token by storing its jti until expiry
  async function blacklistAccessToken(
    accessToken: string,
    userId: string,
    method: string,
  ) {
    const decoded = jwt.verify(
      accessToken,
      ACCESS_TOKEN_SECRET,
    ) as jwt.JwtPayload;
    const jti = decoded.jti;
    const exp = decoded.exp;
    if (jti && exp) {
      const expiresAt = new Date(exp * 1000);
      await createTTLIndex();
      await createRevokedToken(jti, userId, expiresAt);
    }
  }

  return { login, generateTokens, blacklistAccessToken };
}

signJwtToken Signature

signJwtToken from codi-node-utils takes a single object — not positional arguments:

import { signJwtToken } from "@codisolutions23/node-utils";

const token = signJwtToken({
  payload: { user: userId, role: "OWNER" },
  secretKey: ACCESS_TOKEN_SECRET,
  signOptions: { expiresIn: "15m", jwtid: crypto.randomUUID() },
});

Token Strategy

TokenTypeSecretjtiStorage
AccessJWTACCESS_TOKEN_SECRETYes (crypto.randomUUID())Not stored — verified on each request
RefreshJWTREFRESH_TOKEN_SECRETNoHashed (hashToken) → tokens collection

Revocation: On logout, the access token's jti is stored in a revoked_tokens collection with a TTL index that auto-expires matching the token's exp claim.

Rules

  1. Throw HttpError subclasses — never return null to signal failure. The error handler catches them.
  2. Log meaningful events'info' for successful operations, 'error' for caught exceptions.
  3. No req/res — accept plain typed inputs, return plain data.
  4. Composed from repos — a service calls multiple repos when needed; never queries MongoDB directly.
  5. Transactions — use useAtlas.startSession() when multiple write operations must be atomic.
useAtlas is a static class — call useAtlas.startSession(), not useAtlas().startSession().

Transaction Example

import { useAtlas } from "@codisolutions23/node-utils";

async function createResourceWithRelated(input: TResourceCreate) {
  const session = await useAtlas.startSession();
  try {
    session.startTransaction();

    const resourceId = await resourceRepo.add(new Resource(input), session);
    await relatedRepo.add(new Related({ resourceId }), session);

    await session.commitTransaction();
    return resourceId;
  } catch (err) {
    await session.abortTransaction();
    throw err;
  } finally {
    session.endSession();
  }
}
base.add() returns insertedId (an ObjectId), not the full document.

Resource Service Example

// src/services/resource.service.ts
import { ConflictError, NotFoundError } from "@codisolutions23/node-utils";
import { useResourceRepo } from "../repositories/resource.repo";
import { Resource } from "../models/resource.model";
import { log } from "../utils/log.util";
import type { TResourceCreate } from "../models/resource.model";

export function useResourceService() {
  const repo = useResourceRepo();
  const resource = "resource.service";

  async function getAll(params: {
    organizationId: string;
    page?: number;
    limit?: number;
  }) {
    return repo.getResources(params);
  }

  async function create(input: TResourceCreate) {
    const method = "create";
    const existing = await repo.getByName(input.name, input.organizationId);
    if (existing)
      throw new ConflictError("A resource with this name already exists.");

    const insertedId = await repo.createResource(input);
    log("info", resource, method, "Resource created.", { id: insertedId });
    return insertedId;
  }

  async function update(id: string, input: Partial<TResourceCreate>) {
    const method = "update";
    const existing = await repo.getById(id);
    if (!existing) throw new NotFoundError("Resource not found.");

    await repo.updateResource(id, input);
    log("info", resource, method, "Resource updated.", { id });
  }

  async function remove(id: string) {
    const method = "remove";
    const existing = await repo.getById(id);
    if (!existing) throw new NotFoundError("Resource not found.");

    await repo.deleteResource(id);
    log("info", resource, method, "Resource deleted.", { id });
  }

  return { getAll, create, update, remove };
}