Skip to content
KheAi
Go back

The README — Enterprise NestJS Monorepo (enterprise-todo)

Edit page

This post is the README. By the end of the 24-part series you have a full enterprise NestJS + Next.js monorepo. Copy the content below directly into README.md at the root of your workspace.


enterprise-todo

An enterprise-grade todo application built across a 24-part tutorial series — demonstrating every pattern needed to build a production NestJS + Next.js monorepo from scratch.

What this is:


Tutorial Series

Each part adds one layer of the stack with working code and Meteor migration context.

PartSlugTitleKey concepts
16101Meteor → NestJS: The Mental ShiftExplicit vs implicit philosophy, monorepo rationale, concept translation
26102Environment Setup & Nx Workspacenvm, Yarn, Docker Compose, Nx scaffold, first NestJS app
36103TypeScript Decorators, DI & ModulesDecorators, dependency injection, module system, GraphQL vs REST
46104Database: PostgreSQL, TypeORM & MigrationsEntities, AbstractEntity, SnakeNamingStrategy, migration workflow
56105Production Hardening: Config, Logging & SecurityJoi validation, typed AppConfig, LoggingInterceptor, Helmet, throttling, AllExceptionsFilter
66106CQRS — The Enterprise Request PipelineCommandBus, QueryBus, nestjs-typed-cqrs, 9-step pattern, thin handlers
76107GraphQL API + Next.js FrontendDTOs, @FilterableField, ConnectionType, cursor pagination, Next.js 16, Apollo Client v4
86108Authentication: JWT RS256, Guards & ValidationRS256 key pairs, Passport JWT, AuthJwtGuard, @CurrentUser(), ValidationPipe, dual-auth
96109Extended Auth: Email, SecuredTokens & 2FANodemailer + Bull, single-use tokens, password reset, TOTP 2FA with otplib
106110Case Study 1 — Tag Module (9-Step Build)Full walkthrough of every step with zero skipped
116111Case Study 2 — Todo Module (FK + Auth + DataLoader)Foreign keys, ownership scoping, N+1 prevention, Scope.REQUEST
126112Testing: Unit + E2E with Real DBJest, mock TypeORM correctly, real PostgreSQL in E2E, CI integration
136113Queues & Real-Time: Bull + Redis PubSubBull jobs, Redis PubSub vs in-process, GraphQL subscriptions
146114Advanced Data PatternsLowerCaseTransformer, AuditSubscriber, pessimistic-lock running numbers, libs/core
156115Multi-Tenancy & RBACtenantId pattern, TenantGuard, @Authorize, ACPermissionGuard, RBAC
166116Dual-App Portal: Platform Interceptorapps/portal-api, RequestPlatformInterceptor, platform JWT claim, PortalJwtStrategy
176117Media Library: S3, Presigned URLs & CDNS3 presigned upload flow, magic-byte validation, sharp thumbnails, CloudFront
186118Affiliate & Referral Tree: Materialized PathreferralCode, materialized path, downline queries, referral stats
196119Git Workflow, Husky & CI/CDConventional commits, Commitizen, Husky, branch strategy, GitHub Actions CI
206120Production Deployment: ECS, RDS & ElastiCacheECS Fargate, RDS Multi-AZ, ElastiCache TLS, ALB, Secrets Manager, OIDC CD
216121Claude Code: AI Development Layer.claude/ structure, CLAUDE.md, graphify, gitnexus, 6-phase AI workflow
226122MCP: GitHub, ClickUp & Project ManagementGitHub MCP, built-in OAuth integrations, prompt library
236123Memory, Knowledge Graphs & Code IntelligencePersistent memory, graphify codebase graph, gitnexus call graph
246124Tech Lead SDLC & AI-Assisted Daily WorkflowTicket-to-production case study, sprint ceremonies, ADRs, onboarding

Stack

LayerTechnology
MonorepoNx 22
BackendNestJS 11, Express 5, Apollo Server v5 (code-first GraphQL), TypeORM 0.3.x, CQRS
FrontendNext.js 16 (App Router), Tailwind CSS v4, Apollo Client v4, Shadcn UI (base-nova)
DatabasePostgreSQL 15
Cache / QueueRedis 7 (Alpine)
AuthPassport JWT — RS256 (asymmetric, 4096-bit RSA keys)
RuntimeNode 20, Yarn 1.x (Classic)
InfrastructureDocker Compose (local) · ECS Fargate + RDS + ElastiCache (production)

Key ecosystem packages:

PackagePurpose
nestjs-typed-cqrsType-safe CommandBus / QueryBus — no any on bus execute/dispatch
nestjs-dev-utilitiesAbstractEntity base class (id, createdAt, updatedAt, deletedAt, soft-delete)
@ptc-org/nestjs-query-coreQuery<T> filter/sort/paging types
@ptc-org/nestjs-query-graphql@FilterableField, QueryArgsType, ConnectionType (Relay cursor pagination)
@ptc-org/nestjs-query-typeormTypeOrmQueryService<T> + FilterQueryBuilder
typeorm-naming-strategiesAutomatic snake_case column and table names
@jorgebodega/typeorm-seedingDatabase seeders
@as-integrations/express5Apollo Server v5 → Express 5 adapter — required, the Express 4 adapter is incompatible
otplibRFC 6238 TOTP — 2FA secret generation and token verification
sharpImage processing for media thumbnails in the Bull processor

Project Structure

enterprise-todo/
├── apps/
│   ├── api/                           ← NestJS user API  (GraphQL at :3333)
│   │   └── src/
│   │       ├── app/
│   │       │   ├── app.module.ts      ← root module; explicit entities[], CqrsModule.forRoot()
│   │       │   ├── app.resolver.ts    ← health check
│   │       │   └── ormconfig.ts       ← TypeORM DataSource for the CLI (no NestJS DI)
│   │       ├── config/
│   │       │   ├── config.validation.ts   ← Joi schema (all env vars declared here)
│   │       │   └── config.mapper.ts       ← typed AppConfig getter
│   │       ├── filters/
│   │       │   └── all-exceptions.filter.ts   ← global; re-throws for GraphQL, logs 5xx
│   │       ├── interceptors/
│   │       │   └── logging.interceptor.ts     ← request/response timing from nestjs-dev-utilities
│   │       ├── subscribers/
│   │       │   └── audit.subscriber.ts        ← AuditSubscriber (createdBy/updatedBy via CLS)
│   │       ├── helpers/
│   │       │   ├── lower-case.transformer.ts
│   │       │   ├── slug.transformer.ts
│   │       │   └── upper-case.transformer.ts
│   │       ├── modules/
│   │       │   ├── auth/              ← JWT RS256, AuthJwtGuard, @CurrentUser()
│   │       │   │   ├── decorators/    ←   current-user.decorator.ts
│   │       │   │   ├── dto/           ←   AuthTokensDto, SignInInput, RegisterInput
│   │       │   │   ├── guards/        ←   auth-jwt.guard.ts
│   │       │   │   ├── strategies/    ←   jwt.strategy.ts
│   │       │   │   ├── auth.interface.ts   ← JwtPayload (sub, username, platform)
│   │       │   │   ├── auth.module.ts
│   │       │   │   ├── auth.resolver.ts
│   │       │   │   └── auth.service.ts
│   │       │   ├── email/             ← Nodemailer + Bull queue (async delivery)
│   │       │   ├── health/            ← HealthResolver (@SkipThrottle)
│   │       │   ├── media/             ← S3 presigned uploads, magic bytes, CloudFront
│   │       │   │   ├── media.entity.ts
│   │       │   │   ├── media.processor.ts   ← Bull worker: validate → thumbnail → mark ready
│   │       │   │   ├── media.resolver.ts
│   │       │   │   └── s3.service.ts
│   │       │   ├── permission/        ← PermissionEntity (slug-based ACL rows)
│   │       │   ├── referral/          ← materialized path tree, referralCode, downline
│   │       │   ├── role/              ← RoleEntity (ManyToMany → permissions)
│   │       │   ├── running-number/    ← pessimistic-lock sequence service
│   │       │   ├── secured-token/     ← single-use tokens (password reset, email verify)
│   │       │   ├── tag/               ← Tag feature (full 9-step CQRS)
│   │       │   ├── todo/              ← Todo feature (CQRS + DataLoader + ownership)
│   │       │   │   ├── cqrs/          ←   inputs, handlers, index
│   │       │   │   ├── dto/           ←   TodoDto, CreateTodoInput, UpdateTodoInput
│   │       │   │   ├── todo.constant.ts
│   │       │   │   ├── todo.entity.ts
│   │       │   │   ├── todo.loader.ts    ← DataLoader (Scope.REQUEST, N+1 fix)
│   │       │   │   ├── todo.module.ts
│   │       │   │   ├── todo.resolver.ts
│   │       │   │   └── todo.service.ts   ← extends TypeOrmQueryService<TodoEntity>
│   │       │   └── user/
│   │       │       ├── dto/           ←   UserDto (no password field)
│   │       │       ├── user.constant.ts
│   │       │       └── user.entity.ts    ← referralCode, path (materialized), 2FA columns
│   │       ├── shared/
│   │       │   └── guards/
│   │       │       ├── ac-permission.guard.ts     ← ACPermissionGuard
│   │       │       ├── use-ac-guard.decorator.ts  ← @UseACGuard('MODULE', ['slug'])
│   │       │       └── allow-guest.decorator.ts   ← @AllowGuest()
│   │       ├── migrations/            ← TypeORM migration files (explicit, no globs)
│   │       ├── seeders/
│   │       │   ├── 0-reset.seeder.ts
│   │       │   ├── 1-user.seeder.ts
│   │       │   └── 2-todo.seeder.ts
│   │       ├── main.ts                ← bootstrap: helmet, throttle, ValidationPipe, CORS
│   │       └── migration-runner.ts    ← ECS one-off migration task entrypoint
│   │
│   ├── api-e2e/                       ← API end-to-end tests (real PostgreSQL + Redis)
│   │
│   ├── portal-api/                    ← NestJS admin portal API  (GraphQL at :3334)
│   │   └── src/
│   │       ├── app/
│   │       │   └── portal-app.module.ts   ← shares same DB; NO separate migrations
│   │       └── modules/
│   │           ├── portal-auth/           ← PortalJwtStrategy ('portal-jwt'), PortalAuthJwtGuard
│   │           └── portal-health/
│   │
│   └── web/                           ← Next.js 16 frontend  (:3000)
│       └── src/
│           ├── app/
│           │   ├── layout.tsx
│           │   ├── page.tsx
│           │   └── providers.tsx      ← ApolloProvider + authLink
│           ├── components/
│           │   ├── todo-list.tsx
│           │   └── ui/                ← Shadcn UI (Button, Input, Card, Checkbox, Badge)
│           ├── graphql/
│           │   ├── generated.ts       ← codegen output (typed hooks)
│           │   ├── auth.operations.ts
│           │   └── todo.operations.ts
│           ├── hooks/
│           │   └── use-auth.ts
│           └── lib/
│               └── apollo-client.ts   ← ApolloClient, InMemoryCache, authLink

├── libs/
│   ├── contracts/                     ← shared TypeScript types (api + portal-api + web)
│   └── core/                          ← Joi schema, AppConfig, queue constants,
│                                         RequestPlatformInterceptor, CoreConfigModule

├── infra/
│   └── task-definitions/              ← ECS task definitions (api, portal-api, migrator)

├── scripts/
│   └── fix-typeorm-deps.cjs           ← ensures single reflect-metadata instance for TypeORM CLI

├── .github/
│   ├── workflows/
│   │   ├── ci.yml                     ← lint + unit test + E2E (Postgres + Redis services)
│   │   └── deploy.yml                 ← OIDC → ECR push → migration task → ECS rolling deploy
│   └── PULL_REQUEST_TEMPLATE.md

├── codegen.ts                         ← GraphQL Code Generator config
├── commitlint.config.js
├── docker-compose.dev.yml             ← Postgres :5432 · Redis :6379 · Adminer :8080
├── docker-compose.dev.arm.yml         ← Apple Silicon (linux/arm64 images)
├── nx.json
├── package.json
├── tsconfig.base.json
├── tsconfig.typeorm.json              ← TypeORM CLI path aliases (separate from app tsconfig)
└── .env                               ← local env vars (never commit — in .gitignore)

Prerequisites

ToolVersionInstall
nvmlatestcurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
Node.js20 LTSnvm install 20 && nvm alias default 20
Yarn1.22.x (Classic)npm install -g yarn
Docker Desktop24+ with Compose v2docker.com — verify docker compose version
Git2.xbrew install git (macOS)

VS Code extensions (install all for the full AI-assisted dev experience):

nrwl.angular-console          Nx Console (project graph, task runner)
esbenp.prettier-vscode        Prettier formatter
dbaeumer.vscode-eslint        ESLint
eamodio.gitlens               Git history + blame inline
kumar-harsh.graphql-for-vscode GraphQL schema highlighting
firsttris.vscode-jest-runner  Run individual Jest tests from the gutter
mikestead.dotenv              .env syntax highlighting
ms-azuretools.vscode-docker   Docker container management
Gruntfuggly.todo-tree         TODO/FIXME tracking in sidebar
anthropic.claude-code         Claude Code AI assistant (if installed)

Getting Started

1. Install dependencies

yarn install

2. Create Docker volumes (first time only)

docker volume create db_volume
docker volume create redis_volume

3. Start Docker infrastructure

yarn docker:dev        # Intel / Linux
yarn docker:dev:arm    # Apple Silicon M1/M2/M3
# Starts: PostgreSQL :5432 · Redis :6379 · Adminer :8080

4. Create your .env file

cp .env.example .env   # then fill in the required values

See the Environment Variables section below for the full reference.

5. Generate RSA key pairs (first time only)

# User API keys (access + refresh)
openssl genrsa -out jwt_private.pem 4096
openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem
openssl genrsa -out jwt_refresh_private.pem 4096
openssl rsa -in jwt_refresh_private.pem -pubout -out jwt_refresh_public.pem

# Convert each PEM to a single-line string for .env:
awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' jwt_private.pem
# Paste the output as JWT_PRIVATE_KEY= in .env (including -----BEGIN/END PRIVATE KEY-----)

Generate a separate pair for ADMIN_JWT_PRIVATE_KEY / ADMIN_JWT_PUBLIC_KEY (portal-api).

6. Run database migrations

yarn api:migration:run

7. (Optional) Seed with sample data

yarn api:seed:run

8. Start dev servers

# In separate terminals:
yarn api:dev      # → http://localhost:3333  | GraphQL: http://localhost:3333/graphql
yarn web:dev      # → http://localhost:3000
yarn portal:dev   # → http://localhost:3334  | GraphQL: http://localhost:3334/graphql

Environment Variables

Create .env in the workspace root. All variables are required unless marked optional.

# ── App ─────────────────────────────────────────────────────────────────────
NODE_ENV=development
PROJECT_PORT=3333
PROJECT_GRAPHQL_PLAYGROUND=true
PROJECT_GRAPHQL_SUBSCRIPTIONS=false
ALLOWED_ORIGINS=                         # production only: comma-separated allowed origins

# ── Database ─────────────────────────────────────────────────────────────────
PROJECT_DB_CONNECTION=postgres
PROJECT_DB_HOST=localhost
PROJECT_DB_PORT=5432
PROJECT_DB_USERNAME=postgres
PROJECT_DB_PASSWORD=postgres
PROJECT_DB_DATABASE=enterprise_todo
PROJECT_DB_DEBUG=false

# ── Database (Test / E2E) ─────────────────────────────────────────────────
PROJECT_DB_DATABASE_TEST=enterprise_todo_test   # E2E test database (separate from main)

# ── Redis ─────────────────────────────────────────────────────────────────────
REDIS_BULL_HOST=localhost        # Bull queue broker
REDIS_BULL_PORT=6379
REDIS_PUBSUB_HOST=localhost      # GraphQL subscriptions PubSub
REDIS_PUBSUB_PORT=6379

# ── JWT / Auth (RS256) ───────────────────────────────────────────────────────
# Generate: openssl genrsa 4096 | openssl pkcs8 -topk8 -nocrypt
# Then: awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' key.pem
# Keep -----BEGIN PRIVATE KEY----- and -----END PUBLIC KEY----- headers
JWT_PRIVATE_KEY=
JWT_PUBLIC_KEY=
JWT_REFRESH_PRIVATE_KEY=
JWT_REFRESH_PUBLIC_KEY=
JWT_EXPIRATION_TIME=1d
JWT_REFRESH_EXPIRATION_TIME=7d

# ── Email (SMTP) ───────────────────────────────────────────────────────────────
# Local dev: use Mailpit (add to docker-compose: port 1025 / UI :8025)
# Production: use AWS SES (SMTP interface)
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USER=                       # optional in dev (Mailpit accepts unauthenticated)
SMTP_PASS=                       # optional in dev

WEB_URL=http://localhost:3000    # included in password-reset and verify-email links

# ── Two-Factor Auth ───────────────────────────────────────────────────────────
# Dev/test bypass ONLY — code enforces NODE_ENV !== 'production' guard
# Leave empty in staging and production (or the Joi schema will reject it)
TWOFA_BYPASS_PASSWORD=

# ── Media Library (AWS S3 + CloudFront) ───────────────────────────────────────
# Local dev: point at LocalStack or a real dev bucket with restricted CORS
AWS_REGION=ap-southeast-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
S3_BUCKET=enterprise-todo-media-dev
CDN_BASE_URL=https://d1abc.cloudfront.net   # your CloudFront distribution URL

# ── Admin Portal API ──────────────────────────────────────────────────────────
PROJECT_PORTAL_PORT=3334

# Admin JWT (RS256) — SEPARATE key pair from user API — never share
# PortalJwtStrategy lives only in apps/portal-api, never in apps/api
ADMIN_JWT_PRIVATE_KEY=
ADMIN_JWT_PUBLIC_KEY=
ADMIN_JWT_EXPIRATION_TIME=8h

Scripts

CommandWhat it does
yarn api:devStart user API in watch mode (:3333)
yarn api:buildProduction build of the user API
yarn api:testUnit tests for the user API
yarn api:e2eEnd-to-end tests (spins up real Postgres + Redis)
yarn portal:devStart admin portal API in watch mode (:3334)
yarn portal:buildProduction build of the portal API
yarn portal:testUnit tests for the portal API
yarn web:devStart Next.js frontend in watch mode (:3000)
yarn codegenGenerate typed hooks from GraphQL schema via graphql-codegen
yarn formatRun Prettier across all files
yarn lintLint all projects
yarn lint:fixLint + auto-fix all projects
yarn docker:devStart Postgres · Redis · Adminer (Intel / Linux)
yarn docker:dev:armStart Postgres · Redis · Adminer (Apple Silicon)
yarn docker:stopStop all Docker containers
yarn api:migration:generate <path>Generate migration from entity diff (pass full output path: apps/api/src/migrations/CreateTagTable)
yarn api:migration:runApply all pending migrations
yarn api:migration:revertRevert the last applied migration
yarn api:seed:runTruncate + reseed with sample data
yarn depOpen Nx project dependency graph in browser
yarn czCommit using Commitizen (conventional commits — enforced by Husky)

Database

Adminer (web DB UI) is available at http://localhost:8080 while Docker is running.

FieldValue
SystemPostgreSQL
Serverpostgres (Docker internal DNS — NOT localhost)
Usernamepostgres
Passwordpostgres
Databaseenterprise_todo

Migration rules:


Architecture

Dual-App Monorepo

Internet
   ├─ :3333  apps/api         User-facing API   (AuthJwtStrategy      — user RS256 key pair)
   ├─ :3334  apps/portal-api  Admin portal API  (PortalJwtStrategy    — admin RS256 key pair)
   └─ :3000  apps/web         Next.js frontend

libs/core       → Joi schema, typed AppConfig, queue constants, RequestPlatformInterceptor
libs/contracts  → Shared TypeScript types across all three apps

RequestPlatformInterceptor enforces that a user JWT (stamped platform: 'user') cannot authenticate against the portal API and vice versa — structural enforcement at the interceptor layer before guard logic runs.

9-Step CQRS Module Pattern

Every feature module follows this exact sequence:

Step 1  Entity        → extends AbstractEntity, columns, relations, @Index on filtered fields
Step 2  Constants     → enums + registerEnumType() (required for GraphQL schema generation)
Step 3  DTOs          → @ObjectType (output), @InputType (mutation), @ArgsType (list query)
Step 4  CQRS Inputs   → FindOne, FindMany, Count, CreateOne, UpdateOne, DeleteOne classes
Step 5  CQRS Handlers → one line each: return this.service.method(args)
Step 6  CQRS Index    → export handler arrays + re-export input classes
Step 7  Service       → extends TypeOrmQueryService<Entity>, business rules, exceptions
Step 8  Resolver      → @UseGuards, @CurrentUser(), dispatch to CommandBus / QueryBus
Step 9  Module        → TypeOrmModule.forFeature([Entity]) + spread handler arrays
        Register      → add to AppModule.imports[] and AppModule entities[] (explicit — no globs)
        Migrate       → generate → review → run → verify
        Test          → service unit tests + handler unit tests + E2E smoke test

Request Lifecycle (user API)

GraphQL Request


Helmet / ThrottlerGuard     HTTP security headers; rate limit (global, skip health checks)


LoggingInterceptor          log request start + end time


AuthJwtGuard                verify RS256 JWT signature; reject expired / invalid


RequestPlatformInterceptor  reject portal tokens (platform: 'portal') with 403


TenantGuard                 extract tenantId from JWT → TenantContext (Scope.REQUEST)


ACPermissionGuard           check user.status === ACTIVE + permission slugs


ValidationPipe              class-validator on all inputs (whitelist + forbidNonWhitelisted)


Resolver method             @CurrentUser() injects typed user; dispatches one command/query


Handler                     always one line — calls one service method, nothing else


Service  extends TypeOrmQueryService<Entity>   business logic, ownership checks, exceptions


@Authorize decorator        nestjs-query merges WHERE tenant_id = $1 at QueryBuilder level


PostgreSQL                  tenant isolation guaranteed at the data layer

Key Architectural Rules


Notable Gotchas

reflect-metadata must be ^0.2.2

typeorm and nestjs-dev-utilities bundle their own reflect-metadata ^0.2.x. If your root package.json has ^0.1.x, two separate WeakMap instances coexist and NestJS’s DI metadata becomes invisible after TypeORM initialises. Symptom: UnknownDependenciesException: Nest can't resolve dependencies of ConfigService.

Fix: set "reflect-metadata": "^0.2.2" in package.json and run yarn install to force deduplication.


TypeORM entities must be explicitly listed — no glob patterns

This project builds with Webpack. At runtime everything compiles into a single main.js — there are no separate .entity.js files on disk for glob patterns to find. entities: ['**/*.entity{.ts,.js}'] silently finds nothing.

Every entity must be imported and listed explicitly in AppModule’s entities[].


All related entities must be registered, even without a feature module

TodoEntity has @ManyToOne(() => UserEntity). Even if no UserModule exists yet, UserEntity must appear in AppModule’s entities[]. Omitting it causes EntityMetadataNotFoundError at startup.


graphql must be pinned to @16 on Node 20

graphql@17 requires Node 22. Installing without a version pin pulls v17 and breaks the build on Node 20. Always: yarn add graphql@16.

Same issue with @graphql-codegen/cli — pin to @5. Version 6 pulls listr2@10 which also requires Node 22.


TypeORM CLI migration:generate takes a positional path, not --name

The --name flag was removed in TypeORM 0.3. The correct form is:

yarn api:migration:generate apps/api/src/migrations/CreateTagTable
# NOT: yarn api:migration:generate --name=create-tag-table

Apollo Sandbox: name mutations that return Boolean

Anonymous mutations returning a scalar (mutation { deleteTodo(id: 1) }) trigger a spurious “syntax error: invalid number” in Apollo Studio Sandbox. The API is correct — verify with curl. Fix: name the operation:

mutation DeleteTodo { deleteTodo(id: 1) }

Apollo Client v4: all React APIs moved to @apollo/client/react

Turbopack resolves @apollo/client to its core package, which exports no React APIs. Import React APIs from @apollo/client/react:

// ✅
import { ApolloProvider, useQuery, useMutation } from '@apollo/client/react';
import { ApolloClient, InMemoryCache, gql } from '@apollo/client';

// ❌ — silent undefined at runtime
import { ApolloProvider, useQuery } from '@apollo/client';

Tailwind CSS v4: no tailwind.config.js, new PostCSS plugin

This project uses Tailwind v4 (required by shadcn base-nova style). Key differences from v3:

/* ✅ Tailwind v4 */
@import "tailwindcss";

/* ❌ Tailwind v3 — do not use */
@tailwind base;
@tailwind components;
@tailwind utilities;
// postcss.config.js
// ✅ v4
module.exports = { plugins: { '@tailwindcss/postcss': {} } };

// ❌ v3
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };

No tailwind.config.js — v4 auto-detects content files.


@nx/next uses dev target, not serve

npx nx serve web fails — the @nx/next plugin registers the dev target as dev. Use:

yarn web:dev      # = nx dev web

Helmet blocks Apollo Sandbox in development

app.use(helmet()) enforces a strict CSP that blocks Apollo Sandbox’s inline scripts. The fix — already in main.ts:

app.use(helmet({
  contentSecurityPolicy: process.env.NODE_ENV === 'production' ? undefined : false,
}));

@SkipThrottle() on health and internal resolvers

ThrottlerGuard applied globally will rate-limit load balancer health checks in production, causing the ALB to mark the task as unhealthy. HealthResolver (and any internal-only resolver) must have @SkipThrottle().


TWOFA_BYPASS_PASSWORD is blocked at the code level in production

The bypass is gated by process.env.NODE_ENV !== 'production' in verifyTwoFactorLogin. It has no effect in production regardless of the env var value — but for clarity, leave the variable unset in staging and production .env files.


PortalJwtStrategy in the wrong app

PortalJwtStrategy (named 'portal-jwt') belongs exclusively in apps/portal-api. Registering it in apps/api means a portal-issued token can authenticate user endpoints — defeating the entire dual-auth architecture.


Production Stack (AWS)

Deployed via GitHub Actions CD with OIDC (no long-lived access keys in CI secrets).

Route 53
   └─ ALB  (host-based routing)
        ├─ api.yourdomain.com      → ECS Service: enterprise-todo-api
        └─ portal.yourdomain.com  → ECS Service: enterprise-todo-portal-api

ECS Fargate
   ├─ Task: enterprise-todo-api       (port 3333, nestjs user API)
   ├─ Task: enterprise-todo-portal    (port 3334, nestjs admin portal)
   └─ Task: enterprise-todo-migrator  (one-off task — runs BEFORE rolling deploy)

RDS PostgreSQL 15 Multi-AZ
   └─ db.t4g.medium · gp3 · 7-day backups · deletion protection

ElastiCache Redis 7.x
   └─ cache.t4g.small · TLS in-transit + at-rest

ECR
   ├─ enterprise-todo-api       (lifecycle: keep last 20 images)
   └─ enterprise-todo-portal    (lifecycle: keep last 20 images)

AWS Secrets Manager
   └─ All secrets: RSA key pairs, DB password, SMTP credentials, AWS API keys
      (injected as valueFrom into ECS task definitions — never plaintext env vars)

Deployment flow (zero-downtime):

git push → main
   └─ GitHub Actions: build → push to ECR → run migrator task → ECS rolling deploy

The migrator task runs the pending migrations inside the VPC, against the real RDS instance, before any new API pod starts serving traffic. This prevents race conditions where the new code starts before the schema is ready.


AI-Assisted Development

This project ships with a full Claude Code configuration in .claude/:

.claude/
├── CLAUDE.md           ← project rules (stack, commands, architecture, gotchas)
├── rules/
│   ├── architecture.md ← CQRS, module boundaries, handler one-liner rule
│   ├── security.md     ← platform claim, no PortalJwtStrategy in api, ownership scoping
│   ├── performance.md  ← DataLoader, N+1, @Index on filtered columns
│   └── migrations.md   ← no synchronize, no glob entities, no --name flag
├── agents/
│   ├── backend-specialist.md    ← CQRS modules, services, resolvers
│   ├── migration-specialist.md  ← schema changes, pgvector, tenant ordering
│   ├── test-writer.md           ← unit + E2E tests, mock patterns
│   └── frontend-specialist.md   ← Next.js/Tailwind/Apollo
└── memory/
    └── MEMORY.md        ← personal dev context (gitignored — stays local)

Three-layer knowledge system:

LayerToolPurpose
Persistent memory~/.claude/projects/…/memory/Personal dev context across sessions
Codebase graphgraphifySemantic relationships, architecture overview
Call graphgitnexusSymbol-level: callers, callees, blast radius

See Parts 21–24 of the tutorial series for the full AI-assisted development workflow.


License

MIT


Edit page
Share this post:

Next Post
How to configure AstroPaper theme
Previous Post
Tech Lead SDLC & Daily Workflow: Ticket to Production