Architecting Enterprise NestJS Applications: Clean Architecture, Dynamic Caching, and Audit Logging
A deep dive into building production-ready Node.js backends using NestJS 11, TypeORM, Redis multi-level caching, CASL permissions, and process-segregated BullMQ queues.
Written by Kuybi's maintainer, drawing on the framework's actual production codebase rather than a generic NestJS tutorial.
Building enterprise backend software in Node.js requires balancing rapid feature development with non-negotiable operational requirements: RBAC/ABAC authorization, immutable audit trails, multi-tiered caching, background worker segregation, layered testing, and correlated observability. This post details how the open-source Kuybi framework solves these challenges end to end.
1. Domain-Driven Design (DDD) & Folder Blueprint
When Node.js applications scale beyond early prototypes, monolithic folder structures like controllers/, services/, and models/ quickly become maintenance bottlenecks.
In Kuybi, code is organized into strict Domain-Driven Design boundaries:
src/
โโโ core/ # System infrastructure (Database, Redis Cache, Pino Logger, Sentry)
โโโ infrastructure/ # Third-party integrations (S3 Media Storage, Email SMTP, SMS adapters)
โโโ modules/ # Isolated Domain Contexts (Auth, ACL, Users, Stories, Audit, Media)
โโโ shared/ # Cross-cutting Decorators, HTTP Exception Filters, GuardsEnforcing Module Autonomy
Each feature inside src/modules/ operates as an autonomous NestJS module encapsulating its own entities, DTOs, repositories, services, and HTTP controllers. Cross-module data access is governed exclusively through exported service interfaces, avoiding tight coupling.
2. Process Segregation: API Gateway vs Worker Node vs Dashboard
Running heavy tasks (such as image thumbnail generation, email dispatching, or bulk audit report generation) on the same Node.js event loop as incoming HTTP API traffic introduces latency spikes for end users.
To solve this, Kuybi enforces process segregation into three isolated entry points:
- `main.ts` (HTTP Gateway - Port 4040): Dedicated strictly to serving HTTP request/response lifecycles. CPU-intensive work is offloaded directly to Redis queues.
- `worker.ts` (BullMQ Task Consumer): An isolated, headless Node.js worker process that polls Redis queues and processes asynchronous jobs without interfering with web API responsiveness.
- `dashboard.ts` (Bull Board UI - Port 4050): A separate monitoring process allowing engineering teams to inspect job statuses, retry failed jobs, and monitor queue memory usage.
3. Multi-Level Caching: Sub-Millisecond Response Times
Database roundtrips to PostgreSQL typically consume 15msโ80ms depending on query complexity and network hops.
By integrating a smart multi-tier caching layer combining memory fallback and Redis 7 key pattern invalidation, entity reads serve responses in < 1ms.
Automatic Cache Invalidation Strategy
// Repositories automatically clear entity cache keys on mutations
async updateStory(id: string, dto: UpdateStoryDto): Promise<Story> {
const updated = await this.repository.save({ id, ...dto });
// Pattern purge invalidates both individual story cache and paginated list queries
await this.cacheManager.delByPattern(`stories:*`);
return updated;
}In internal benchmarking on read-heavy endpoints, this pattern removed the majority of primary-database read load by serving cached hits in under 1ms โ exact gains depend on cache hit ratio and query complexity, so measure against your own workload before treating any percentage as a guarantee.
4. Testing Strategy: Unit, Integration, and Ephemeral Database Containers
A DDD module boundary is only as trustworthy as the tests that guard it. Kuybi splits tests into three tiers with deliberately different feedback loops, rather than one large suite that is slow to run and slow to trust.
The Three-Tier Pyramid
- Unit tests (
*.spec.ts, colocated with source): mock the repository and cache layer entirely. These run in-process with Jest and finish in seconds โ they exist to pin down business logic (permission calculations, DTO transformation, pricing rules), not infrastructure. - Integration tests (
*.integration-spec.ts): exercise a real PostgreSQL and Redis instance via Testcontainers, spun up fresh per test file. These catch the class of bugs unit tests structurally cannot โ a TypeORM migration that doesn't match the entity, a Redis key pattern that doesn't actually match on delete, a unique constraint that fires at the database layer instead of the DTO validator. - E2E tests (
*.e2e-spec.ts): boot the full Nest application (Test.createTestingModule) and drive it withsupertestover HTTP, including guards, interceptors, and exception filters โ the closest simulation of production traffic short of a staging environment.
Ephemeral Containers Instead of a Shared Test Database
A shared test database is a recurring source of test flakiness: one suite's leftover row breaks another suite's assertion. Testcontainers sidesteps this by giving every integration test file its own disposable Postgres container:
// test/integration/story.repository.integration-spec.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { DataSource } from "typeorm";
describe("StoryRepository (integration)", () => {
let container: StartedPostgreSqlContainer;
let dataSource: DataSource;
beforeAll(async () => {
container = await new PostgreSqlContainer("postgres:16-alpine").start();
dataSource = new DataSource({
type: "postgres",
url: container.getConnectionUri(),
entities: [Story],
synchronize: true, // acceptable for ephemeral test containers only โ never in production
});
await dataSource.initialize();
});
afterAll(async () => {
await dataSource.destroy();
await container.stop();
});
it("enforces the unique slug constraint at the database layer", async () => {
const repo = dataSource.getRepository(Story);
await repo.save({ slug: "launch-week", title: "Launch Week" });
await expect(repo.save({ slug: "launch-week", title: "Duplicate" })).rejects.toThrow();
});
});CI Gating: Fast Feedback First
Unit tests run on every push and block the PR within seconds. Integration and e2e tests โ slower because they pull container images and boot a full Postgres instance โ run as a required check before merge but are not re-run on every keystroke locally. This keeps the inner development loop fast while still catching infrastructure-level regressions before they reach main.
5. Authentication Hardening: JWT Rotation and Refresh Token Reuse Detection
Short-lived access tokens paired with long-lived refresh tokens are standard practice, but the refresh token itself is a high-value target: a stolen refresh token grants an attacker indefinite re-authentication until it expires. Kuybi's ACL module adds refresh token rotation with reuse detection on top of the baseline pattern.
The Rotation Contract
- Access tokens are short-lived (15 minutes) JWTs, validated stateless โ no database roundtrip on every request.
- Refresh tokens are long-lived, opaque, single-use. Each refresh call issues a new refresh token and immediately invalidates the one just presented.
- Every refresh token belongs to a token family โ a chain rooted at the original login. Rotating a token keeps the family id; only the token value changes.
Reuse Detection: Catching Token Theft
The rotation-only version above already limits exposure, but it doesn't detect theft โ it just narrows the window. Reuse detection closes that gap: if a refresh token that has already been rotated out is presented again, that is a strong signal the token was intercepted and both the attacker and the legitimate user are racing to use it. The correct response is not to silently reject the request โ it's to revoke the entire token family, forcing every device on that session chain to re-authenticate.
// modules/acl/services/refresh-token.service.ts
async rotateRefreshToken(presentedToken: string): Promise<TokenPair> {
const record = await this.refreshTokenRepo.findOne({
where: { tokenHash: hash(presentedToken) },
});
if (!record) {
throw new UnauthorizedException("Invalid refresh token");
}
if (record.revokedAt) {
// This token was already rotated out once before โ reuse detected.
// Revoke the whole family: every token sharing this familyId.
await this.refreshTokenRepo.update(
{ familyId: record.familyId },
{ revokedAt: new Date() },
);
await this.auditLogService.record({
action: "REFRESH_TOKEN_REUSE_DETECTED",
userId: record.userId,
familyId: record.familyId,
});
throw new UnauthorizedException("Session revoked โ re-authentication required");
}
// Normal rotation: mark this token used, issue a fresh one in the same family
await this.refreshTokenRepo.update(record.id, { revokedAt: new Date() });
return this.issueTokenPair(record.userId, record.familyId);
}Only the hash of the refresh token is ever persisted โ matching password-storage discipline โ so a leaked database dump does not itself hand out usable tokens.
6. Observability: Correlated Structured Logging and Error Tracking
When a request fails somewhere between the Nginx edge, the NestJS API gateway, and a BullMQ worker three hops later, the single most useful thing a log line can carry is a correlation id that ties every log statement from that request together โ across process boundaries.
Structured JSON Logs with Pino
Kuybi replaces Nest's default console logger with Pino, emitting structured JSON instead of formatted strings. Structured logs are what make querying "every 5xx in the last hour for user X" a CloudWatch Insights query instead of a regex hunt through plaintext.
Propagating a Correlation ID with AsyncLocalStorage
Node's AsyncLocalStorage lets a value set at the top of a request โ before any await โ remain accessible deep inside service methods and queue handlers without threading it through every function signature:
// core/logging/correlation.middleware.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
export const correlationStorage = new AsyncLocalStorage<{ correlationId: string }>();
@Injectable()
export class CorrelationMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const correlationId = (req.headers["x-correlation-id"] as string) ?? randomUUID();
res.setHeader("x-correlation-id", correlationId);
correlationStorage.run({ correlationId }, () => next());
}
}
// core/logging/logger.service.ts โ every log call picks up the active correlation id
export function logWithContext(level: "info" | "warn" | "error", msg: string, meta: object = {}) {
const store = correlationStorage.getStore();
pinoLogger[level]({ ...meta, correlationId: store?.correlationId }, msg);
}Because AsyncLocalStorage context survives across await boundaries, the same correlation id set in the HTTP middleware is still present when a BullMQ job handler picks up work enqueued during that request โ as long as the id is explicitly attached to the job payload when it's queued, since the async context itself does not cross the Redis boundary into a separate worker process.
Wiring Sentry to the Same Correlation ID
Every exception captured by Sentry is tagged with the same correlation id, so a stack trace in Sentry and a structured log line in CloudWatch for the same failed request can be joined with one grep โ no more manually correlating timestamps across two different observability tools.
7. Fine-Grained Governance & Non-Repudiable Audit Logging
Enterprise compliance mandates strict visibility into who performed an operation, when it occurred, and what data payload changed.
Every sensitive request in Kuybi passes through an interceptor chain that automatically records non-repudiable audit trails:
{
"timestamp": "2026-07-23T14:15:22.891Z",
"userId": "usr_94821034",
"action": "STORY_UPDATE",
"entity": "Story",
"entityId": "str_104958",
"changes": { "status": "draft -> published" },
"clientIp": "198.51.100.14",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
}Fine-grained authorization is enforced at the controller level using CASL (`@casl/ability`), supporting role-based access (RBAC) and attribute-based access (ABAC) down to specific entity fields and user ownership rules.
8. Try Kuybi Open-Source Framework
Kuybi is fully open-source and ready for production deployment. It packages security, TypeORM database migrations, Terminus health checks, and Prometheus metrics into a modular Node.js/TypeScript boilerplate.
Explore the complete architecture and documentation on the [Kuybi Showcase Page](/open-source/kuybi) or star the repository on [GitHub](https://github.com/moayyadfaris/Kuybi).
Frequently Asked Questions
What is Kuybi?
Kuybi is an open-source NestJS 11 backend foundation that packages Domain-Driven Design module boundaries, process-segregated BullMQ workers, multi-tier Redis caching, CASL-based RBAC/ABAC authorization, and non-repudiable audit logging into a production-ready TypeScript boilerplate.
Why does Kuybi use process segregation instead of a single Node.js process?
Running CPU-intensive work (image processing, email dispatch, report generation) on the same event loop as incoming HTTP traffic causes latency spikes for API consumers. Kuybi splits the HTTP gateway, the BullMQ worker, and the queue-monitoring dashboard into three separate entry points so heavy background work never blocks request/response handling.
How does Kuybi's refresh token reuse detection work?
Every refresh token belongs to a token family rooted at the original login. Rotating a token keeps the family ID but issues a new token value; if a token that was already rotated out is presented again, that's a signal of theft, and Kuybi revokes the entire token family rather than just rejecting the one request.
What testing strategy does Kuybi use?
A three-tier pyramid: fast in-process unit tests with mocked repositories, integration tests against real PostgreSQL and Redis instances spun up per test file via Testcontainers, and end-to-end tests that boot the full Nest application and drive it over HTTP with supertest.