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 relationship | Action |
|---|---|
| Tightly coupled, same product area, high traffic between them | Nest under parent |
| Distinct domain, could live on its own | Promote 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
| Rule | Inside 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 |
| Events | domain events local; integration events go through parent's public/events/ | integration events only |
| Shared types | _shared/ at parent level | src/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 |
|---|---|
| 1 | No DB outside repositories/ or queries/. No raw SQL in services. |
| 2 | Controllers/gateways are thin — validate, call service, return DTO. |
| 3 | Services = business logic only. No cache. No DB. No event routing. |
| 4 | No cross-module service imports. Events by default; Adapter for sync (§7). |
| 5 | DTO in, DTO out. Always plainToInstance(ResponseDto, entity). |
| 6 | One service/file > ~300 lines → split. |
| 7 | Cache keys include every input that changes output: v1:<entity>:<op>:tenant=<db>:ws=<id>:user=<id>:<filter-hash>. |
| 8 | Integration events are append-only. Version the payload (V1, V2). Never break it. |
| 9 | No SQL JOINs across modules. Fetch from your repo, enrich via the other module's public service. |
| 10 | Every repository/query respects tenant + workspace context (§11). |
| 11 | Outside 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 Service | Use Event |
|---|---|
| need immediate result | side effect |
| required for the flow | async / decoupling |
| Type | Tool | Scope |
|---|---|---|
| Domain event | EventEmitter2 | in-process, same transaction |
| Integration event | Redis pub/sub + Bull | cross-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:
- Integration event (default). Emit; listeners in other modules react.
- Adapter (sync needed). Consumer module defines what it needs, wraps the other module's public service. Only file allowed to import across module boundaries.
- 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
- Don't cache-all-GETs. Cache intentionally, per-route.
- Key convention (Rule 7):
v1:<entity>:<op>:tenant=<db>:ws=<id>:user=<id>:<filter-hash>. - Tag-based invalidation. Redis sets map entities → keys. On write, drop the tag. TTL is safety, not strategy.
- When NOT to cache: if an index or materialized view solves it faster.
- 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:
| Model | Isolation | Where |
|---|---|---|
| Tenant | dedicated PostgreSQL database per tenant | main CRM customers |
| Hyper-tenant | shared database, workspace_id column per row | free-tier, subdomain app |
How each layer behaves
| Layer | Request-scoped (controller/service) | Bull processor / background | Cross-module |
|---|---|---|---|
| Repository | ContextAwareRepositoryProvider.getRepository() auto-injects workspace_id from AsyncLocalStorage | dataSource.getRepository() — manually add .andWhere('x.workspace_id = :ws', { ws }) from Bull job payload | unaffected |
| Query | same — auto | same — manual with workspaceId arg | unaffected |
| Cache key | Must include tenant=${db}:ws=${wsId} (Rule 7) | same | same |
| Integration event | Must carry tenantDatabase + workspaceId | same | consumer reads payload, calls AsyncLocalStorage.run({ tenant, ws }, handler) |
| Adapter | works unchanged — calls public service inside same ALS frame | job handler re-enters ALS with payload context before calling adapter | unchanged |
| WebSocket | AuthenticatedSocketAdapter extracts tenant from subdomain at handshake, stores on socket.data, opens ALS frame per emit | N/A | unchanged |
Rules
- Dedicated tenant:
workspaceIdisundefined. Manual.andWhere('x.workspace_id = :ws')becomes no-op (if (workspaceId) { ... }). - Hyper-tenant:
workspaceIdalways set. Every read/write filters by it. Every cache key includes it. Every event carries it. - Bull jobs: payload always
{ tenantDatabase, workspaceId, ... }. Processor wraps handler inAsyncLocalStorage.run(). - 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/cqrs—CommandBus+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 (
V1→V2) instead - Forgetting
workspace_idin a Bull processor query (hyper-tenant data leak) - Cache key missing
tenant/ws(hyper-tenant key collision)
15. Testing
| Layer | Test type | Mocks |
|---|---|---|
| Repository / Query | integration | real DB (test tenant) |
| Service | unit | mocked repository, mocked adapters |
| Proxy | unit | mocked service, mocked cache |
| Adapter | unit | mocked public service of the other module |
| Controller / Gateway | e2e | spin up Nest app, real Redis |
| Listener | unit | emit 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
| Module | Action |
|---|---|
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/ | TenantDatabaseService → infrastructure/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
- This file is the standard. Append to
CLAUDE.md. - Scaffold empty
src/shared/+src/infrastructure/+ contracts only. - Pilot 1: refactor
messaging/realtime/(§16). - Pilot 2: extract Smart Field Engine → sibling module — exercises
public/+ adapters + integration events. - Pilot 3: reshape
object/to nested-module layout (§3). - Leaf modules:
admin/,user/,tenant/— layout-only PRs. - Move IO adapters into
infrastructure/. - Regroup
workflow/. - 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
| Layer | Role |
|---|---|
| Controller / Gateway | entry |
| Proxy | performance |
| Service | logic |
| Repository / Query | data (tenant + workspace aware) |
| Event | notification |
| Observer | reaction |
| Adapter | sync cross-module contract (only file allowed across boundaries) |
| Parent module facade | aggregates nested children for outsiders |
| Infrastructure | IO |
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.