ct-engagement.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn } from 'typeorm';
import { ClientAccount } from './client-account.entity';
@Entity('project_engagements')
export class ProjectEngagement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'enum', enum: ['discovery', 'proposal', 'negotiation', 'closed'], default: 'discovery' })
stage: string;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
estimatedValue: number | null;
@ManyToOne(() => ClientAccount, (account) => account.engagements, { onDelete: 'CASCADE' })
client: ClientAccount;
@CreateDateColumn()
createdAt: Date;
}
**Rationale:** TypeORM's decorator-based mapping keeps schema definitions colocated with business logic. The `onDelete: 'CASCADE'` strategy prevents orphaned engagement records when a client account is archived. Using `uuid` primary keys avoids sequential ID enumeration attacks and aligns with distributed system patterns.
### Step 2: Expose via GraphQL Yoga
Twenty's backend relies on GraphQL Yoga for schema stitching and resolver execution. We define a type-safe schema that supports filtering, pagination, and real-time subscriptions.
```typescript
// src/graphql/schema/project-engagement.schema.ts
import { ObjectType, Field, ID, Float, InputType, Query, Resolver } from '@nestjs/graphql';
import { ProjectEngagementService } from '../../services/project-engagement.service';
@ObjectType()
export class EngagementPayload {
@Field(() => ID)
id: string;
@Field()
title: string;
@Field()
stage: string;
@Field(() => Float, { nullable: true })
estimatedValue: number | null;
}
@InputType()
export class EngagementFilterInput {
@Field(() => String, { nullable: true })
stage?: string;
@Field(() => Int, { nullable: true })
limit?: number;
@Field(() => Int, { nullable: true })
offset?: number;
}
@Resolver()
export class EngagementResolver {
constructor(private readonly engagementService: ProjectEngagementService) {}
@Query(() => [EngagementPayload])
async listEngagements(@Args('filter') filter: EngagementFilterInput) {
return this.engagementService.findFiltered(filter);
}
}
Rationale: GraphQL Yoga handles schema validation, type coercion, and resolver chaining automatically. By separating ObjectType and InputType, we enforce strict boundaries between read and write operations. The resolver delegates to a service layer, keeping GraphQL concerns isolated from database queries.
Step 3: Implement Caching with Redis
Frequently accessed engagement lists benefit from Redis caching. We implement a cache-aside pattern with TTL-based invalidation.
// src/services/project-engagement.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RedisService } from '@liaoliaots/nestjs-redis';
import { ProjectEngagement } from '../entities/project-engagement.entity';
import { EngagementFilterInput } from '../graphql/schema/project-engagement.schema';
@Injectable()
export class ProjectEngagementService {
private readonly cacheKey = 'engagements:filtered';
constructor(
@InjectRepository(ProjectEngagement)
private readonly repo: Repository<ProjectEngagement>,
@Inject(RedisService) private readonly redis: RedisService
) {}
async findFiltered(filter: EngagementFilterInput) {
const cacheKey = `${this.cacheKey}:${JSON.stringify(filter)}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const queryBuilder = this.repo.createQueryBuilder('engagement');
if (filter.stage) queryBuilder.andWhere('engagement.stage = :stage', { stage: filter.stage });
const results = await queryBuilder
.skip(filter.offset || 0)
.take(filter.limit || 20)
.getMany();
await this.redis.set(cacheKey, JSON.stringify(results), 'EX', 300);
return results;
}
}
Rationale: The cache-aside pattern prevents database overload during high-traffic dashboard loads. TTL expiration (300 seconds) balances freshness with performance. Using JSON.stringify for cache keys ensures deterministic hashing for filter combinations. Redis runs as a sidecar container, keeping latency sub-millisecond.
Step 4: Frontend State Management with Jotai
The React frontend uses Jotai for atomic state management. We define a store for engagement filters and sync it with GraphQL queries.
// src/stores/engagement.store.ts
import { atom } from 'jotai';
import { EngagementFilterInput } from '../graphql/schema/project-engagement.schema';
export const engagementFilterAtom = atom<EngagementFilterInput>({
stage: undefined,
limit: 20,
offset: 0,
});
export const engagementListAtom = atom(async (get) => {
const filter = get(engagementFilterAtom);
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `query ListEngagements($filter: EngagementFilterInput!) { listEngagements(filter: $filter) { id title stage estimatedValue } }`,
variables: { filter },
}),
});
const data = await response.json();
return data.data?.listEngagements ?? [];
});
Rationale: Jotai's atomic model eliminates prop drilling and unnecessary re-renders. The derived atom automatically refetches when filter values change. Using a single /graphql endpoint keeps network requests predictable and simplifies error boundary implementation.
Architecture Decisions Summary
| Layer | Technology | Why |
|---|
| Monorepo | Nx | Shared TypeScript configs, deterministic builds, isolated dependency graphs |
| Backend | NestJS + TypeORM | Dependency injection, modular architecture, relational mapping without SQL boilerplate |
| API | GraphQL Yoga | Schema stitching, automatic type generation, subscription support |
| Cache | Redis | Sub-millisecond reads, TTL invalidation, distributed session storage |
| Frontend | React 18 + Vite + Jotai | Fast HMR, atomic state, tree-shakeable bundles |
| Database | PostgreSQL | JSONB support, row-level security, mature extension ecosystem |
Pitfall Guide
Explanation: Fetching unbounded result sets triggers memory exhaustion on the backend and UI rendering lag on the frontend. Legacy REST APIs often return full collections, but GraphQL resolvers must enforce limits.
Fix: Always require limit and offset (or cursor-based pagination) in filter inputs. Set server-side defaults (e.g., limit: 20) and reject requests exceeding limit: 100.
2. Misconfiguring TypeORM Cascade Deletes
Explanation: Omitting onDelete: 'CASCADE' or using SET NULL incorrectly leaves orphaned records that violate referential integrity. This corrupts analytics pipelines and breaks relationship queries.
Fix: Explicitly define cascade behavior per relationship. Use CASCADE for child entities that lose meaning without a parent (e.g., engagements → accounts). Use RESTRICT for critical records that require manual archival.
3. Over-Reliance on AI Auto-Fill Without Validation
Explanation: AI-assisted data entry accelerates input but introduces hallucination risks. Unvalidated AI outputs pollute CRM records, causing downstream automation failures.
Fix: Implement a two-step validation pipeline: AI suggests → human approves → system commits. Store raw AI output in a separate ai_suggestions table for audit trails. Never bypass required field constraints.
4. Monorepo Build Cache Corruption
Explanation: Nx's distributed caching accelerates CI/CD but fails when environment variables, Docker layers, or TypeScript configs drift across machines. Stale caches produce runtime mismatches.
Fix: Invalidate caches on package.json or tsconfig.json changes. Use nx reset in CI pipelines when switching branches. Pin Docker base images and avoid latest tags for reproducible builds.
5. Docker Volume Permission Mismatches
Explanation: PostgreSQL and Redis containers run as non-root users. Mounting host directories without correct UID/GID mapping causes EACCES errors and silent data loss.
Fix: Use named volumes instead of bind mounts for production. If bind mounts are required, set user: "999:999" (PostgreSQL) and user: "999:999" (Redis) in docker-compose.yml. Verify permissions with docker exec <container> ls -la /var/lib/postgresql/data.
6. Skipping Webhook Signature Verification
Explanation: External integrations send payloads to CRM endpoints. Without signature verification, attackers can inject fake events, trigger duplicate workflows, or exfiltrate data.
Fix: Implement HMAC-SHA256 verification on all webhook routes. Compare X-Signature headers against a shared secret. Reject requests with mismatched signatures and log attempts for security auditing.
7. Hardcoding Environment Secrets in Nx Configs
Explanation: Storing database credentials or API keys in environment.ts or nx.json exposes them in version control. CI/CD pipelines may also leak secrets in build logs.
Fix: Use .env.local for development and inject secrets via CI/CD variables in production. Load environment files at runtime using dotenv or NestJS ConfigModule. Never commit .env files to Git.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-team SMB deployment | Docker Compose with local PostgreSQL/Redis | Minimal infrastructure overhead, fast iteration | Low ($0–$50/mo) |
| Multi-region enterprise | Kubernetes + managed PostgreSQL + Redis Cluster | High availability, horizontal scaling, geographic redundancy | Medium ($200–$800/mo) |
| High-compliance environment | Self-hosted with air-gapped network + encrypted volumes | Data sovereignty, audit compliance, zero external dependencies | High (infrastructure + ops) |
| Rapid prototyping | Local Nx workspace + SQLite fallback | Zero database setup, instant schema iteration | Low (developer time only) |
Configuration Template
# docker-compose.prod.yml
version: '3.9'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: twenty_crm
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
twenty-backend:
build:
context: .
dockerfile: apps/backend/Dockerfile
environment:
DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@postgres:5432/twenty_crm
REDIS_URL: redis://redis:6379
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "3000:3000"
restart: unless-stopped
volumes:
pg_data:
redis_data:
Quick Start Guide
-
Clone the repository and install dependencies:
git clone https://github.com/twentyhq/twenty.git
cd twenty
pnpm install
-
Initialize environment variables:
cp .env.example .env.local
# Edit .env.local with your database and Redis credentials
-
Launch infrastructure services:
docker compose -f docker-compose.dev.yml up -d postgres redis
-
Run database migrations and start the backend:
pnpm nx run backend:database:reset
pnpm nx run backend:serve
-
Start the frontend development server:
pnpm nx run frontend:serve
Access the application at http://localhost:3000. Verify GraphQL playground availability at http://localhost:3000/graphql.