Skip to main content

Source of truth: ARCHITECTURE.md — do not edit this generated copy.

CRM Backend Architecture

Style: Modular Monolith + Event-Driven + Pragmatic Clean + CQRS-light + Proxy

Principle: pragmatic, not dogmatic. Don't add domain/ use-cases/ infrastructure/ ceremony per module. Split a module into sub-folders only when it earns it (workflow, smart-fields). Simple modules keep the layout in §2.


1. System Layout

src/
├── api/v1/ # Feature modules (bounded contexts)
├── infrastructure/ # DB, Redis, Queue, WebSocket, Storage, Mailer
├── shared/ # Contracts, domain primitives, integration-event types
├── common/ # Guards, filters, interceptors, decorators, utils

Dependency direction (enforced in CI):

api/v1/ → infrastructure/ → shared/
api/v1/ → shared/
infrastructure/ → shared/
shared/ → (nothing — pure TS, no NestJS, no TypeORM)

No cycles. No api/v1/X imports api/v1/Y directly (see §7).


2. Module Layout (every feature)

<feature>/
├── <feature>.module.ts
├── public/ # PUBLIC API: only what other modules may see
│ ├── <feature>.public-service.interface.ts
│ └── <feature>.public-service.ts
├── adapters/ # how THIS module talks to OTHER modules (sync)
├── controllers/ # HTTP — thin
├── gateways/ # WebSocket — thin
├── services/ # business logic
├── proxies/ # cache / rate-limit / pre-checks
├── repositories/ # DB writes + simple reads
├── queries/ # complex reads (CQRS-light)
├── dto/
├── entities/
├── events/ # event payload types + listeners/ subfolder (@OnEvent handlers)
└── __tests__/

Private by default. If it is not in public/, other modules cannot import it.

Optional folders (only for complex modules like workflow, smart-fields): domain/ (value objects, pure TS), use-cases/ (one class per operation). Don't add them to simple CRUD modules — over-engineering.


3. Nested Modules (module inside a module)

Large bounded contexts (like object/) contain sub-features. Rule:

Sub-feature relationshipAction
Tightly coupled, same product area, high traffic between themNest under parent
Distinct domain, could live on its ownPromote to sibling at api/v1/

Example — object/:

  • fields/, records/, relations/, views/, comments/, tasks/, analytics/, record-move/, export-import/nested (one product area)
  • smart-fields/ (calculation, serial-number, smart-catalog, invoice) → promoted to sibling (separate domain)

Nested layout

object/
├── object.module.ts # parent — imports children, exposes one public face
├── public/ # ← THE ONLY door to the outside world
│ └── object.public-service.ts # aggregates children's public services
├── adapters/ # how object/ talks to admin/, user/, audit/
├── _shared/ # types shared between children, NOT outside

├── fields/ # ← child module — full §2 layout
│ ├── fields.module.ts
│ ├── public/ # visible to siblings inside object/ only
│ ├── services/
│ ├── repositories/
│ ├── queries/
│ ├── dto/
│ └── entities/

├── records/
│ ├── records.module.ts
│ ├── public/
│ ├── adapters/ # wraps fields/public if records needs fields
│ ├── services/
│ └── repositories/

├── relations/ views/ comments/ tasks/

Rules for nested modules

RuleInside object/ (sibling to sibling)object/ ↔ outside
Import sibling's public/✅ allowed directly❌ forbidden
Import sibling's internals❌ forbidden❌ forbidden
Adapter required?optional — direct public injection is fine✅ required
Eventsdomain events local; integration events go through parent's public/events/integration events only
Shared types_shared/ at parent levelsrc/shared/

Guarantee: from outside, object/ is one module. Workflow/, messaging/, audit/ only know ObjectPublicService — never FieldsModule, RecordsModule, etc.

Concrete example — records needs fields

// object/fields/public/fields.public-service.ts
@Injectable()
export class FieldsPublicService {
getFieldsForObject(objectSlug: string) { /* ... */ }
}

// object/fields/fields.module.ts
@Module({ providers: [FieldsPublicService], exports: [FieldsPublicService] })
export class FieldsModule {}

// object/records/records.module.ts — sibling import allowed
@Module({
imports: [FieldsModule],
providers: [RecordsService, RecordsRepository],
})
export class RecordsModule {}

// object/records/services/records.service.ts — direct injection, no adapter needed
constructor(
private readonly recordsRepo: RecordsRepository,
private readonly fields: FieldsPublicService, // ← sibling public, allowed
) {}

Parent facade — aggregates children for outsiders

// object/public/object.public-service.ts
@Injectable()
export class ObjectPublicService {
constructor(
private readonly records: RecordsPublicService,
private readonly fields: FieldsPublicService,
private readonly views: ViewsPublicService,
) {}

getRecordWithFields(objectSlug: string, recordSlug: string) {
return this.records.findWithFieldValues(objectSlug, recordSlug);
}
}

// object/object.module.ts
@Module({
imports: [FieldsModule, RecordsModule, RelationsModule, ViewsModule, CommentsModule, TasksModule],
providers: [ObjectPublicService],
exports: [ObjectPublicService], // ← ONLY this goes outside
})
export class ObjectModule {}

4. Core Rules

#Rule
1No DB outside repositories/ or queries/. No raw SQL in services.
2Controllers/gateways are thin — validate, call service, return DTO.
3Services = business logic only. No cache. No DB. No event routing.
4No cross-module service imports. Events by default; Adapter for sync (§7).
5DTO in, DTO out. Always plainToInstance(ResponseDto, entity).
6One service/file > ~300 lines → split.
7Cache keys include every input that changes output: v1:<entity>:<op>:tenant=<db>:ws=<id>:user=<id>:<filter-hash>.
8Integration events are append-only. Version the payload (V1, V2). Never break it.
9No SQL JOINs across modules. Fetch from your repo, enrich via the other module's public service.
10Every repository/query respects tenant + workspace context (§11).
11Outside modules import only the parent's public/ — never a nested child's public/ directly (§3).

5. Layers

Repository — DB writes + simple reads

@Injectable()
export class UserRepository {
constructor(@InjectRepository(User) private repo: Repository<User>) {}
findById(id: string) { return this.repo.findOne({ where: { id } }); }
create(data: Partial<User>) { return this.repo.save(data); }
}

Query — complex reads

@Injectable()
export class UserQueryService {
findUsersWithPosts() {
return this.repo.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'posts')
.getMany();
}
}

Proxy — performance layer

@Injectable()
export class UserServiceProxy {
constructor(private service: UserService, private cache: CacheService) {}
async findById(id: string) {
const key = `v1:user:byId:tenant=${ctx.db}:id=${id}`;
const cached = await this.cache.get(key);
if (cached) return cached;
const user = await this.service.findById(id);
await this.cache.setWithTags(key, user, [`user:${id}`, `tenant:${ctx.db}`]);
return user;
}
}

Observer — reacts to events

@OnEvent('user.updated')
async invalidateCache(e) { await this.cache.invalidateTag(`user:${e.userId}`); }

Cache invalidation, analytics, workflows, notifications, audit. No business logic.


6. Events vs Services

Use ServiceUse Event
need immediate resultside effect
required for the flowasync / decoupling
TypeToolScope
Domain eventEventEmitter2in-process, same transaction
Integration eventRedis pub/sub + Bullcross-instance, durable, cross-module

Integration event payload must carry tenant context:

export class MessageReceivedEventV1 {
constructor(
public readonly tenantDatabase: string | null, // null = main/shared
public readonly workspaceId: number | null, // null = dedicated tenant
public readonly chatSlug: string,
public readonly text: string,
public readonly occurredAt: string,
) {}
}

Event chains banned: A → B → C → D.


7. Cross-Module Communication

Three allowed channels, in order:

  1. Integration event (default). Emit; listeners in other modules react.
  2. Adapter (sync needed). Consumer module defines what it needs, wraps the other module's public service. Only file allowed to import across module boundaries.
  3. Public service via public/ (last resort). Only classes exported from <feature>/public/index.ts.

Banned: direct import of a repository, query, or internal service from another module.

Adapter pattern — full example (realtime needs admin permissions):

// admin/public/admin.public-service.ts
@Injectable()
export class AdminPublicService {
constructor(private readonly perms: PermissionCheckerService) {}
hasPermission(adminId: number, perm: string) { return this.perms.check(adminId, perm); }
}

// admin/admin.module.ts
@Module({ providers: [AdminPublicService], exports: [AdminPublicService] })
export class AdminModule {}

// messaging/realtime/adapters/permission-checker.interface.ts
export interface PermissionChecker { can(adminId: number, perm: string): Promise<boolean>; }
export const PERMISSION_CHECKER = Symbol('PERMISSION_CHECKER');

// messaging/realtime/adapters/admin-permission.adapter.ts
@Injectable()
export class AdminPermissionAdapter implements PermissionChecker {
constructor(private readonly admin: AdminPublicService) {} // ONLY file touching AdminModule
can(adminId: number, perm: string) { return this.admin.hasPermission(adminId, perm); }
}

// messaging/realtime/realtime.module.ts
@Module({
imports: [AdminModule],
providers: [{ provide: PERMISSION_CHECKER, useClass: AdminPermissionAdapter }, ConnectionService],
})
export class RealtimeModule {}

// messaging/realtime/services/connection.service.ts — depends on LOCAL interface
constructor(@Inject(PERMISSION_CHECKER) private readonly perms: PermissionChecker) {}

8. Caching Rules

  1. Don't cache-all-GETs. Cache intentionally, per-route.
  2. Key convention (Rule 7): v1:<entity>:<op>:tenant=<db>:ws=<id>:user=<id>:<filter-hash>.
  3. Tag-based invalidation. Redis sets map entities → keys. On write, drop the tag. TTL is safety, not strategy.
  4. When NOT to cache: if an index or materialized view solves it faster.
  5. Proxy vs CacheInterceptor:
    • CacheInterceptor — simple GET, TTL only, no tenant variance.
    • Proxy class — multi-tenant, complex keys, tag invalidation, pre-checks.

9. Request Flows

READ

Controller → Proxy (cache) → HIT → return
→ MISS → Service → Repository/Query → setWithTags

WRITE

Controller → Service → Repository → emit event → Observers (invalidateTag, logs, workflows)

10. Infrastructure Layer

All IO in src/infrastructure/:

infrastructure/
├── database/ # datasource factory, WorkspaceScopedRepository, subscribers
├── redis/ # pub/sub, CacheService (tag-based), typed channels
├── queue/ # Bull config, BaseQueueProcessor (workspace-aware)
├── websocket/ # IoAdapter (handshake auth), base gateway, RedisPropagator
├── storage/ # S3 / local adapters
└── mailer/ # templated email adapter

Features depend on shared/contracts/ interfaces; infrastructure/ binds implementations.


11. Multi-Tenant & Hyper-Tenant Fit

Two isolation models:

ModelIsolationWhere
Tenantdedicated PostgreSQL database per tenantmain CRM customers
Hyper-tenantshared database, workspace_id column per rowfree-tier, subdomain app

How each layer behaves

LayerRequest-scoped (controller/service)Bull processor / backgroundCross-module
RepositoryContextAwareRepositoryProvider.getRepository() auto-injects workspace_id from AsyncLocalStoragedataSource.getRepository() — manually add .andWhere('x.workspace_id = :ws', { ws }) from Bull job payloadunaffected
Querysame — autosame — manual with workspaceId argunaffected
Cache keyMust include tenant=${db}:ws=${wsId} (Rule 7)samesame
Integration eventMust carry tenantDatabase + workspaceIdsameconsumer reads payload, calls AsyncLocalStorage.run({ tenant, ws }, handler)
Adapterworks unchanged — calls public service inside same ALS framejob handler re-enters ALS with payload context before calling adapterunchanged
WebSocketAuthenticatedSocketAdapter extracts tenant from subdomain at handshake, stores on socket.data, opens ALS frame per emitN/Aunchanged

Rules

  1. Dedicated tenant: workspaceId is undefined. Manual .andWhere('x.workspace_id = :ws') becomes no-op (if (workspaceId) { ... }).
  2. Hyper-tenant: workspaceId always set. Every read/write filters by it. Every cache key includes it. Every event carries it.
  3. Bull jobs: payload always { tenantDatabase, workspaceId, ... }. Processor wraps handler in AsyncLocalStorage.run().
  4. Integration event listener: dispatcher sets ALS before firing @OnEvent — wrap once, handlers stay clean.

Fit verdict

  • Repository/Query: ✅ solved by WorkspaceScopedRepository + manual-filter rule.
  • Cache: ✅ §8 key convention prevents collisions.
  • Events: ⚠️ today some events don't carry workspaceId — payload upgrade needed.
  • Adapters: ✅ neutral — ALS flows through.
  • No cross-module JOINs (Rule 9): ✅ extra important under hyper-tenant.

12. Microservice-Ready

Adapters are the extraction seam. When a module gets its own process, only the adapter changes:

// Before — in-process
@Injectable()
export class AdminPermissionAdapter implements PermissionChecker {
constructor(private readonly admin: AdminPublicService) {}
can(id, perm) { return this.admin.hasPermission(id, perm); }
}

// After — HTTP/gRPC, same interface
@Injectable()
export class AdminPermissionHttpAdapter implements PermissionChecker {
constructor(private readonly http: HttpService) {}
async can(id, perm) {
const { data } = await this.http.post('/perm/check', { id, perm }).toPromise();
return data.allowed;
}
}

// realtime.module.ts — one-line swap
providers: [{
provide: PERMISSION_CHECKER,
useClass: process.env.ADMIN_SERVICE_URL ? AdminPermissionHttpAdapter : AdminPermissionAdapter,
}]

No service/use-case code changes. Event transport: flip Redis → RabbitMQ/Kafka — integration events cross unchanged because they're versioned (Rule 8) and carry tenant context (§11).


13. Upgrade Path

  • Today: plain <Feature>Service + <Feature>Repository + <Feature>QueryService + EventEmitter2.
  • When a module earns it (workflow, smart-fields — multi-step orchestration or sagas): promote to @nestjs/cqrsCommandBus + QueryBus + EventBus + sagas.
  • Own deployment: see §12.

14. Anti-Patterns

  • God services
  • Raw SQL in services
  • Cross-module service imports (bypass adapter / events)
  • SQL JOIN across modules (Rule 9)
  • Outside module imports a nested child's public/ (must go through parent, Rule 11)
  • Business logic in controllers / proxies / observers
  • Cache mixed into service logic
  • N+1 queries
  • Event chains (A → B → C → D)
  • forwardRef — smell: merge the modules or mediate via event
  • Breaking integration event payloads — version (V1V2) instead
  • Forgetting workspace_id in a Bull processor query (hyper-tenant data leak)
  • Cache key missing tenant / ws (hyper-tenant key collision)

15. Testing

LayerTest typeMocks
Repository / Queryintegrationreal DB (test tenant)
Serviceunitmocked repository, mocked adapters
Proxyunitmocked service, mocked cache
Adapterunitmocked public service of the other module
Controller / Gatewaye2espin up Nest app, real Redis
Listenerunitemit event, assert side effect

Co-locate in __tests__/. CLAUDE.md target: >80% coverage.


16. Realtime Example (the pilot)

src/api/v1/messaging/realtime/:

realtime/
├── realtime.module.ts
├── public/ # BroadcastPublicService only
├── adapters/ # AdminPermissionAdapter, ObjectDataAdapter
├── gateways/messaging-events.gateway.ts # ~80 lines
├── services/
│ ├── connection.service.ts # rooms, auto-subscribe, disconnect
│ ├── broadcast.service.ts # fan-out + auth filter (via adapter)
│ ├── presence.service.ts # typing/seen/online/offline
│ ├── redis-propagator.service.ts # Redis → Socket.IO
│ └── socket-context.service.ts # tenant cache (DI, not global Map)
├── interfaces/
└── __tests__/

Plus infrastructure/websocket/authenticated-socket.adapter.ts — JWT + tenant at Socket.IO handshake (kills isReady race + TENANT_CACHE Map).


17. Per-Module Audit

ModuleAction
messaging/realtime/Apply §16. Priority 1.
messaging/Reference module. Template.
object/Adopt nested-module layout (§3). Parent public/ObjectPublicService aggregates children.
workflow/Regroup 16 flat folders → application/, engine/, infrastructure/. Complex enough for use-cases/.
object/smart-fields/Promote to sibling module api/v1/smart-fields/ (distinct domain).
admin/, user/Apply canonical layout (§2). Simple — no domain/.
tenant/TenantDatabaseServiceinfrastructure/database/.
common/{firebase,file-handler,pdf-generator,whatsapp-otp}Move → infrastructure/.
audit/, webhook/, lead-distribution/Keep. Publish event types via shared/events/.

18. Adoption Order

  1. This file is the standard. Append to CLAUDE.md.
  2. Scaffold empty src/shared/ + src/infrastructure/ + contracts only.
  3. Pilot 1: refactor messaging/realtime/ (§16).
  4. Pilot 2: extract Smart Field Engine → sibling module — exercises public/ + adapters + integration events.
  5. Pilot 3: reshape object/ to nested-module layout (§3).
  6. Leaf modules: admin/, user/, tenant/ — layout-only PRs.
  7. Move IO adapters into infrastructure/.
  8. Regroup workflow/.
  9. Add Dependency Cruiser in CI. Merge-block on violations.

One reversible PR each. No big-bang.

Dependency Cruiser — full config

// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'no-cross-module-imports',
comment: 'Only adapters/ and *.module.ts may import from another api/v1 module',
severity: 'error',
from: {
path: '^src/api/v1/([^/]+)/',
pathNot: [
'^src/api/v1/[^/]+/adapters/',
'^src/api/v1/[^/]+/[^/]+\\.module\\.(ts|js)$',
],
},
to: { path: '^src/api/v1/(?!$1)[^/]+/' },
},
{
name: 'adapters-only-touch-public',
comment: 'Adapters may only import the other module\'s public/ folder',
severity: 'error',
from: { path: '^src/api/v1/[^/]+/adapters/' },
to: {
path: '^src/api/v1/(?!$1)[^/]+/',
pathNot: ['^src/api/v1/[^/]+/public/', '^src/api/v1/[^/]+/[^/]+\\.module\\.(ts|js)$'],
},
},
{
name: 'outside-only-touches-parent-public',
comment: 'Outside modules must import the parent\'s public/, not a nested child\'s',
severity: 'error',
from: { path: '^src/api/v1/(?!object)[^/]+/' },
to: { path: '^src/api/v1/object/[^/]+/(?!public)' },
},
{
name: 'nested-siblings-only-touch-public',
comment: 'Inside object/: fields/ records/ etc. only import each other\'s public/',
severity: 'error',
from: { path: '^src/api/v1/object/([^/]+)/' },
to: {
path: '^src/api/v1/object/(?!$1)[^/]+/',
pathNot: [
'^src/api/v1/object/[^/]+/public/',
'^src/api/v1/object/[^/]+/[^/]+\\.module\\.(ts|js)$',
],
},
},
{
name: 'shared-stays-pure',
comment: 'shared/ cannot import NestJS, TypeORM, or anything from api/v1 / infrastructure',
severity: 'error',
from: { path: '^src/shared/' },
to: { path: '^(src/api/v1|src/infrastructure|@nestjs|typeorm)' },
},
],
};

Run: npx depcruise --validate .dependency-cruiser.cjs src.


19. Mental Model

LayerRole
Controller / Gatewayentry
Proxyperformance
Servicelogic
Repository / Querydata (tenant + workspace aware)
Eventnotification
Observerreaction
Adaptersync cross-module contract (only file allowed across boundaries)
Parent module facadeaggregates nested children for outsiders
InfrastructureIO

Modules don't talk directly. They communicate through events (default) or an Adapter (when sync is required). Nested modules share a parent public/. Services contain logic. Everything else is infrastructure or optimization.