system-design51 min read

How to Design a CRM System — A Senior+ Guide | Ayodhyya

How to Design a CRM System

Building a Production-Grade Customer Relationship Management Platform — Salesforce/HubSpot at Scale

Senior+ System Design Guide 10,000+ Words 30 Deep-Dive Sections C# · Mermaid · Real-World Case Studies

1. Introduction & Why CRM is Hard

A Customer Relationship Management system is the central nervous system of any sales, marketing, and customer success organization. Salesforce generates over $30 billion in annual revenue; HubSpot serves 194,000+ customers across 120+ countries. These platforms manage the entire lifecycle of a customer relationship — from the first anonymous website visit to a closed-won enterprise deal worth millions, and through years of ongoing support and expansion.

Building a CRM is deceptively difficult because it sits at the intersection of several hard problems simultaneously:

  • Massive data volume: A mid-size company might have millions of contacts, hundreds of thousands of deals, and billions of activity records over a decade.
  • Complex relationship graphs: Contacts belong to accounts, accounts have parent-child hierarchies, deals roll up to accounts, activities link to both, and custom objects can create arbitrary entity-relationship graphs.
  • Multi-tenancy: Salesforce serves 150,000+ companies on shared infrastructure. Each tenant has custom schemas, custom logic, and completely isolated data — yet they share compute and storage.
  • Real-time requirements: Email tracking pixels must fire within seconds. Dashboard refreshes must feel instant. Workflow automations must trigger within minutes, not hours.
  • Extensibility: Customers need custom objects, custom fields, custom workflows, custom UI, and custom APIs — without the platform team shipping any of it.
  • Compliance: GDPR right-to-erasure, CCPA opt-out, HIPAA for healthcare CRMs, SOC 2 Type II — all must be enforced at the data layer, not just the application layer.
Interview Tip: When asked to design a CRM, start by clarifying scope. Are you designing the core data model and CRUD operations, or the full platform with custom objects, workflows, and an app marketplace? The scope difference is 10x in complexity.

2. Functional & Non-Functional Requirements

Core Functional Requirements

ModuleKey Features
Contact ManagementCRUD contacts, merge duplicates, import/export, tagging, custom fields
Account/CompanyAccount hierarchies, parent-child, subsidiaries, account scoring
Deal PipelineKanban stages, probability-weighted forecasting, lost/won reasons, products on deals
ActivitiesLog calls, emails, meetings with timestamps, duration, outcomes
Email IntegrationGmail/Outlook 2-way sync, open/click tracking, email templates
Tasks & RemindersCreate tasks with due dates, auto-reminders, task lists by owner
Custom Fields/ObjectsUser-defined schema, field types, validation rules, relationships
Workflow AutomationIf-this-then-that rules, field updates, email sends, task creation, API calls
Lead ScoringRule-based and ML-based scoring, fit + engagement signals
Reporting & DashboardsCustom reports, scheduled delivery, dashboard widgets, drill-down
RBACRoles, profiles, permission sets, field-level security, record sharing
Support TicketsCase creation, SLA tracking, escalation, knowledge base
Marketing CampaignsCampaign creation, member tracking, A/B testing, ROI reporting

Non-Functional Requirements

RequirementTarget
Availability99.95% uptime (4.38 hours downtime/year)
Latency (p99)List views < 500ms, search < 200ms, API < 200ms
Throughput50,000 API requests/second at peak
Data Durability99.999999999% (11 nines) via cross-region replication
Tenant IsolationStrict data isolation — no cross-tenant data leakage ever
CustomizationEach tenant can define custom objects, fields, workflows, and UI
SearchFull-text search across all objects with sub-second response
Data RetentionMinimum 7 years for audit logs; configurable per tenant

3. High-Level Architecture Overview

The CRM platform follows a multi-service architecture with a shared data layer that supports multi-tenancy. The key insight is separating the metadata layer (tenant schemas, custom objects, workflow definitions) from the data layer (actual records). This separation allows the platform to run custom logic per tenant without per-tenant code deployments.

graph TB subgraph Clients["Client Layer"] WEB["React SPA
Web App"] MOB["React Native
Mobile App"] API_CLIENTS["3rd Party
API Clients"] end subgraph Gateway["API Gateway"] LB["Load Balancer
(NGINX / AWS ALB)"] AUTH["Auth Service
OAuth 2.0 / JWT"] RATELIMIT["Rate Limiter
Per-Tenant Throttling"] end subgraph CoreServices["Core Microservices"] CONTACT_SVC["Contact Service"] ACCOUNT_SVC["Account Service"] DEAL_SVC["Deal/Pipeline Service"] ACTIVITY_SVC["Activity Service"] EMAIL_SVC["Email Integration Service"] WORKFLOW_SVC["Workflow Engine"] SEARCH_SVC["Search Service
(Elasticsearch)"] REPORT_SVC["Reporting Service"] NOTIFICATION_SVC["Notification Service"] FILE_SVC["File/Document Service"] CUSTOM_SVC["Custom Object Service"] LEAD_SVC["Lead Scoring Service"] CAMPAIGN_SVC["Campaign Service"] SUPPORT_SVC["Support Ticket Service"] end subgraph AsyncLayer["Async Processing"] MQ["Message Queue
(Kafka / RabbitMQ)"] WF_SCHED["Workflow Scheduler
(Hangfire)"] EMAIL_WORKER["Email Sync Workers"] BATCH_WORKER["Batch Processing
Workers"] end subgraph DataLayer["Data Layer"] PG["PostgreSQL
(Primary Store)"] REDIS["Redis Cluster
(Cache + Sessions)"] ES["Elasticsearch
(Full-Text Search)"] S3["Object Storage
(S3 / Blob)"] DW["Data Warehouse
(BigQuery / Redshift)"] end WEB --> LB MOB --> LB API_CLIENTS --> LB LB --> AUTH AUTH --> RATELIMIT RATELIMIT --> CONTACT_SVC RATELIMIT --> ACCOUNT_SVC RATELIMIT --> DEAL_SVC RATELIMIT --> ACTIVITY_SVC RATELIMIT --> SEARCH_SVC RATELIMIT --> REPORT_SVC RATELIMIT --> CUSTOM_SVC CONTACT_SVC --> PG CONTACT_SVC --> REDIS DEAL_SVC --> PG DEAL_SVC --> MQ ACTIVITY_SVC --> PG ACTIVITY_SVC --> MQ EMAIL_SVC --> MQ WORKFLOW_SVC --> MQ WORKFLOW_SVC --> PG SEARCH_SVC --> ES REPORT_SVC --> DW FILE_SVC --> S3 MQ --> WF_SCHED MQ --> EMAIL_WORKER MQ --> BATCH_WORKER NOTIFICATION_SVC --> MQ

Service Responsibilities

  • API Gateway: Authentication, rate limiting, request routing, and tenant context injection. Every request carries a JWT with the tenant ID, user ID, and role.
  • Contact/Account/Deal Services: Core CRUD operations with business logic. Each owns its database tables but can read from others via internal APIs or shared read replicas.
  • Workflow Engine: Evaluates rules asynchronously. When a record changes, an event is published; the workflow engine checks if any rules match and enqueues actions.
  • Search Service: Denormalizes data from all services into Elasticsearch for cross-object full-text search. Updated via CDC (Change Data Capture) from PostgreSQL.
  • Reporting Service: Runs complex aggregations on the data warehouse, not the transactional database. Reports are pre-computed and cached.

4. Data Model & Schema Design

The data model is the backbone of a CRM. The core entities form a rich relationship graph. The design must support both standard objects (contacts, accounts, deals) and user-defined custom objects with their own fields and relationships.

erDiagram TENANT ||--o{ CONTACT : contains TENANT ||--o{ ACCOUNT : contains TENANT ||--o{ DEAL : contains TENANT ||--o{ ACTIVITY : contains TENANT ||--o{ CUSTOM_OBJECT_DEF : defines CONTACT }o--|| ACCOUNT : belongs_to CONTACT ||--o{ ACTIVITY : has CONTACT ||--o{ DEAL_CONTACT : "many-to-many" ACCOUNT ||--o{ DEAL : has ACCOUNT }o--o{ ACCOUNT : parent_child DEAL ||--o{ DEAL_STAGE_HISTORY : tracks DEAL ||--o{ DEAL_PRODUCT : contains DEAL_PRODUCT }o--|| PRODUCT : references PRODUCT }o--|| PRICEBOOK : belongs_to DEAL ||--o{ QUOTE : generates ACTIVITY }o--|| CONTACT : about ACTIVITY }o--|| DEAL : about

Core Tables

SQL
-- Tenant isolation is enforced via row-level security
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    plan VARCHAR(50) NOT NULL DEFAULT 'professional',
    settings JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    metadata_schema_version INT NOT NULL DEFAULT 1
);

CREATE TABLE contacts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    email VARCHAR(255),
    phone VARCHAR(50),
    mobile VARCHAR(50),
    title VARCHAR(150),
    account_id UUID REFERENCES accounts(id),
    owner_id UUID NOT NULL,
    lead_source VARCHAR(100),
    lifecycle_stage VARCHAR(50) DEFAULT 'lead',
    custom_fields JSONB NOT NULL DEFAULT '{}',
    tags TEXT[] NOT NULL DEFAULT '{}',
    is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    search_vector TSVECTOR
);

CREATE TABLE accounts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    name VARCHAR(255) NOT NULL,
    domain VARCHAR(255),
    industry VARCHAR(100),
    size VARCHAR(50),
    annual_revenue NUMERIC(15,2),
    billing_address JSONB,
    shipping_address JSONB,
    parent_account_id UUID REFERENCES accounts(id),
    owner_id UUID NOT NULL,
    account_type VARCHAR(50) DEFAULT 'prospect',
    custom_fields JSONB NOT NULL DEFAULT '{}',
    is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE deals (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    name VARCHAR(255) NOT NULL,
    account_id UUID NOT NULL REFERENCES accounts(id),
    contact_id UUID REFERENCES contacts(id),
    owner_id UUID NOT NULL,
    stage VARCHAR(100) NOT NULL DEFAULT 'prospecting',
    amount NUMERIC(15,2),
    close_date DATE,
    probability NUMERIC(5,2),
    forecast_category VARCHAR(50),
    type VARCHAR(50),
    lead_source VARCHAR(100),
    lost_reason TEXT,
    won_reason TEXT,
    custom_fields JSONB NOT NULL DEFAULT '{}',
    is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE activities (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    type VARCHAR(50) NOT NULL, -- call, email, meeting, note, task
    subject VARCHAR(500),
    description TEXT,
    contact_id UUID REFERENCES contacts(id),
    deal_id UUID REFERENCES deals(id),
    account_id UUID REFERENCES accounts(id),
    owner_id UUID NOT NULL,
    due_date TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    duration_minutes INT,
    outcome VARCHAR(100),
    metadata JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Multi-tenant index pattern: always include tenant_id as leading column
CREATE INDEX idx_contacts_tenant_owner ON contacts(tenant_id, owner_id);
CREATE INDEX idx_contacts_tenant_email ON contacts(tenant_id, email);
CREATE INDEX idx_contacts_tenant_search ON contacts USING GIN(search_vector);
CREATE INDEX idx_deals_tenant_stage ON deals(tenant_id, stage);
CREATE INDEX idx_deals_tenant_owner ON deals(tenant_id, owner_id);
CREATE INDEX idx_activities_tenant_contact ON activities(tenant_id, contact_id);
CREATE INDEX idx_activities_tenant_created ON activities(tenant_id, created_at DESC);

-- Row-level security: every query automatically filters by tenant
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_contacts ON contacts
    USING (tenant_id = current_setting('app.tenant_id')::UUID);

Custom Object Schema Storage

Custom objects and fields are stored as metadata, not as separate tables. This avoids DDL operations in production and allows tenants to extend the schema without platform deployments.

SQL
CREATE TABLE custom_object_definitions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    object_name VARCHAR(100) NOT NULL,
    display_name VARCHAR(150) NOT NULL,
    api_name VARCHAR(100) NOT NULL,
    description TEXT,
    is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(tenant_id, api_name)
);

CREATE TABLE custom_field_definitions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    object_api_name VARCHAR(100) NOT NULL,
    field_api_name VARCHAR(100) NOT NULL,
    display_name VARCHAR(150) NOT NULL,
    field_type VARCHAR(50) NOT NULL, -- text, number, date, picklist, lookup, formula
    is_required BOOLEAN NOT NULL DEFAULT FALSE,
    default_value TEXT,
    picklist_values JSONB,
    lookup_object VARCHAR(100),
    validation_rule TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(tenant_id, object_api_name, field_api_name)
);

-- All custom object records stored in a single EAV-style table
CREATE TABLE custom_object_records (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    object_api_name VARCHAR(100) NOT NULL,
    record_id UUID NOT NULL,
    field_values JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_cor_tenant_object ON custom_object_records(tenant_id, object_api_name);
CREATE INDEX idx_cor_field_values ON custom_object_records USING GIN(tenant_id, object_api_name, field_values);
Design Decision: The JSONB approach for custom fields trades query flexibility for schema simplicity. For fields that need heavy querying, the system can create materialized views or generated columns. This mirrors Salesforce's Entity-Attribute-Value (EAV) model but uses PostgreSQL's JSONB for better performance.

5. Contact Management

Contacts are the fundamental entity in any CRM — they represent the people your business interacts with. The contact management module must handle millions of records per tenant while supporting complex lookups, deduplication, and enrichment.

Contact Lifecycle

stateDiagram-v2 [*] --> Anonymous_Visitor: Website Visit Anonymous_Visitor --> Lead: Form Fill / Download Lead --> MQL: Score Threshold Met MQL --> SQL: Sales Accepted SQL --> Opportunity: Deal Created Opportunity --> Customer: Closed Won Opportunity --> Lost_Lead: Closed Lost Lead --> Unsubscribed: Opt-Out Customer --> Churned: No Activity 90d Lost_Lead --> Lead: Re-Engaged

Contact Enrichment Pipeline

When a new contact is created, the system can automatically enrich it with data from external providers. This runs asynchronously to avoid slowing down the create operation.

C#
public class ContactEnrichmentService
{
    private readonly IEnrichmentProvider _enrichmentProvider;
    private readonly IEventBus _eventBus;
    private readonly ILogger<ContactEnrichmentService> _logger;

    public ContactEnrichmentService(
        IEnrichmentProvider enrichmentProvider,
        IEventBus eventBus,
        ILogger<ContactEnrichmentService> logger)
    {
        _enrichmentProvider = enrichmentProvider;
        _eventBus = eventBus;
        _logger = logger;
    }

    public async Task EnrichContactAsync(Contact contact)
    {
        try
        {
            var enrichmentData = await _enrichmentProvider.GetCompanyDataAsync(
                contact.Email, contact.CompanyDomain);

            if (enrichmentData != null)
            {
                contact.CompanyName = enrichmentData.CompanyName;
                contact.Industry = enrichmentData.Industry;
                contact.CompanySize = enrichmentData.EmployeeCount;
                contact.LinkedinUrl = enrichmentData.LinkedinProfile;
                contact.Title = enrichmentData.JobTitle ?? contact.Title;

                await _eventBus.PublishAsync(new ContactEnrichedEvent
                {
                    ContactId = contact.Id,
                    TenantId = contact.TenantId,
                    EnrichmentSource = "clearbit",
                    EnrichedFields = enrichmentData.GetChangedFields()
                });
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex,
                "Enrichment failed for contact {ContactId}, continuing without enrichment",
                contact.Id);
        }
    }
}

Duplicate Detection on Contact Create

Before inserting a contact, the system checks for potential duplicates using multiple matching strategies: exact email match, fuzzy name + company match, and phonetic matching on phone numbers.

C#
public class DuplicateDetectionService
{
    private readonly CrmDbContext _db;

    public async Task<List<PotentialDuplicate>> FindDuplicatesAsync(
        Contact newContact, Guid tenantId)
    {
        var duplicates = new List<PotentialDuplicate>();

        // Strategy 1: Exact email match (highest confidence)
        if (!string.IsNullOrEmpty(newContact.Email))
        {
            var emailMatches = await _db.Contacts
                .Where(c => c.TenantId == tenantId
                    && c.Email.ToLower() == newContact.Email.ToLower()
                    && !c.IsDeleted
                    && c.Id != newContact.Id)
                .Select(c => new PotentialDuplicate
                {
                    Contact = c,
                    MatchType = "exact_email",
                    Confidence = 1.0m
                })
                .ToListAsync();
            duplicates.AddRange(emailMatches);
        }

        // Strategy 2: Fuzzy name + company match
        var firstName = newContact.FirstName.ToLower();
        var lastName = newContact.LastName.ToLower();
        var fuzzyMatches = await _db.Contacts
            .Where(c => c.TenantId == tenantId
                && !c.IsDeleted
                && c.Id != newContact.Id
                && LevenshteinDistance(c.FirstName.ToLower(), firstName) <= 1
                && LevenshteinDistance(c.LastName.ToLower(), lastName) <= 1
                && c.AccountId == newContact.AccountId)
            .Select(c => new PotentialDuplicate
            {
                Contact = c,
                MatchType = "fuzzy_name_company",
                Confidence = 0.75m
            })
            .ToListAsync();
        duplicates.AddRange(fuzzyMatches);

        return duplicates.OrderByDescending(d => d.Confidence).ToList();
    }
}

6. Company/Account Modeling

Accounts represent organizations — they can be customers, prospects, partners, or competitors. The account model supports hierarchical trees (parent companies with subsidiaries), relationship mapping, and account-level scoring.

Account Hierarchy

graph TD ACME["ACME Corp
Annual Revenue: $500M"] ACME_US["ACME US Inc.
Annual Revenue: $200M"] ACME_EU["ACME Europe GmbH
Annual Revenue: $150M"] ACME_UK["ACME UK Ltd.
Annual Revenue: $80M"] ACME_DE["ACME Germany AG
Annual Revenue: $70M"] ACME_FR["ACME France SAS
Annual Revenue: $60M"] ACME --> ACME_US ACME --> ACME_EU ACME_EU --> ACME_UK ACME_EU --> ACME_DE ACME_EU --> ACME_FR

The account hierarchy enables roll-up reporting — you can see the total pipeline, revenue, and activity across all subsidiaries of a global account. The system computes roll-ups asynchronously and caches them to avoid expensive recursive queries on every page load.

C#
public class AccountHierarchyService
{
    private readonly CrmDbContext _db;
    private readonly IDistributedCache _cache;

    public async Task<AccountRollUp> GetAccountRollUpAsync(Guid accountId, Guid tenantId)
    {
        var cacheKey = $"account:rollup:{tenantId}:{accountId}";
        var cached = await _cache.GetAsync<AccountRollUp>(cacheKey);
        if (cached != null) return cached;

        var allAccountIds = await GetDescendantAccountIdsAsync(accountId, tenantId);
        allAccountIds.Add(accountId);

        var rollUp = new AccountRollUp
        {
            TotalOpenDeals = await _db.Deals
                .Where(d => allAccountIds.Contains(d.AccountId)
                    && d.TenantId == tenantId
                    && d.Stage != "closed_won"
                    && d.Stage != "closed_lost")
                .SumAsync(d => d.Amount),

            TotalWonRevenue = await _db.Deals
                .Where(d => allAccountIds.Contains(d.AccountId)
                    && d.TenantId == tenantId
                    && d.Stage == "closed_won")
                .SumAsync(d => d.Amount),

            TotalContacts = await _db.Contacts
                .Where(c => allAccountIds.Contains(c.AccountId)
                    && c.TenantId == tenantId
                    && !c.IsDeleted)
                .CountAsync(),

            ChildAccountCount = allAccountIds.Count - 1
        };

        await _cache.SetAsync(cacheKey, rollUp, TimeSpan.FromMinutes(15));
        return rollUp;
    }

    private async Task<List<Guid>> GetDescendantAccountIdsAsync(
        Guid parentId, Guid tenantId)
    {
        var result = new List<Guid>();
        var children = await _db.Accounts
            .Where(a => a.ParentAccountId == parentId
                && a.TenantId == tenantId
                && !a.IsDeleted)
            .Select(a => a.Id)
            .ToListAsync();

        foreach (var childId in children)
        {
            result.Add(childId);
            result.AddRange(await GetDescendantAccountIdsAsync(childId, tenantId));
        }
        return result;
    }
}
Performance Optimization: The recursive hierarchy query is bounded to 10 levels deep (matching typical corporate structures) and results are cached for 15 minutes. For real-time roll-ups on the account detail page, a background job refreshes the cache every 5 minutes.

7. Deal/Opportunity Pipeline & Kanban Stages

The deal pipeline is the revenue engine of the CRM. Each deal progresses through configurable stages, and the system provides Kanban views, weighted forecasting, and stage velocity analytics.

Pipeline Stages

graph LR P["Prospecting
10%"] --> DISC["Discovery
20%"] DISC --> QUAL["Qualification
30%"] QUAL --> PROPOSAL["Proposal
50%"] PROPOSAL --> NEG["Negotiation
75%"] NEG --> CLOSEDW["Closed Won
100%"] NEG --> CLOSEDLOST["Closed Lost
0%"] PROPOSAL --> CLOSEDLOST QUAL --> CLOSEDLOST

Deal Service Implementation

C#
public class DealService
{
    private readonly CrmDbContext _db;
    private readonly IEventBus _eventBus;
    private readonly IForecastEngine _forecastEngine;

    public async Task<Deal> CreateDealAsync(CreateDealCommand command, Guid tenantId)
    {
        var deal = new Deal
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            Name = command.Name,
            AccountId = command.AccountId,
            ContactId = command.ContactId,
            OwnerId = command.OwnerId,
            Stage = "prospecting",
            Amount = command.Amount,
            CloseDate = command.ExpectedCloseDate,
            Probability = 10m,
            ForecastCategory = "pipeline",
            Type = command.DealType,
            LeadSource = command.LeadSource,
            CreatedAt = DateTime.UtcNow,
            UpdatedAt = DateTime.UtcNow
        };

        _db.Deals.Add(deal);
        await _db.SaveChangesAsync();

        await _eventBus.PublishAsync(new DealCreatedEvent
        {
            DealId = deal.Id,
            TenantId = tenantId,
            Amount = deal.Amount,
            Stage = deal.Stage
        });

        return deal;
    }

    public async Task<Deal> AdvanceStageAsync(
        Guid dealId, string newStage, Guid tenantId, Guid userId)
    {
        var deal = await _db.Deals
            .FirstOrDefaultAsync(d => d.Id == dealId && d.TenantId == tenantId);

        if (deal == null) throw new NotFoundException("Deal not found");

        var oldStage = deal.Stage;
        var stageConfig = await GetStageConfigAsync(newStage, tenantId);

        // Record stage history for velocity tracking
        _db.DealStageHistories.Add(new DealStageHistory
        {
            Id = Guid.NewGuid(),
            DealId = dealId,
            TenantId = tenantId,
            FromStage = oldStage,
            ToStage = newStage,
            MovedAt = DateTime.UtcNow,
            MovedByUserId = userId,
            DaysInPreviousStage = (DateTime.UtcNow - deal.UpdatedAt).Days
        });

        deal.Stage = newStage;
        deal.Probability = stageConfig.Probability;
        deal.ForecastCategory = stageConfig.ForecastCategory;
        deal.UpdatedAt = DateTime.UtcNow;

        if (newStage == "closed_won")
        {
            deal.WonReason = "Sales process completed";
            await UpdateAccountRevenueAsync(deal.AccountId, deal.Amount, tenantId);
        }
        else if (newStage == "closed_lost")
        {
            deal.LostReason = "Lost to competitor"; // Updated by user
        }

        await _db.SaveChangesAsync();

        await _eventBus.PublishAsync(new DealStageChangedEvent
        {
            DealId = dealId,
            TenantId = tenantId,
            OldStage = oldStage,
            NewStage = newStage,
            Amount = deal.Amount
        });

        return deal;
    }
}

Forecasting Model

Forecast CategoryStagesWeighting
PipelineProspecting, DiscoveryAmount × Probability %
Best CaseQualification, ProposalAmount × Probability %
CommitNegotiationAmount × Probability %
Closed WonClosed WonAmount × 100%

8. Activity Tracking — Calls, Emails, Meetings

Activities are the interaction history between your team and contacts. Every call, email, meeting, and note creates an activity record linked to the relevant contacts, deals, and accounts. This creates the "timeline" view that gives a 360-degree view of the relationship.

Activity Types and Schema

C#
public enum ActivityType
{
    Call,
    Email,
    Meeting,
    Note,
    Task,
    Sms,
    WhatsAp
}

public class Activity
{
    public Guid Id { get; set; }
    public Guid TenantId { get; set; }
    public ActivityType Type { get; set; }
    public string Subject { get; set; }
    public string Description { get; set; }
    public Guid? ContactId { get; set; }
    public Guid? DealId { get; set; }
    public Guid? AccountId { get; set; }
    public Guid OwnerId { get; set; }
    public DateTime? DueDate { get; set; }
    public DateTime? CompletedAt { get; set; }
    public int? DurationMinutes { get; set; }
    public string Outcome { get; set; } // connected, voicemail, no_answer, left_message
    public Dictionary<string, object> Metadata { get; set; } // call recording URL, meeting link, etc.
}

public class LogCallCommand
{
    public Guid ContactId { get; set; }
    public Guid? DealId { get; set; }
    public string Subject { get; set; }
    public string Notes { get; set; }
    public int DurationMinutes { get; set; }
    public string Outcome { get; set; }
    public string CallRecordingUrl { get; set; }
}

Timeline View

The timeline view aggregates all activities across contacts, deals, and accounts, sorted by date. It supports filtering by activity type, date range, and owner.

C#
public class TimelineService
{
    private readonly CrmDbContext _db;

    public async Task<PagedResult<TimelineEntry>> GetTimelineAsync(
        TimelineQuery query, Guid tenantId)
    {
        var baseQuery = _db.Activities
            .Where(a => a.TenantId == tenantId && !a.IsDeleted);

        if (query.ContactId.HasValue)
            baseQuery = baseQuery.Where(a => a.ContactId == query.ContactId);

        if (query.DealId.HasValue)
            baseQuery = baseQuery.Where(a => a.DealId == query.DealId);

        if (query.AccountId.HasValue)
            baseQuery = baseQuery.Where(a => a.AccountId == query.AccountId);

        if (query.ActivityTypes?.Any() == true)
            baseQuery = baseQuery.Where(a => query.ActivityTypes.Contains(a.Type));

        if (query.Since.HasValue)
            baseQuery = baseQuery.Where(a => a.CreatedAt >= query.Since);

        var total = await baseQuery.CountAsync();
        var activities = await baseQuery
            .OrderByDescending(a => a.CreatedAt)
            .Skip((query.Page - 1) * query.PageSize)
            .Take(query.PageSize)
            .Select(a => new TimelineEntry
            {
                Id = a.Id,
                Type = a.Type.ToString(),
                Subject = a.Subject,
                Description = a.Description,
                Date = a.CompletedAt ?? a.CreatedAt,
                OwnerName = a.Owner.FullName,
                Duration = a.DurationMinutes,
                ContactName = a.Contact.FullName,
                DealName = a.Deal.Name
            })
            .ToListAsync();

        return new PagedResult<TimelineEntry>(activities, total, query.Page, query.PageSize);
    }
}

9. Email Integration — Gmail/Outlook Sync & Tracking

Email integration is one of the most valued CRM features. It connects the two-way email conversation to the CRM record, logs emails as activities, and provides open/click tracking.

Email Sync Architecture

sequenceDiagram participant U as User participant CRM as CRM App participant MSG as Message Queue participant WORKER as Sync Worker participant GMAIL as Gmail API participant ES as Elasticsearch U->>CRM: Connect Gmail Account CRM->>GMAIL: OAuth 2.0 Authorization GMAIL-->>CRM: Access Token + Refresh Token CRM->>MSG: Enqueue Initial Sync Job loop Every 3 minutes (Push Notifications) GMAIL->>WORKER: Webhook: New Email WORKER->>GMAIL: Fetch Email Content GMAIL-->>WORKER: Email + Headers WORKER->>WORKER: Match Recipients to Contacts WORKER->>CRM: Create Activity Record WORKER->>ES: Index for Search end U->>CRM: Send Email via CRM CRM->>GMAIL: Send through Connected Account Note over CRM: Add Tracking Pixel +
Unique Link Parameters CRM->>CRM: Log as Sent Activity

Email Tracking Pixel Service

C#
public class EmailTrackingService
{
    private readonly CrmDbContext _db;
    private readonly IEventBus _eventBus;

    public string GenerateTrackingPixel(string activityId, Guid tenantId)
    {
        var trackingId = EncryptTrackingId(activityId, tenantId);
        return $"https://track.yourcrm.com/pixel/{trackingId}.gif";
    }

    public async Task RecordOpenAsync(string trackingId)
    {
        var (activityId, tenantId) = DecryptTrackingId(trackingId);

        var tracking = new EmailTrackingEvent
        {
            ActivityId = activityId,
            TenantId = tenantId,
            EventType = "opened",
            Timestamp = DateTime.UtcNow
        };

        _db.EmailTrackingEvents.Add(tracking);

        // Update aggregated stats
        var stats = await _db.EmailTrackingStats
            .FirstOrDefaultAsync(s => s.ActivityId == activityId);
        if (stats != null)
        {
            stats.OpenCount++;
            stats.LastOpenedAt = DateTime.UtcNow;
        }

        await _db.SaveChangesAsync();

        await _eventBus.PublishAsync(new EmailOpenedEvent
        {
            ActivityId = activityId,
            TenantId = tenantId,
            OpenedAt = DateTime.UtcNow
        });
    }

    public async Task RecordClickAsync(string trackingId, string targetUrl)
    {
        var (activityId, tenantId) = DecryptTrackingId(trackingId);

        _db.EmailTrackingEvents.Add(new EmailTrackingEvent
        {
            ActivityId = activityId,
            TenantId = tenantId,
            EventType = "clicked",
            TargetUrl = targetUrl,
            Timestamp = DateTime.UtcNow
        });

        var stats = await _db.EmailTrackingStats
            .FirstOrDefaultAsync(s => s.ActivityId == activityId);
        if (stats != null)
        {
            stats.ClickCount++;
            stats.LastClickedAt = DateTime.UtcNow;
        }

        await _db.SaveChangesAsync();
    }
}
Gmail Push Notifications: Instead of polling, the CRM uses Gmail's Pub/Sub API. When a user connects their Gmail, the CRM subscribes to their inbox topic. Gmail publishes notifications to a Google Cloud Pub/Sub topic, which triggers a webhook to the CRM's sync worker. This reduces API calls by 95% compared to polling.

10. Task & Reminder Management

Tasks are the action items that keep deals moving forward. The task system integrates with the pipeline — when a deal moves to a new stage, the system can automatically create follow-up tasks. Reminders ensure nothing falls through the cracks.

C#
public class TaskService
{
    private readonly CrmDbContext _db;
    private readonly INotificationService _notificationService;
    private readonly IEventBus _eventBus;

    public async Task<CrmTask> CreateTaskAsync(CreateTaskCommand cmd, Guid tenantId)
    {
        var task = new CrmTask
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            Title = cmd.Title,
            Description = cmd.Description,
            AssignedToUserId = cmd.AssigneeId,
            CreatedByUserId = cmd.CreatorId,
            ContactId = cmd.ContactId,
            DealId = cmd.DealId,
            AccountId = cmd.AccountId,
            Priority = cmd.Priority, // low, medium, high, urgent
            Status = "open",
            DueDate = cmd.DueDate,
            ReminderAt = cmd.ReminderAt ?? cmd.DueDate?.AddHours(-1),
            CreatedAt = DateTime.UtcNow
        };

        _db.CrmTasks.Add(task);
        await _db.SaveChangesAsync();

        if (task.ReminderAt.HasValue && task.ReminderAt > DateTime.UtcNow)
        {
            await ScheduleReminderAsync(task);
        }

        await _eventBus.PublishAsync(new TaskCreatedEvent
        {
            TaskId = task.Id,
            TenantId = tenantId,
            AssignedTo = task.AssignedToUserId
        });

        return task;
    }

    private async Task ScheduleReminderAsync(CrmTask task)
    {
        var reminder = new ScheduledReminder
        {
            Id = Guid.NewGuid(),
            TaskId = task.Id,
            TenantId = task.TenantId,
            UserId = task.AssignedToUserId,
            ReminderAt = task.ReminderAt.Value,
            Method = ReminderMethod.InApp | ReminderMethod.Email,
            IsSent = false
        };

        _db.ScheduledReminders.Add(reminder);
        await _db.SaveChangesAsync();
    }
}

11. Custom Fields & Objects

The ability to define custom objects and fields is what separates a CRM from a simple database. Salesforce's AppExchange ecosystem exists because of custom objects. The platform must allow tenants to create objects like "Products," "Events," or "Invoices" — each with their own fields, relationships, and business logic — without any code deployment.

Custom Field Types

Field TypeStorageMax LengthExample
TextVARCHAR via JSONB255 charsExternal ID, Reference Number
NumberNUMERIC via JSONB18 digitsQuantity, Score
CurrencyNUMERIC via JSONB15,2Deal Amount, Invoice Total
DateISO string via JSONBContract Start Date
PicklistString via JSONB255 charsStatus, Priority, Region
Multi-Select PicklistArray via JSONBTags, Categories
BooleanBoolean via JSONBIs VIP, Requires Approval
LookupUUID via JSONBRelated Account, Parent Record
FormulaComputed at read timeROI = Revenue / Cost
URLString via JSONB2048 charsWebsite, Documentation Link
EmailString via JSONB255 charsAlternate Email
PhoneString via JSONB50 charsFax, Direct Line

Custom Object CRUD Service

C#
public class CustomObjectService
{
    private readonly CrmDbContext _db;
    private readonly ICustomFieldValidator _validator;

    public async Task<CustomObjectRecord> CreateRecordAsync(
        string objectApiName,
        Dictionary<string, object> fieldValues,
        Guid tenantId)
    {
        // Validate the object exists for this tenant
        var objectDef = await _db.CustomObjectDefinitions
            .FirstOrDefaultAsync(o =>
                o.TenantId == tenantId
                && o.ApiName == objectApiName
                && !o.IsDeleted)
            ?? throw new NotFoundException($"Object '{objectApiName}' not found");

        // Get field definitions and validate values
        var fieldDefs = await _db.CustomFieldDefinitions
            .Where(f => f.TenantId == tenantId && f.ObjectApiName == objectApiName)
            .ToListAsync();

        var validationResult = await _validator.ValidateAsync(fieldValues, fieldDefs);
        if (!validationResult.IsValid)
            throw new ValidationException(validationResult.Errors);

        // Apply default values for missing required fields
        foreach (var field in fieldDefs.Where(f => f.IsRequired && !fieldValues.ContainsKey(f.FieldApiName)))
        {
            if (field.DefaultValue != null)
                fieldValues[field.FieldApiName] = field.DefaultValue;
            else
                throw new ValidationException($"Required field '{field.DisplayName}' is missing");
        }

        var record = new CustomObjectRecord
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            ObjectApiName = objectApiName,
            RecordId = Guid.NewGuid(),
            FieldValues = fieldValues,
            CreatedAt = DateTime.UtcNow,
            UpdatedAt = DateTime.UtcNow
        };

        _db.CustomObjectRecords.Add(record);
        await _db.SaveChangesAsync();
        return record;
    }
}

12. Workflow Automation — If-This-Then-That Triggers

Workflow automation is the multiplier that lets a small team operate like a large one. When a deal closes, automatically create an onboarding task. When a contact's score crosses 80, notify the sales rep. When an activity is logged, update the deal's last-touch timestamp.

Workflow Rule Data Model

C#
public class WorkflowRule
{
    public Guid Id { get; set; }
    public Guid TenantId { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public bool IsActive { get; set; } = true;
    public TriggerType TriggerType { get; set; }
    public string TriggerObject { get; set; } // contact, deal, custom_object
    public List<WorkflowCondition> Conditions { get; set; }
    public List<WorkflowAction> Actions { get; set; }
    public ExecutionWindow ExecutionWindow { get; set; }
}

public enum TriggerType
{
    OnCreate,
    OnUpdate,
    OnDelete,
    OnFieldChange,
    OnSchedule,       // recurring: daily, weekly
    OnRecordMatch     // when a record matches criteria
}

public class WorkflowCondition
{
    public string FieldName { get; set; }
    public string Operator { get; set; } // equals, not_equals, greater_than, contains, is_in, is_empty
    public object Value { get; set; }
    public string LogicalOperator { get; set; } // AND, OR
}

public class WorkflowAction
{
    public ActionType Type { get; set; }
    public Dictionary<string, object> Parameters { get; set; }
}

public enum ActionType
{
    UpdateRecord,
    CreateTask,
    SendEmail,
    SendSlackNotification,
    AssignToUser,
    ChangeOwner,
    CreateRecord,     // create related record
    CallWebhook,      // external API call
    AddToCampaign,
    SetFieldValue
}

Workflow Execution Engine

C#
public class WorkflowExecutionEngine
{
    private readonly CrmDbContext _db;
    private readonly IEventBus _eventBus;
    private readonly IServiceProvider _serviceProvider;

    public async Task ProcessRecordChangeAsync(
        string objectName, Guid recordId, Guid tenantId,
        RecordChangeType changeType)
    {
        var rules = await _db.WorkflowRules
            .Where(r =>
                r.TenantId == tenantId
                && r.IsActive
                && r.TriggerObject == objectName
                && MatchesTriggerType(r.TriggerType, changeType))
            .ToListAsync();

        foreach (var rule in rules)
        {
            var record = await GetRecordAsync(objectName, recordId, tenantId);
            if (record == null) continue;

            var conditionsMet = EvaluateConditions(rule.Conditions, record);
            if (!conditionsMet) continue;

            var executionLog = new WorkflowExecutionLog
            {
                Id = Guid.NewGuid(),
                RuleId = rule.Id,
                TenantId = tenantId,
                RecordId = recordId,
                ObjectName = objectName,
                TriggeredBy = changeType,
                ExecutedAt = DateTime.UtcNow,
                ActionsExecuted = new List<string>()
            };

            foreach (var action in rule.Actions)
            {
                try
                {
                    await ExecuteActionAsync(action, record, tenantId);
                    executionLog.ActionsExecuted.Add(action.Type.ToString());
                }
                catch (Exception ex)
                {
                    executionLog.Errors.Add($"{action.Type}: {ex.Message}");
                }
            }

            executionLog.IsSuccess = !executionLog.Errors.Any();
            _db.WorkflowExecutionLogs.Add(executionLog);
        }

        await _db.SaveChangesAsync();
    }

    private async Task ExecuteActionAsync(
        WorkflowAction action, DynamicRecord record, Guid tenantId)
    {
        switch (action.Type)
        {
            case ActionType.UpdateRecord:
                await UpdateRecordFieldsAsync(record, action.Parameters);
                break;
            case ActionType.CreateTask:
                var taskService = _serviceProvider.GetRequiredService<TaskService>();
                await taskService.CreateTaskAsync(
                    MapToCreateTaskCommand(action.Parameters, record), tenantId);
                break;
            case ActionType.SendEmail:
                var emailService = _serviceProvider.GetRequiredService<EmailService>();
                await emailService.SendWorkflowEmailAsync(action.Parameters, record, tenantId);
                break;
            case ActionType.CallWebhook:
                var webhookService = _serviceProvider.GetRequiredService<WebhookService>();
                await webhookService.CallAsync(action.Parameters["url"].ToString(),
                    record, tenantId);
                break;
            case ActionType.AddToCampaign:
                var campaignService = _serviceProvider.GetRequiredService<CampaignService>();
                await campaignService.AddMemberAsync(
                    Guid.Parse(action.Parameters["campaignId"].ToString()),
                    record.Id, tenantId);
                break;
        }
    }

    private bool EvaluateConditions(List<WorkflowCondition> conditions, DynamicRecord record)
    {
        if (!conditions.Any()) return true;

        bool result = true;
        bool lastOperator = true;

        foreach (var condition in conditions)
        {
            var fieldValue = record.GetField(condition.FieldName);
            bool conditionMet = EvaluateSingleCondition(condition, fieldValue);

            if (condition.LogicalOperator == "OR")
                result = result || conditionMet;
            else
                result = result && conditionMet;
        }

        return result;
    }
}
Recursion Prevention: Workflow actions that update records must not trigger the same workflow again. The engine uses a "trigger depth" counter stored in a thread-local context. If depth exceeds 5, the engine stops processing to prevent infinite loops. This mirrors Salesforce's recursive trigger protection.

13. Lead Scoring with ML

Lead scoring determines which leads are most likely to convert, so sales teams focus their time on the highest-value prospects. The system combines rule-based scoring (deterministic) with ML-based scoring (predictive).

Scoring Components

ComponentSignalsWeight Source
Fit ScoreCompany size, industry, title seniority, technology stackRule-based (admin configured)
Engagement ScoreEmail opens, page visits, form fills, content downloadsML model (logistic regression)
Behavioral ScoreRecency of last activity, frequency of interactions, trend directionML model (gradient boosting)
Intent ScorePricing page visits, competitor comparison pages, demo requestRule-based + ML hybrid

ML Scoring Pipeline

graph LR subgraph Features["Feature Engineering"] F1["Demographic
Features"] F2["Firmographic
Features"] F3["Behavioral
Features"] F4["Engagement
Features"] end subgraph Model["Scoring Model"] M1["Feature
Vector"] M2["Logistic
Regression"] M3["Gradient
Boosting"] M4["Ensemble
Classifier"] end subgraph Output["Output"] O1["Lead Score
0-100"] O2["Conversion
Probability"] O3["Recommended
Action"] end F1 --> M1 F2 --> M1 F3 --> M1 F4 --> M1 M1 --> M2 M1 --> M3 M2 --> M4 M3 --> M4 M4 --> O1 M4 --> O2 M4 --> O3
C#
public class LeadScoringService
{
    private readonly CrmDbContext _db;
    private readonly IMLModelService _mlService;
    private readonly FeatureEngineeringService _featureService;

    public async Task<LeadScore> ScoreLeadAsync(Guid contactId, Guid tenantId)
    {
        var contact = await _db.Contacts
            .FirstOrDefaultAsync(c => c.Id == contactId && c.TenantId == tenantId);

        // Rule-based fit score
        var fitScore = CalculateFitScore(contact);

        // ML-based engagement score
        var features = await _featureService.ExtractFeaturesAsync(contactId, tenantId);
        var mlResult = await _mlService.PredictAsync(features);

        // Weighted composite score
        var compositeScore = new LeadScore
        {
            ContactId = contactId,
            FitScore = fitScore,                         // 0-100, rule-based
            EngagementScore = mlResult.EngagementScore,  // 0-100, ML-based
            BehavioralScore = mlResult.BehavioralScore,  // 0-100, ML-based
            IntentScore = CalculateIntentScore(contact),
            OverallScore = CalculateWeightedScore(fitScore, mlResult),
            ConversionProbability = mlResult.ConversionProbability,
            ScoredAt = DateTime.UtcNow,
            ModelVersion = mlResult.ModelVersion
        };

        // Save score
        var existing = await _db.LeadScores
            .FirstOrDefaultAsync(s => s.ContactId == contactId && s.TenantId == tenantId);
        if (existing != null)
            _db.LeadScores.Update(compositeScore);
        else
            _db.LeadScores.Add(compositeScore);

        await _db.SaveChangesAsync();

        // Check if score crossed MQL threshold
        if (compositeScore.OverallScore >= 80 && (existing?.OverallScore ?? 0) < 80)
        {
            await _eventBus.PublishAsync(new MQLThresholdReachedEvent
            {
                ContactId = contactId,
                TenantId = tenantId,
                Score = compositeScore.OverallScore
            });
        }

        return compositeScore;
    }

    private decimal CalculateFitScore(Contact contact)
    {
        decimal score = 0;

        // Company size scoring
        if (contact.CompanySize == "enterprise") score += 30;
        else if (contact.CompanySize == "mid_market") score += 20;
        else if (contact.CompanySize == "small") score += 10;

        // Title seniority scoring
        var seniorTitles = new[] { "ceo", "cto", "vp", "director", "head", "chief" };
        if (seniorTitles.Any(t => contact.Title?.ToLower().Contains(t) == true))
            score += 25;
        else if (contact.Title?.ToLower().Contains("manager") == true)
            score += 15;

        // Industry scoring (configured per tenant)
        score += GetIndustryScore(contact.Industry);

        // Target account scoring
        if (contact.AccountId.HasValue)
        {
            var account = _db.Accounts.Find(contact.AccountId.Value);
            if (account?.AccountType == "target_account") score += 20;
        }

        return Math.Min(score, 100);
    }
}

14. Forecasting & Reporting

Reporting in a CRM serves multiple audiences: sales reps need deal-level views, sales managers need team roll-ups, executives need quarterly forecasts, and marketing needs campaign ROI. The reporting engine must handle both real-time queries (dashboards) and scheduled batch reports (email delivery).

Forecasting Engine

C#
public class ForecastingEngine
{
    private readonly CrmDbContext _db;
    private readonly IDataWarehouse _dataWarehouse;

    public async Task<Forecast> GenerateForecastAsync(
        Guid tenantId, Guid salesManagerId, ForecastPeriod period)
    {
        var repIds = await GetDirectReportsAsync(salesManagerId, tenantId);

        var openDeals = await _db.Deals
            .Where(d =>
                d.TenantId == tenantId
                && repIds.Contains(d.OwnerId)
                && !d.IsDeleted
                && d.Stage != "closed_won"
                && d.Stage != "closed_lost"
                && d.CloseDate >= period.StartDate
                && d.CloseDate <= period.EndDate)
            .ToListAsync();

        var forecast = new Forecast
        {
            Period = period,
            GeneratedAt = DateTime.UtcNow,
            Categories = new Dictionary<string, ForecastCategory>()
        };

        // Group deals by forecast category
        foreach (var category in new[] { "pipeline", "best_case", "commit", "closed_won" })
        {
            var categoryDeals = openDeals.Where(d => d.ForecastCategory == category).ToList();
            forecast.Categories[category] = new ForecastCategory
            {
                Name = category,
                TotalAmount = categoryDeals.Sum(d => d.Amount ?? 0),
                WeightedAmount = categoryDeals.Sum(d =>
                    (d.Amount ?? 0) * (d.Probability ?? 0) / 100),
                DealCount = categoryDeals.Count,
                Deals = categoryDeals.Select(d => new ForecastDeal
                {
                    DealId = d.Id,
                    DealName = d.Name,
                    Amount = d.Amount,
                    Probability = d.Probability,
                    CloseDate = d.CloseDate,
                    OwnerName = d.Owner.FullName
                }).ToList()
            };
        }

        // Compare against quota
        var quota = await _db.SalesQuotas
            .FirstOrDefaultAsync(q =>
                q.TenantId == tenantId
                && q.UserId == salesManagerId
                && q.PeriodStart == period.StartDate);

        if (quota != null)
        {
            forecast.QuotaAmount = quota.Amount;
            forecast.AttainmentPercent =
                forecast.Categories["closed_won"].TotalAmount / quota.Amount * 100;
        }

        return forecast;
    }
}

Report Builder Query Engine

C#
public class ReportQueryBuilder
{
    public string BuildDynamicQuery(ReportDefinition definition, Guid tenantId)
    {
        var baseTable = GetBaseTable(definition.ObjectName);
        var conditions = new List<string> { $"{baseTable}.tenant_id = '{tenantId}'" };
        var selectColumns = new List<string>();

        // Add filters
        foreach (var filter in definition.Filters)
        {
            conditions.Add(BuildFilterClause(filter));
        }

        // Add group-by
        if (definition.GroupBy?.Any() == true)
        {
            selectColumns.AddRange(definition.GroupBy.Select(g => g.ColumnName));
        }

        // Add aggregations
        foreach (var agg in definition.Aggregations)
        {
            selectColumns.Add($"{agg.Function}({agg.ColumnName}) AS {agg.Alias}");
        }

        // Add row-level filters for RBAC
        var rowFilter = GetRowLevelFilter(definition.ObjectName, tenantId);
        conditions.Add(rowFilter);

        return $@"
            SELECT {string.Join(", ", selectColumns)}
            FROM {baseTable}
            WHERE {string.Join(" AND ", conditions)}
            {(definition.GroupBy?.Any() == true
                ? $"GROUP BY {string.Join(", ", definition.GroupBy.Select(g => g.ColumnName))}"
                : "")}
            {(definition.Having != null ? $"HAVING {definition.Having}" : "")}
            ORDER BY {definition.OrderBy ?? "1"}
            LIMIT {definition.MaxRows ?? 10000}";
    }
}

15. Dashboard Builder

The dashboard builder allows users to compose multiple report widgets into a single view. Each widget can be a chart, table, KPI number, or funnel visualization. Dashboards refresh on configurable intervals and support drill-down into underlying records.

Widget Types

WidgetData SourceVisualizationUse Case
KPI CardSingle aggregateBig number + trendTotal Pipeline, Won Revenue
Bar ChartGrouped aggregationHorizontal/Vertical barsDeals by Stage, Revenue by Rep
Line ChartTime seriesLine with markersMonthly Revenue Trend
Pie/DonutCategorical splitCircle segmentsDeals by Industry, Lead Source
FunnelPipeline stagesFunnel shapeLead → Opportunity Conversion
TableTabular reportSortable tableTop Deals, Overdue Tasks
HeatmapMatrix aggregationColor-coded gridActivity by Day/Hour

Dashboard Data Model

C#
public class Dashboard
{
    public Guid Id { get; set; }
    public Guid TenantId { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public Guid OwnerId { get; set; }
    public DashboardLayout Layout { get; set; }
    public List<DashboardWidget> Widgets { get; set; }
    public DashboardScope Scope { get; set; } // private, team, public
    public int RefreshIntervalSeconds { get; set; } = 300;
}

public class DashboardWidget
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public WidgetType Type { get; set; }
    public Guid ReportId { get; set; }
    public int GridRow { get; set; }
    public int GridCol { get; set; }
    public int GridWidth { get; set; }
    public int GridHeight { get; set; }
    public Dictionary<string, object> VisualizationConfig { get; set; }
    // e.g., { "chartType": "bar", "color": "#58a6ff", "showLabels": true }
}

16. Role-Based Access Control & Territory Management

Enterprise CRMs serve organizations with complex hierarchies. A sales rep sees only their own deals. A regional manager sees all deals in their region. A VP sees all deals globally. RBAC must be enforced at the data layer, not just the UI layer.

Permission Model

graph TB subgraph Hierarchy["Role Hierarchy"] CEO["CEO
Full Access"] VP_SALES["VP Sales
All Deals"] DIR_EAST["Director East
East Region Deals"] DIR_WEST["Director West
West Region Deals"] REP_A["Rep A
Own Deals Only"] REP_B["Rep B
Own Deals Only"] end CEO --> VP_SALES VP_SALES --> DIR_EAST VP_SALES --> DIR_WEST DIR_EAST --> REP_A DIR_WEST --> REP_B
C#
public class RecordSharingService
{
    private readonly CrmDbContext _db;

    public IQueryable<T> ApplyRecordSharing<T>(
        IQueryable<T> query, Guid userId, Guid tenantId,
        string objectName) where T : CrmEntity
    {
        var userRole = _db.UserRoles
            .FirstOrDefault(ur => ur.UserId == userId && ur.TenantId == tenantId);

        if (userRole == null)
            return query.Where(x => false); // no access

        var permission = GetObjectPermission(userRole.RoleId, objectName, tenantId);

        return permission.AccessLevel switch
        {
            AccessLevel.All => query, // no additional filter

            AccessLevel.Team => query.Where(x =>
                x.OwnerId == userId
                || _db.TeamMembers.Any(tm =>
                    tm.TeamId == x.TeamId && tm.UserId == userId)),

            AccessLevel.Territory => query.Where(x =>
                _db.TerritoryAssignments.Any(ta =>
                    ta.UserId == userId
                    && ta.TerritoryId == x.TerritoryId)),

            AccessLevel.SelfAndSubordinates => query.Where(x =>
                _db.UserHierarchy.IsDescendantOf(userId, x.OwnerId, tenantId)),

            AccessLevel.Self => query.Where(x => x.OwnerId == userId),

            AccessLevel.None => query.Where(x => false),

            _ => query.Where(x => false)
        };
    }
}

public class TerritoryManagementService
{
    private readonly CrmDbContext _db;

    public async Task<Territory> CreateTerritoryAsync(
        CreateTerritoryCommand cmd, Guid tenantId)
    {
        var territory = new Territory
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            Name = cmd.Name,
            Description = cmd.Description,
            ParentTerritoryId = cmd.ParentTerritoryId,
            // Territory rules define automatic record assignment
            Rules = cmd.Rules.Select(r => new TerritoryRule
            {
                FieldName = r.FieldName,
                Operator = r.Operator,
                Value = r.Value
            }).ToList()
        };

        _db.Territories.Add(territory);
        await _db.SaveChangesAsync();
        return territory;
    }

    public async Task<Guid?> FindTerritoryForRecordAsync(
        string objectName, Dictionary<string, object> fieldValues, Guid tenantId)
    {
        var territories = await _db.Territories
            .Where(t => t.TenantId == tenantId && !t.IsDeleted)
            .Include(t => t.Rules)
            .ToListAsync();

        foreach (var territory in territories.OrderBy(t => t.HierarchyDepth))
        {
            if (territory.Rules.All(r => r.Evaluate(fieldValues)))
                return territory.Id;
        }

        return null; // default territory
    }
}

17. Notes & Collaboration

Notes allow teams to capture context that doesn't fit structured fields — meeting summaries, relationship insights, competitive intel. Collaboration features like @mentions, comments, and sharing keep the team aligned.

C#
public class NoteService
{
    private readonly CrmDbContext _db;
    private readonly INotificationService _notifications;

    public async Task<Note> CreateNoteAsync(CreateNoteCommand cmd, Guid tenantId, Guid userId)
    {
        var note = new Note
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            Content = cmd.Content,
            RichTextContent = cmd.RichTextContent,
            ContactId = cmd.ContactId,
            DealId = cmd.DealId,
            AccountId = cmd.AccountId,
            CreatedByUserId = userId,
            IsPinned = cmd.IsPinned,
            CreatedAt = DateTime.UtcNow
        };

        _db.Notes.Add(note);
        await _db.SaveChangesAsync();

        // Extract @mentions and notify
        var mentions = ExtractMentions(cmd.Content);
        foreach (var mention in mentions)
        {
            await _notifications.SendAsync(new Notification
            {
                TenantId = tenantId,
                UserId = mention.UserId,
                Type = NotificationType.Mention,
                Title = $"{GetUserName(userId)} mentioned you in a note",
                Body = TruncateText(cmd.Content, 200),
                EntityId = note.Id,
                EntityType = "note"
            });
        }

        return note;
    }

    private List<MentionedUser> ExtractMentions(string content)
    {
        var pattern = @"@(\w+\.\w+)";
        var matches = Regex.Matches(content, pattern);
        return matches.Select(m => new MentionedUser { Email = m.Groups[1].Value }).ToList();
    }
}

18. Document Management

Document management handles contracts, proposals, presentations, and other files attached to CRM records. The system stores files in object storage (S3/Azure Blob) and maintains metadata in the database for searchability and access control.

C#
public class DocumentService
{
    private readonly IObjectStorage _storage;
    private readonly CrmDbContext _db;
    private readonly IDocumentSearchIndexer _searchIndexer;

    public async Task<CrmDocument> UploadDocumentAsync(
        UploadDocumentCommand cmd, Guid tenantId, Guid userId)
    {
        // Generate a unique storage key with tenant isolation
        var storageKey = $"{tenantId}/{cmd.RelatedObjectType}/{cmd.RelatedObjectId}/{Guid.NewGuid()}/{cmd.FileName}";

        var uploadResult = await _storage.UploadAsync(storageKey, cmd.FileStream, new UploadOptions
        {
            ContentType = cmd.ContentType,
            Metadata = new Dictionary<string, string>
            {
                ["tenant-id"] = tenantId.ToString(),
                ["uploaded-by"] = userId.ToString(),
                ["original-name"] = cmd.FileName
            }
        });

        var document = new CrmDocument
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            FileName = cmd.FileName,
            ContentType = cmd.ContentType,
            FileSizeBytes = cmd.FileStream.Length,
            StorageKey = storageKey,
            RelatedObjectType = cmd.RelatedObjectType,
            RelatedObjectId = cmd.RelatedObjectId,
            UploadedByUserId = userId,
            UploadedAt = DateTime.UtcNow,
            Version = 1,
            Tags = cmd.Tags ?? new List<string>()
        };

        _db.Documents.Add(document);
        await _db.SaveChangesAsync();
        await _searchIndexer.IndexDocumentAsync(document);

        return document;
    }

    public async Task<Stream> DownloadDocumentAsync(Guid documentId, Guid tenantId)
    {
        var document = await _db.Documents
            .FirstOrDefaultAsync(d => d.Id == documentId && d.TenantId == tenantId)
            ?? throw new NotFoundException("Document not found");

        return await _storage.DownloadAsync(document.StorageKey);
    }
}

19. Product Catalog & Price Books

Product catalogs and price books enable the CRM to associate specific products with deals, calculate totals, and manage pricing for different segments (enterprise, SMB, partner discounts). This is essential for CPQ (Configure, Price, Quote) workflows.

Schema Design

SQL
CREATE TABLE products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    name VARCHAR(255) NOT NULL,
    sku VARCHAR(100),
    description TEXT,
    category VARCHAR(100),
    unit_price NUMERIC(15,2) NOT NULL,
    currency VARCHAR(3) NOT NULL DEFAULT 'USD',
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    custom_fields JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE price_books (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    name VARCHAR(255) NOT NULL,
    description TEXT,
    is_default BOOLEAN NOT NULL DEFAULT FALSE,
    currency VARCHAR(3) NOT NULL DEFAULT 'USD',
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE price_book_entries (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    price_book_id UUID NOT NULL REFERENCES price_books(id),
    product_id UUID NOT NULL REFERENCES products(id),
    unit_price NUMERIC(15,2) NOT NULL,
    discount_percent NUMERIC(5,2) DEFAULT 0,
    min_quantity INT DEFAULT 1,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(price_book_id, product_id)
);

CREATE TABLE deal_products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    deal_id UUID NOT NULL REFERENCES deals(id),
    product_id UUID NOT NULL REFERENCES products(id),
    quantity INT NOT NULL DEFAULT 1,
    unit_price NUMERIC(15,2) NOT NULL,
    discount_percent NUMERIC(5,2) DEFAULT 0,
    total_price NUMERIC(15,2) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

20. Quotes & Proposals

Quotes formalize the pricing and terms presented to customers. A quote is generated from a deal, includes products from the price book, applies discounts, and produces a PDF for the customer. The quote lifecycle includes creation, approval, sending, and acceptance.

C#
public class QuoteService
{
    private readonly CrmDbContext _db;
    private readonly IPdfGenerator _pdfGenerator;
    private readonly IEmailService _emailService;

    public async Task<Quote> GenerateQuoteAsync(Guid dealId, Guid tenantId)
    {
        var deal = await _db.Deals
            .Include(d => d.DealProducts).ThenInclude(dp => dp.Product)
            .Include(d => d.Account)
            .FirstOrDefaultAsync(d => d.Id == dealId && d.TenantId == tenantId);

        var quote = new Quote
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            DealId = dealId,
            QuoteNumber = await GenerateQuoteNumberAsync(tenantId),
            Status = "draft",
            ValidUntil = DateTime.UtcNow.AddDays(30),
            LineItems = deal.DealProducts.Select(dp => new QuoteLineItem
            {
                ProductId = dp.ProductId,
                ProductName = dp.Product.Name,
                Description = dp.Product.Description,
                Quantity = dp.Quantity,
                UnitPrice = dp.UnitPrice,
                DiscountPercent = dp.DiscountPercent,
                TotalPrice = dp.TotalPrice
            }).ToList(),
            SubTotal = deal.DealProducts.Sum(dp => dp.TotalPrice),
            DiscountAmount = deal.DealProducts.Sum(dp =>
                dp.UnitPrice * dp.Quantity * (dp.DiscountPercent / 100)),
            TaxRate = 0.08m, // configurable per tenant
            CreatedAt = DateTime.UtcNow
        };

        quote.TaxAmount = (quote.SubTotal - quote.DiscountAmount) * quote.TaxRate;
        quote.TotalAmount = quote.SubTotal - quote.DiscountAmount + quote.TaxAmount;

        _db.Quotes.Add(quote);
        await _db.SaveChangesAsync();
        return quote;
    }

    public async Task<byte[]> GenerateQuotePdfAsync(Guid quoteId, Guid tenantId)
    {
        var quote = await _db.Quotes
            .Include(q => q.LineItems)
            .Include(q => q.Deal).ThenInclude(d => d.Account)
            .FirstOrDefaultAsync(q => q.Id == quoteId && q.TenantId == tenantId);

        var template = await GetQuoteTemplateAsync(tenantId);
        return await _pdfGenerator.GenerateAsync(template, quote);
    }
}

21. Campaign Management & Marketing Automation

Campaign management bridges marketing and sales by tracking the effectiveness of marketing efforts. Each campaign tracks members (leads/contacts), their responses (opened, clicked, converted), and ultimately the revenue generated.

Campaign Member Lifecycle

stateDiagram-v2 [*] --> Sent: Campaign Sent Sent --> Opened: Email Opened Opened --> Clicked: Link Clicked Clicked --> Converted: Became MQL Converted --> Won: Closed Deal Sent --> Unsubscribed: Opt-Out Opened --> Unsubscribed: Opt-Out Clicked --> Unsubscribed: Opt-Out Sent --> Bounced: Bounce
C#
public class CampaignService
{
    private readonly CrmDbContext _db;

    public async Task<CampaignReport> GetCampaignReportAsync(
        Guid campaignId, Guid tenantId)
    {
        var members = await _db.CampaignMembers
            .Where(cm => cm.CampaignId == campaignId && cm.TenantId == tenantId)
            .ToListAsync();

        var report = new CampaignReport
        {
            CampaignId = campaignId,
            TotalMembers = members.Count,
            SentCount = members.Count(m => m.Status == "sent"),
            OpenedCount = members.Count(m => m.OpenedAt.HasValue),
            ClickedCount = members.Count(m => m.ClickedAt.HasValue),
            ConvertedCount = members.Count(m => m.ConvertedAt.HasValue),
            UnsubscribedCount = members.Count(m => m.UnsubscribedAt.HasValue),
            BouncedCount = members.Count(m => m.BouncedAt.HasValue),
            OpenRate = members.Count(m => m.OpenedAt.HasValue) * 100m /
                Math.Max(members.Count, 1),
            ClickRate = members.Count(m => m.ClickedAt.HasValue) * 100m /
                Math.Max(members.Count, 1),
            ConversionRate = members.Count(m => m.ConvertedAt.HasValue) * 100m /
                Math.Max(members.Count, 1)
        };

        // Calculate ROI
        var wonDeals = await _db.Deals
            .Where(d =>
                d.TenantId == tenantId
                && d.Stage == "closed_won"
                && members.Select(m => m.ContactId).Contains(d.ContactId))
            .ToListAsync();

        report.TotalRevenue = wonDeals.Sum(d => d.Amount ?? 0);
        var campaignCost = (await _db.Campaigns.FindAsync(campaignId))?.BudgetSpent ?? 0;
        report.ROI = campaignCost > 0
            ? (report.TotalRevenue - campaignCost) / campaignCost * 100
            : 0;

        return report;
    }
}

22. Customer Support Tickets & SLA Management

Support tickets close the loop on the customer relationship. When a customer has an issue, a ticket is created with priority, category, and SLA expectations. The system tracks response time, resolution time, and ensures SLA compliance.

Ticket SLA Matrix

PriorityFirst Response SLAResolution SLAEscalation
Critical (P1)15 minutes4 hoursImmediate page to on-call
High (P2)1 hour8 hoursNotify team lead at 50%
Medium (P3)4 hours24 hoursNotify manager at 80%
Low (P4)24 hours72 hoursWeekly review
C#
public class TicketService
{
    private readonly CrmDbContext _db;
    private readonly ISLAMonitor _slaMonitor;
    private readonly INotificationService _notifications;

    public async Task<SupportTicket> CreateTicketAsync(
        CreateTicketCommand cmd, Guid tenantId)
    {
        var ticket = new SupportTicket
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            Subject = cmd.Subject,
            Description = cmd.Description,
            Priority = cmd.Priority,
            Category = cmd.Category,
            Status = "open",
            ContactId = cmd.ContactId,
            AccountId = cmd.AccountId,
            AssignedToUserId = cmd.AssigneeId,
            CreatedAt = DateTime.UtcNow
        };

        // Calculate SLA deadlines
        var slaConfig = await GetSLAConfigAsync(cmd.Priority, tenantId);
        ticket.FirstResponseDueAt = ticket.CreatedAt.Add(slaConfig.FirstResponseWindow);
        ticket.ResolutionDueAt = ticket.CreatedAt.Add(slaConfig.ResolutionWindow);

        _db.SupportTickets.Add(ticket);
        await _db.SaveChangesAsync();

        // Start SLA monitoring
        await _slaMonitor.StartMonitoringAsync(ticket);

        // Notify assignee
        if (ticket.AssignedToUserId.HasValue)
        {
            await _notifications.SendAsync(new Notification
            {
                UserId = ticket.AssignedToUserId.Value,
                Type = NotificationType.TicketAssigned,
                Title = $"New {cmd.Priority} ticket: {cmd.Subject}",
                EntityId = ticket.Id,
                EntityType = "ticket"
            });
        }

        return ticket;
    }

    public async Task EscalateTicketAsync(Guid ticketId, Guid tenantId)
    {
        var ticket = await _db.SupportTickets
            .FirstOrDefaultAsync(t => t.Id == ticketId && t.TenantId == tenantId);

        var escalationPath = await GetEscalationPathAsync(
            ticket.Priority, ticket.AssignedToUserId, tenantId);

        if (escalationPath != null)
        {
            ticket.AssignedToUserId = escalationPath.UserId;
            ticket.EscalationLevel++;
            ticket.EscalatedAt = DateTime.UtcNow;

            await _notifications.SendAsync(new Notification
            {
                UserId = escalationPath.UserId,
                Type = NotificationType.TicketEscalated,
                Title = $"Ticket escalated to you: {ticket.Subject}",
                EntityId = ticket.Id,
                EntityType = "ticket"
            });

            await _db.SaveChangesAsync();
        }
    }
}

23. Audit Trail & Compliance

Every CRM modification must be traceable. The audit log records who changed what, when, and the before/after values. This is critical for compliance (GDPR, CCPA, SOX), debugging, and dispute resolution.

Audit Log Schema

SQL
CREATE TABLE audit_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    entity_type VARCHAR(100) NOT NULL,
    entity_id UUID NOT NULL,
    action VARCHAR(50) NOT NULL, -- create, update, delete, undelete
    user_id UUID NOT NULL,
    user_name VARCHAR(200),
    user_ip_address INET,
    changed_fields JSONB, -- {"fieldName": {"old": "val1", "new": "val2"}}
    old_values JSONB,
    new_values JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_tenant_entity ON audit_logs(tenant_id, entity_type, entity_id);
CREATE INDEX idx_audit_tenant_time ON audit_logs(tenant_id, created_at DESC);
CREATE INDEX idx_audit_tenant_user ON audit_logs(tenant_id, user_id, created_at DESC);

-- Audit logs are immutable - no UPDATE or DELETE allowed
-- Enforced via PostgreSQL trigger
CREATE OR REPLACE FUNCTION prevent_audit_modification()
RETURNS TRIGGER AS $$
BEGIN
    RAISE EXCEPTION 'Audit logs are immutable and cannot be modified or deleted';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_immutable
    BEFORE UPDATE OR DELETE ON audit_logs
    FOR EACH ROW EXECUTE FUNCTION prevent_audit_modification();

GDPR Compliance

C#
public class GDPRComplianceService
{
    private readonly CrmDbContext _db;
    private readonly IObjectStorage _storage;
    private readonly IAuditLogger _auditLogger;

    public async Task<GDPRDataExport> ExportPersonalDataAsync(
        Guid contactId, Guid tenantId)
    {
        var contact = await _db.Contacts
            .FirstOrDefaultAsync(c => c.Id == contactId && c.TenantId == tenantId);

        var export = new GDPRDataExport
        {
            Contact = contact,
            Activities = await _db.Activities
                .Where(a => a.ContactId == contactId && a.TenantId == tenantId)
                .ToListAsync(),
            Notes = await _db.Notes
                .Where(n => n.ContactId == contactId && n.TenantId == tenantId)
                .ToListAsync(),
            Deals = await _db.Deals
                .Where(d => d.ContactId == contactId && d.TenantId == tenantId)
                .ToListAsync(),
            Documents = await _db.Documents
                .Where(d => d.RelatedObjectType == "contact"
                    && d.RelatedObjectId == contactId
                    && d.TenantId == tenantId)
                .ToListAsync(),
            AuditLogs = await _db.AuditLogs
                .Where(a => a.EntityType == "contact"
                    && a.EntityId == contactId
                    && a.TenantId == tenantId)
                .ToListAsync()
        };

        await _auditLogger.LogAsync(new AuditEntry
        {
            TenantId = tenantId,
            EntityType = "contact",
            EntityId = contactId,
            Action = "gdpr_export",
            UserId = contactId // the data subject
        });

        return export;
    }

    public async Task DeletePersonalDataAsync(Guid contactId, Guid tenantId)
    {
        // GDPR Article 17 - Right to Erasure
        // Anonymize rather than hard delete to preserve referential integrity
        var contact = await _db.Contacts
            .FirstOrDefaultAsync(c => c.Id == contactId && c.TenantId == tenantId);

        if (contact != null)
        {
            contact.FirstName = "[REDACTED]";
            contact.LastName = "[REDACTED]";
            contact.Email = $"redacted-{contact.Id}@deleted.invalid";
            contact.Phone = null;
            contact.Mobile = null;
            contact.CustomFields = new Dictionary<string, object>();
            contact.Tags = Array.Empty<string>();
            contact.IsDeleted = true;
            contact.DeletedAt = DateTime.UtcNow;
            contact.DeletionReason = "GDPR erasure request";
        }

        // Delete uploaded documents
        var documents = await _db.Documents
            .Where(d => d.RelatedObjectType == "contact"
                && d.RelacedObjectId == contactId
                && d.TenantId == tenantId)
            .ToListAsync();

        foreach (var doc in documents)
        {
            await _storage.DeleteAsync(doc.StorageKey);
        }

        await _db.SaveChangesAsync();

        await _auditLogger.LogAsync(new AuditEntry
        {
            TenantId = tenantId,
            EntityType = "contact",
            EntityId = contactId,
            Action = "gdpr_deletion",
            Description = "Personal data anonymized per GDPR erasure request"
        });
    }
}
Compliance Requirement: GDPR requires responding to data subject requests within 30 days. The system must have an automated workflow that notifies the data protection officer, tracks the request timeline, and executes the export/deletion within the deadline.

24. Duplicate Detection & Merging

Duplicate records are the cancer of CRM data quality. The dedup system runs both in real-time (on record create/update) and as a batch job (weekly scan). When duplicates are found, users can merge them through a guided UI that previews the merge result.

Merge Strategy

C#
public class DuplicateMergeService
{
    private readonly CrmDbContext _db;
    private readonly IEventBus _eventBus;

    public async Task<Contact> MergeContactsAsync(
        Guid primaryContactId,
        List<Guid> duplicateIds,
        Dictionary<string, MergeChoice> fieldChoices,
        Guid tenantId, Guid userId)
    {
        var primary = await _db.Contacts.FindAsync(primaryContactId);
        var duplicates = await _db.Contacts
            .Where(c => duplicateIds.Contains(c.Id) && c.TenantId == tenantId)
            .ToListAsync();

        // Apply user's field-level merge choices
        foreach (var choice in fieldChoices)
        {
            switch (choice.Value)
            {
                case MergeChoice.KeepPrimary:
                    // No change needed
                    break;
                case MergeChoice.UseMostRecent:
                    var mostRecent = duplicates
                        .OrderByDescending(c => c.UpdatedAt)
                        .First();
                    SetFieldValue(primary, choice.Key,
                        GetFieldValue(mostRecent, choice.Key));
                    break;
                case MergeChoice.UseNonEmpty:
                    var nonEmpty = duplicates
                        .FirstOrDefault(c => !string.IsNullOrEmpty(
                            GetFieldValue(c, choice.Key)?.ToString()));
                    if (nonEmpty != null)
                        SetFieldValue(primary, choice.Key,
                            GetFieldValue(nonEmpty, choice.Key));
                    break;
                case MergeChoice.UseDuplicate:
                    var dupContact = duplicates
                        .First(c => c.Id == fieldChoices[$"{choice.Key}_sourceId"].Equals(c.Id));
                    SetFieldValue(primary, choice.Key,
                        GetFieldValue(dupContact, choice.Key));
                    break;
            }
        }

        // Re-parent all activities from duplicates to primary
        foreach (var dup in duplicates)
        {
            await _db.Activities
                .Where(a => a.ContactId == dup.Id && a.TenantId == tenantId)
                .ExecuteUpdateAsync(s => s
                    .SetProperty(a => a.ContactId, primaryContactId));

            await _db.Deals
                .Where(d => d.ContactId == dup.Id && d.TenantId == tenantId)
                .ExecuteUpdateAsync(s => s
                    .SetProperty(d => d.ContactId, primaryContactId));

            await _db.Notes
                .Where(n => n.ContactId == dup.Id && n.TenantId == tenantId)
                .ExecuteUpdateAsync(s => s
                    .SetProperty(n => n.ContactId, primaryContactId));

            // Soft delete the duplicate
            dup.IsDeleted = true;
            dup.DeletedAt = DateTime.UtcNow;
            dup.DeletionReason = $"Merged into contact {primaryContactId}";
        }

        primary.UpdatedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();

        await _eventBus.PublishAsync(new ContactMergedEvent
        {
            PrimaryContactId = primaryContactId,
            MergedContactIds = duplicateIds,
            TenantId = tenantId
        });

        return primary;
    }
}

25. Data Import/Export

Data import is critical for initial CRM setup and ongoing data enrichment from external sources. The system supports CSV/Excel imports with field mapping, validation, dedup checks, and progress tracking for large files.

Import Pipeline

graph LR UPLOAD["Upload CSV/Excel
Max 100MB"] PARSE["Parse & Validate
Schema Check"] MAP["Field Mapping
Auto-detect + Manual"] DEDUP["Duplicate
Detection"] VALIDATE["Row Validation
Required Fields"] TRANSFORM["Data Transform
Normalize, Enrich"] EXECUTE["Batch Insert/Update
1000 rows/batch"] REPORT["Import Report
Success/Failure"]
C#
public class DataImportService
{
    private readonly CrmDbContext _db;
    private readonly IEventBus _eventBus;
    private readonly IBlobStorage _storage;

    public async Task<ImportJob> StartImportAsync(
        StartImportCommand cmd, Guid tenantId, Guid userId)
    {
        var job = new ImportJob
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            ObjectType = cmd.ObjectType,
            FileName = cmd.FileName,
            TotalRows = cmd.RowCount,
            ProcessedRows = 0,
            SuccessRows = 0,
            FailedRows = 0,
            Status = "pending",
            FieldMappings = cmd.FieldMappings,
            DuplicateStrategy = cmd.DuplicateStrategy, // skip, update, create
            CreatedByUserId = userId,
            CreatedAt = DateTime.UtcNow
        };

        _db.ImportJobs.Add(job);
        await _db.SaveChangesAsync();

        // Upload file to storage
        var storageKey = $"imports/{tenantId}/{job.Id}/{cmd.FileName}";
        await _storage.UploadAsync(storageKey, cmd.FileStream);

        // Queue processing
        await _eventBus.PublishAsync(new ImportJobQueuedEvent
        {
            ImportJobId = job.Id,
            TenantId = tenantId,
            StorageKey = storageKey
        });

        return job;
    }
}

26. API & Webhooks Design

The CRM API is the integration backbone. It follows RESTful conventions with consistent resource naming, pagination, filtering, and bulk operations. Webhooks enable real-time event notifications to external systems.

REST API Endpoints

MethodEndpointDescription
GET/api/v1/contactsList contacts with pagination, filtering, sorting
POST/api/v1/contactsCreate a new contact
GET/api/v1/contacts/:idGet a single contact by ID
PATCH/api/v1/contacts/:idUpdate specific fields on a contact
DELETE/api/v1/contacts/:idSoft-delete a contact
POST/api/v1/contacts/bulkBulk create/update up to 1000 contacts
GET/api/v1/dealsList deals with pipeline stage filter
PATCH/api/v1/deals/:id/stageAdvance or move deal to new stage
GET/api/v1/reports/:id/executeExecute a saved report
POST/api/v1/webhooksRegister a webhook subscription

Webhook System

C#
public class WebhookService
{
    private readonly CrmDbContext _db;
    private readonly IHttpClientFactory _httpClientFactory;

    public async Task ProcessEventAsync(CrmEvent crmEvent, Guid tenantId)
    {
        var subscriptions = await _db.WebhookSubscriptions
            .Where(ws =>
                ws.TenantId == tenantId
                && ws.IsActive
                && ws.EventTypes.Contains(crmEvent.EventType))
            .ToListAsync();

        foreach (var subscription in subscriptions)
        {
            var payload = new WebhookPayload
            {
                EventId = crmEvent.Id,
                EventType = crmEvent.EventType,
                Timestamp = DateTime.UtcNow,
                Data = crmEvent.Payload,
                TenantId = tenantId
            };

            var signature = ComputeHmacSignature(
                JsonSerializer.Serialize(payload),
                subscription.SecretKey);

            var retryPolicy = Policy
                .Handle<HttpRequestException>()
                .OrResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
                .WaitAndRetryAsync(3, attempt =>
                    TimeSpan.FromSeconds(Math.Pow(2, attempt)));

            var client = _httpClientFactory.CreateClient();
            client.DefaultRequestHeaders.Add("X-Webhook-Signature", signature);
            client.DefaultRequestHeaders.Add("X-Webhook-Event", crmEvent.EventType);

            var response = await retryPolicy.ExecuteAsync(() =>
                client.PostAsJsonAsync(subscription.Url, payload));

            _db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog
            {
                SubscriptionId = subscription.Id,
                EventId = crmEvent.Id,
                Url = subscription.Url,
                StatusCode = (int)response.StatusCode,
                ResponseBody = await response.Content.ReadAsStringAsync(),
                DeliveredAt = DateTime.UtcNow
            });
        }

        await _db.SaveChangesAsync();
    }

    private string ComputeHmacSignature(string payload, string secretKey)
    {
        var keyBytes = Encoding.UTF8.GetBytes(secretKey);
        var payloadBytes = Encoding.UTF8.GetBytes(payload);
        using var hmac = new HMACSHA256(keyBytes);
        var hash = hmac.ComputeHash(payloadBytes);
        return Convert.ToBase64String(hash);
    }
}

27. Third-Party Integrations Marketplace

The integrations marketplace allows partners to build and publish connectors that extend the CRM's functionality. This includes Slack notifications, Zoom meeting sync, Stripe payment integration, DocuSign e-signatures, and hundreds more.

Integration Architecture

graph TB subgraph Marketplace["Integration Marketplace"] LISTING["Integration Listing
App Store UI"] INSTALL["Install/Uninstall
Flow"] CONFIG["Configuration
OAuth + Settings"] end subgraph Runtime["Integration Runtime"] OAUTH_PROXY["OAuth Proxy
Token Management"] WEBHOOK_RELAY["Webhook Relay
Event Routing"] CONNECTOR_SDK["Connector SDK
REST + GraphQL"] end subgraph ThirdParty["Third-Party Services"] SLACK["Slack"] ZOOM["Zoom"] STRIPE["Stripe"] HUBSPOT["HubSpot"] ZOHO["Zoho"] end LISTING --> INSTALL INSTALL --> CONFIG CONFIG --> OAUTH_PROXY OAUTH_PROXY --> SLACK OAUTH_PROXY --> ZOOM OAUTH_PROXY --> STRIPE WEBHOOK_RELAY --> HUBSPOT WEBHOOK_RELAY --> ZOHO CONNECTOR_SDK --> SLACK CONNECTOR_SDK --> ZOOM
C#
public class IntegrationService
{
    private readonly CrmDbContext _db;
    private readonly IOAuthProxyService _oauthProxy;

    public async Task<Integration> InstallIntegrationAsync(
        InstallIntegrationCommand cmd, Guid tenantId, Guid userId)
    {
        var integrationDef = await _db.IntegrationDefinitions
            .FirstOrDefaultAsync(d => d.Id == cmd.IntegrationDefId)
            ?? throw new NotFoundException("Integration not found");

        // Start OAuth flow
        var authUrl = _oauthProxy.BuildAuthorizationUrl(
            integrationDef.OAuthConfig,
            state: $"{tenantId}:{userId}:{cmd.IntegrationDefId}");

        var integration = new Integration
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            IntegrationDefId = cmd.IntegrationDefId,
            Status = "pending_auth",
            InstalledByUserId = userId,
            Config = cmd.Config ?? new Dictionary<string, string>(),
            InstalledAt = DateTime.UtcNow
        };

        _db.Integrations.Add(integration);
        await _db.SaveChangesAsync();

        return integration;
    }

    public async Task SyncIntegrationDataAsync(Guid integrationId, Guid tenantId)
    {
        var integration = await _db.Integrations
            .FirstOrDefaultAsync(i => i.Id == integrationId && i.TenantId == tenantId);

        var def = await _db.IntegrationDefinitions.FindAsync(integration.IntegrationDefId);

        // Refresh OAuth token if expired
        if (integration.TokenExpiresAt < DateTime.UtcNow.AddMinutes(5))
        {
            var newToken = await _oauthProxy.RefreshTokenAsync(
                def.OAuthConfig, integration.RefreshToken);
            integration.AccessToken = newToken.AccessToken;
            integration.TokenExpiresAt = newToken.ExpiresAt;
        }

        // Execute the integration's sync script
        var syncHandler = GetSyncHandler(def.HandlerType);
        await syncHandler.SyncAsync(integration, tenantId);

        integration.LastSyncedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();
    }
}

28. Mobile App Considerations

CRM mobile apps must work offline, sync intelligently, and handle poor connectivity. Sales reps use mobile CRMs in the field — at client offices, airports, and coffee shops. The mobile experience must be fast and reliable.

Mobile Sync Strategy

FeatureStrategySync Frequency
ContactsFull sync with incremental deltaEvery 5 minutes
DealsUser's own deals + team dealsEvery 2 minutes
ActivitiesLast 30 days + scheduledEvery 5 minutes
Offline QueueOptimistic writes with conflict resolutionOn connectivity restore
PhotosOn-demand with cacheManual
C#
// Shared model used by both Web API and mobile clients
public class OfflineSyncService
{
    private readonly LocalDatabase _localDb;
    private readonly ApiClient _apiClient;
    private readonly ConnectivityMonitor _connectivity;

    public async Task<SyncResult> SyncAsync()
    {
        if (!_connectivity.IsOnline)
            return new SyncResult { Status = "offline" };

        var result = new SyncResult();

        // Step 1: Push local changes
        var pendingChanges = await _localDb.GetPendingChangesAsync();
        foreach (var change in pendingChanges)
        {
            try
            {
                var serverResult = await _apiClient.PushChangeAsync(change);
                await _localDb.MarkSyncedAsync(change.Id, serverResult.Version);
                result.PushedCount++;
            }
            catch (ConflictException ex)
            {
                // Last-write-wins or field-level merge
                var merged = await MergeConflictAsync(change, ex.ServerRecord);
                await _localDb.UpdateAsync(merged);
                result.ConflictsResolved++;
            }
        }

        // Step 2: Pull remote changes since last sync
        var lastSyncTimestamp = await _localDb.GetLastSyncTimestampAsync();
        var remoteChanges = await _apiClient.GetChangesSinceAsync(lastSyncTimestamp);

        foreach (var change in remoteChanges)
        {
            await _localDb.UpsertAsync(change);
            result.PulledCount++;
        }

        await _localDb.SetLastSyncTimestampAsync(DateTime.UtcNow);
        result.Status = "success";
        return result;
    }
}

29. Monitoring, Security & Compliance

A production CRM requires comprehensive observability, security hardening, and compliance controls. The monitoring stack covers application metrics, infrastructure health, data quality, and business KPIs.

Key Metrics to Monitor

CategoryMetricAlert Threshold
API Performancep99 response time> 500ms for 5 minutes
API PerformanceError rate (5xx)> 0.1% over 5 minutes
DatabaseConnection pool utilization> 80%
DatabaseSlow query count (> 1s)> 10 per minute
SearchElasticsearch query latency> 200ms p95
Email SyncSync lag (minutes behind Gmail)> 10 minutes
WorkflowQueue depth> 10,000 pending actions
BusinessData import failures> 5% failure rate
SecurityFailed auth attempts> 100 in 5 minutes (potential brute force)

Security Architecture

C#
// Encryption at rest and in transit
public class SecurityConfiguration
{
    // All PII fields are encrypted with tenant-specific keys
    public static readonly string[] EncryptedFields = new[]
    {
        "contacts.email",
        "contacts.phone",
        "contacts.mobile",
        "accounts.billing_address",
        "custom_fields"
    };

    // API authentication: OAuth 2.0 with PKCE for web,
    // API keys for integrations
    public static readonly AuthenticationConfig AuthConfig = new()
    {
        AccessTokenExpiry = TimeSpan.FromHours(1),
        RefreshTokenExpiry = TimeSpan.FromDays(30),
        MaxFailedAttempts = 5,
        LockoutDuration = TimeSpan.FromMinutes(15),
        RequireMFA = true,
        AllowedOrigins = new[] { "https://app.yourcrm.com" }
    };

    // Rate limiting per tenant per endpoint category
    public static readonly RateLimitConfig RateLimits = new()
    {
        ReadEndpoints = new RateLimit { Requests = 1000, WindowSeconds = 60 },
        WriteEndpoints = new RateLimit { Requests = 200, WindowSeconds = 60 },
        SearchEndpoints = new RateLimit { Requests = 100, WindowSeconds = 60 },
        BulkEndpoints = new RateLimit { Requests = 10, WindowSeconds = 60 },
        FileUploadEndpoints = new RateLimit { Requests = 50, WindowSeconds = 60 }
    };
}

Compliance Matrix

RegulationRequirementImplementation
GDPRRight to erasureAnonymization API + 30-day SLA tracking
GDPRData portabilityJSON/CSV export of all personal data
GDPRConsent managementConsent records on contacts, opt-in/opt-out tracking
CCPADo Not SellFlag to prevent data sharing with third parties
SOC 2Access loggingFull audit trail with tamper-proof storage
SOC 2Encryption at restAES-256 for all PII fields
SOC 2Encryption in transitTLS 1.3 for all connections
HIPAABAA with cloud providerAWS/GCP BAA for healthcare tenants

30. Cost Estimation

Estimating the infrastructure cost for a CRM platform at various scales helps in capacity planning and business model validation. Below are estimates for a multi-tenant SaaS CRM serving different customer tiers.

TierTenantsContactsAPI RPMComputeDatabaseStorageMonthly Cost
Startup50500K total5K2× c5.xlarge1× RDS db.r5.large500 GB S3~$2,500
Growth50010M total50K8× c5.2xlarge3× RDS db.r5.2xlarge5 TB S3~$15,000
Enterprise5,000500M total500K30× c5.4xlargeAurora Serverless v250 TB S3~$120,000
Salesforce Scale150K+Billions5M+Custom k8s clustersSharded PostgreSQLPB-scale~$5M+/month

Cost Breakdown

Component% of TotalNotes
Compute (API + Workers)35%Scales linearly with API traffic
Database (PostgreSQL)30%Scales with data volume + query complexity
Search (Elasticsearch)15%Scales with index size + query volume
Object Storage (S3)5%File attachments, documents
Message Queue (Kafka)8%Event streaming + async processing
CDN + Monitoring7%CloudFront, Datadog, PagerDuty
Cost Optimization: The biggest cost savings come from (1) caching frequently-accessed records in Redis (reduces DB load by 60%), (2) using read replicas for reporting queries (keeps the primary for writes), and (3) cold storage for audit logs older than 1 year (S3 Glacier at $0.004/GB/month vs. $0.10/GB for hot storage).

31. Testing Strategy

CRM systems require thorough testing because bugs directly impact revenue — a broken deal pipeline or incorrect forecast can cost millions. The testing strategy spans unit tests, integration tests, end-to-end tests, and data quality tests.

Test Pyramid

graph TB E2E["E2E Tests
Playwright / Selenium
~50 tests
Slow, High Confidence"] API["API Integration Tests
xUnit + Testcontainers
~500 tests
Medium Speed"] UNIT["Unit Tests
xUnit + Moq
~2000 tests
Fast, Isolated"]

Key Test Scenarios

AreaTest CaseType
ContactsCreate contact with required fieldsUnit
ContactsDuplicate detection with fuzzy matchingUnit + Integration
DealsStage transition with probability updateUnit
DealsForecast calculation with roll-upIntegration
RBACRecord sharing across role hierarchyIntegration
WorkflowsRule trigger with condition evaluationUnit
WorkflowsRecursion prevention on self-updating workflowsIntegration
EmailTracking pixel open recordingIntegration
ImportCSV import with validation errorsIntegration
Multi-tenantCross-tenant data isolationIntegration
E2EFull sales cycle: lead → deal wonE2E
C#
[Trait("Category", "Integration")]
public class DealPipelineTests : IClassFixture<CrmDatabaseFixture>
{
    private readonly CrmDatabaseFixture _fixture;

    public DealPipelineTests(CrmDatabaseFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task AdvanceDealStage_UpdatesProbabilityAndForecastCategory()
    {
        // Arrange
        var tenant = await _fixture.CreateTenantAsync();
        var user = await _fixture.CreateUserAsync(tenant.Id);
        var account = await _fixture.CreateAccountAsync(tenant.Id);
        var deal = await _fixture.CreateDealAsync(tenant.Id, account.Id, user.Id);

        var service = new DealService(
            _fixture.CreateContext(),
            new FakeEventBus(),
            new FakeForecastEngine());

        // Act
        var updated = await service.AdvanceStageAsync(
            deal.Id, "negotiation", tenant.Id, user.Id);

        // Assert
        Assert.Equal("negotiation", updated.Stage);
        Assert.Equal(75m, updated.Probability);
        Assert.Equal("commit", updated.ForecastCategory);

        // Verify stage history was recorded
        var history = await _fixture.CreateContext().DealStageHistories
            .FirstOrDefaultAsync(h => h.DealId == deal.Id);
        Assert.NotNull(history);
        Assert.Equal("prospecting", history.FromStage);
        Assert.Equal("negotiation", history.ToStage);
    }

    [Fact]
    public async Task CrossTenantDataIsolation_PreventsDataLeakage()
    {
        // Arrange
        var tenantA = await _fixture.CreateTenantAsync();
        var tenantB = await _fixture.CreateTenantAsync();
        var contactA = await _fixture.CreateContactAsync(tenantA.Id, "secret@tenant-a.com");
        var userB = await _fixture.CreateUserAsync(tenantB.Id);

        // Act
        var service = new ContactService(_fixture.CreateContext());
        var result = await service.GetContactAsync(contactA.Id, tenantB.Id);

        // Assert - user from tenant B should NOT see tenant A's contact
        Assert.Null(result);
    }
}

32. Interview Q&A Deep Dive

Q1: How would you handle the multi-tenancy data isolation?

Answer: I'd use PostgreSQL Row-Level Security (RLS). Every table has a tenant_id column. An RLS policy filters all queries by the current tenant ID, which is set as a session variable when the connection is established. This provides defense-in-depth — even if application code has a bug, the database layer prevents cross-tenant data access. For extra isolation, high-value tenants can get dedicated database instances via connection routing.

Q2: How would you design the custom objects system?

Answer: I'd use a metadata-driven EAV (Entity-Attribute-Value) approach with PostgreSQL JSONB. Custom field definitions are stored in a metadata table. Custom object records are stored in a single table with a JSONB field_values column. This avoids DDL operations per tenant and allows schema flexibility. For performance-critical custom fields, we can create materialized views or generated columns. The trade-off is query complexity — but for CRM workloads (mostly CRUD + search), JSONB is well-suited.

Q3: How would you prevent workflow automation recursion?

Answer: I'd use a depth counter in a thread-local/async-local context. When a workflow action updates a record, it increments the depth counter. If the counter exceeds a configurable limit (typically 5), the workflow engine stops processing. Additionally, I'd implement a "workflow context" object that tracks which rules have already executed for the current event chain, preventing the same rule from firing twice.

Q4: How would you handle email sync at scale?

Answer: For Gmail, I'd use the Pub/Sub push notification API — no polling. When a new email arrives, Gmail notifies a Cloud Pub/Sub topic, which triggers a webhook to the CRM's sync worker. The worker fetches the email via the Gmail API, matches recipients to contacts, and creates an activity record. For Outlook, I'd use Microsoft Graph's subscription API with similar push-based architecture. Both approaches reduce API calls by 95% compared to polling.

Q5: How would you handle the data model for deals with products and pricing?

Answer: I'd model it with a Product Catalog (products table), Price Books (price_books + price_book_entries), and a junction table (deal_products) that stores the specific price, quantity, and discount for each line item on a deal. The deal_products table denormalizes the total price for fast reporting. When a deal closes, the snapshot of pricing is preserved in deal_products even if the product price changes later.

Q6: How would you implement lead scoring with ML?

Answer: I'd build a feature pipeline that extracts demographic, firmographic, behavioral, and engagement features from the CRM data. The ML model (gradient boosting classifier) is trained on historical conversion data. The model runs as a batch job nightly to score all leads, with real-time scoring for new leads on creation. Features include: company size, industry match, email engagement rate, page visit frequency, form fills, and days since last activity. The model outputs a 0-100 score and a conversion probability.

Q7: How would you handle territory-based record assignment?

Answer: Territories are hierarchical and have rules (e.g., "industry = technology AND company_size = enterprise → North America Enterprise Territory"). When a record is created or its key fields change, the system evaluates territory rules in hierarchy order and assigns the first matching territory. The territory's assigned user becomes the record owner. The RecordSharingService applies row-level filters based on the user's role hierarchy and territory assignments.

Q8: How would you design the audit trail to be immutable?

Answer: I'd use a PostgreSQL trigger that prevents UPDATE and DELETE operations on the audit_logs table. Audit entries are append-only. For extra security, I'd store a hash chain — each audit entry includes a hash of the previous entry, making tampering detectable. For compliance, audit logs are replicated to a separate, append-only storage (like S3 with Object Lock) within seconds of creation.

Q9: How would you handle the dashboard builder at scale?

Answer: Dashboard data is pre-computed and cached. Each widget's report runs against the data warehouse (not the transactional DB) on a scheduled basis (every 5-15 minutes depending on widget). Results are cached in Redis with the widget ID as key. The dashboard page loads instantly by reading from cache. For real-time widgets (like "deals closing today"), we use materialized views that refresh every minute. The dashboard builder UI allows drag-and-drop widget placement with grid-based layout.

Q10: How would you handle document versioning?

Answer: Each document upload creates a new version record. The version field increments. Old versions are kept in object storage with a versioned key. The UI shows the current version by default with a version history panel. Only the current version is shown in search results. Old versions can be restored. Storage costs are managed by moving versions older than 90 days to cold storage (S3 Glacier).

Pre-Interview Checklist

  • Understand multi-tenancy patterns: shared DB vs. separate DB vs. hybrid
  • Know JSONB advantages and limitations for custom fields
  • Design a Kanban pipeline with stage transitions and probability weighting
  • Understand email sync: OAuth flows, push notifications vs. polling, tracking pixels
  • Know RBAC with role hierarchy, record sharing, and territory management
  • Explain workflow automation with recursion prevention
  • Discuss lead scoring: rule-based + ML hybrid approach
  • Understand GDPR/CCPA compliance requirements for CRM data
  • Know how to handle duplicate detection with fuzzy matching
  • Explain audit trail immutability and hash chain verification
  • Discuss webhook design with HMAC signatures and retry policies
  • Understand offline sync strategies for mobile CRM apps

CRM System Design — Senior+ Guide | Ayodhyya