How to Design a CRM System
Building a Production-Grade Customer Relationship Management Platform — Salesforce/HubSpot at Scale
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.
2. Functional & Non-Functional Requirements
Core Functional Requirements
| Module | Key Features |
|---|---|
| Contact Management | CRUD contacts, merge duplicates, import/export, tagging, custom fields |
| Account/Company | Account hierarchies, parent-child, subsidiaries, account scoring |
| Deal Pipeline | Kanban stages, probability-weighted forecasting, lost/won reasons, products on deals |
| Activities | Log calls, emails, meetings with timestamps, duration, outcomes |
| Email Integration | Gmail/Outlook 2-way sync, open/click tracking, email templates |
| Tasks & Reminders | Create tasks with due dates, auto-reminders, task lists by owner |
| Custom Fields/Objects | User-defined schema, field types, validation rules, relationships |
| Workflow Automation | If-this-then-that rules, field updates, email sends, task creation, API calls |
| Lead Scoring | Rule-based and ML-based scoring, fit + engagement signals |
| Reporting & Dashboards | Custom reports, scheduled delivery, dashboard widgets, drill-down |
| RBAC | Roles, profiles, permission sets, field-level security, record sharing |
| Support Tickets | Case creation, SLA tracking, escalation, knowledge base |
| Marketing Campaigns | Campaign creation, member tracking, A/B testing, ROI reporting |
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Availability | 99.95% uptime (4.38 hours downtime/year) |
| Latency (p99) | List views < 500ms, search < 200ms, API < 200ms |
| Throughput | 50,000 API requests/second at peak |
| Data Durability | 99.999999999% (11 nines) via cross-region replication |
| Tenant Isolation | Strict data isolation — no cross-tenant data leakage ever |
| Customization | Each tenant can define custom objects, fields, workflows, and UI |
| Search | Full-text search across all objects with sub-second response |
| Data Retention | Minimum 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.
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.
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);
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
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
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;
}
}
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
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 Category | Stages | Weighting |
|---|---|---|
| Pipeline | Prospecting, Discovery | Amount × Probability % |
| Best Case | Qualification, Proposal | Amount × Probability % |
| Commit | Negotiation | Amount × Probability % |
| Closed Won | Closed Won | Amount × 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
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();
}
}
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 Type | Storage | Max Length | Example |
|---|---|---|---|
| Text | VARCHAR via JSONB | 255 chars | External ID, Reference Number |
| Number | NUMERIC via JSONB | 18 digits | Quantity, Score |
| Currency | NUMERIC via JSONB | 15,2 | Deal Amount, Invoice Total |
| Date | ISO string via JSONB | — | Contract Start Date |
| Picklist | String via JSONB | 255 chars | Status, Priority, Region |
| Multi-Select Picklist | Array via JSONB | — | Tags, Categories |
| Boolean | Boolean via JSONB | — | Is VIP, Requires Approval |
| Lookup | UUID via JSONB | — | Related Account, Parent Record |
| Formula | Computed at read time | — | ROI = Revenue / Cost |
| URL | String via JSONB | 2048 chars | Website, Documentation Link |
| String via JSONB | 255 chars | Alternate Email | |
| Phone | String via JSONB | 50 chars | Fax, 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;
}
}
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
| Component | Signals | Weight Source |
|---|---|---|
| Fit Score | Company size, industry, title seniority, technology stack | Rule-based (admin configured) |
| Engagement Score | Email opens, page visits, form fills, content downloads | ML model (logistic regression) |
| Behavioral Score | Recency of last activity, frequency of interactions, trend direction | ML model (gradient boosting) |
| Intent Score | Pricing page visits, competitor comparison pages, demo request | Rule-based + ML hybrid |
ML Scoring Pipeline
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
| Widget | Data Source | Visualization | Use Case |
|---|---|---|---|
| KPI Card | Single aggregate | Big number + trend | Total Pipeline, Won Revenue |
| Bar Chart | Grouped aggregation | Horizontal/Vertical bars | Deals by Stage, Revenue by Rep |
| Line Chart | Time series | Line with markers | Monthly Revenue Trend |
| Pie/Donut | Categorical split | Circle segments | Deals by Industry, Lead Source |
| Funnel | Pipeline stages | Funnel shape | Lead → Opportunity Conversion |
| Table | Tabular report | Sortable table | Top Deals, Overdue Tasks |
| Heatmap | Matrix aggregation | Color-coded grid | Activity 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
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
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
| Priority | First Response SLA | Resolution SLA | Escalation |
|---|---|---|---|
| Critical (P1) | 15 minutes | 4 hours | Immediate page to on-call |
| High (P2) | 1 hour | 8 hours | Notify team lead at 50% |
| Medium (P3) | 4 hours | 24 hours | Notify manager at 80% |
| Low (P4) | 24 hours | 72 hours | Weekly 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"
});
}
}
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
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
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/contacts | List contacts with pagination, filtering, sorting |
POST | /api/v1/contacts | Create a new contact |
GET | /api/v1/contacts/:id | Get a single contact by ID |
PATCH | /api/v1/contacts/:id | Update specific fields on a contact |
DELETE | /api/v1/contacts/:id | Soft-delete a contact |
POST | /api/v1/contacts/bulk | Bulk create/update up to 1000 contacts |
GET | /api/v1/deals | List deals with pipeline stage filter |
PATCH | /api/v1/deals/:id/stage | Advance or move deal to new stage |
GET | /api/v1/reports/:id/execute | Execute a saved report |
POST | /api/v1/webhooks | Register 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
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
| Feature | Strategy | Sync Frequency |
|---|---|---|
| Contacts | Full sync with incremental delta | Every 5 minutes |
| Deals | User's own deals + team deals | Every 2 minutes |
| Activities | Last 30 days + scheduled | Every 5 minutes |
| Offline Queue | Optimistic writes with conflict resolution | On connectivity restore |
| Photos | On-demand with cache | Manual |
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
| Category | Metric | Alert Threshold |
|---|---|---|
| API Performance | p99 response time | > 500ms for 5 minutes |
| API Performance | Error rate (5xx) | > 0.1% over 5 minutes |
| Database | Connection pool utilization | > 80% |
| Database | Slow query count (> 1s) | > 10 per minute |
| Search | Elasticsearch query latency | > 200ms p95 |
| Email Sync | Sync lag (minutes behind Gmail) | > 10 minutes |
| Workflow | Queue depth | > 10,000 pending actions |
| Business | Data import failures | > 5% failure rate |
| Security | Failed 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
| Regulation | Requirement | Implementation |
|---|---|---|
| GDPR | Right to erasure | Anonymization API + 30-day SLA tracking |
| GDPR | Data portability | JSON/CSV export of all personal data |
| GDPR | Consent management | Consent records on contacts, opt-in/opt-out tracking |
| CCPA | Do Not Sell | Flag to prevent data sharing with third parties |
| SOC 2 | Access logging | Full audit trail with tamper-proof storage |
| SOC 2 | Encryption at rest | AES-256 for all PII fields |
| SOC 2 | Encryption in transit | TLS 1.3 for all connections |
| HIPAA | BAA with cloud provider | AWS/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.
| Tier | Tenants | Contacts | API RPM | Compute | Database | Storage | Monthly Cost |
|---|---|---|---|---|---|---|---|
| Startup | 50 | 500K total | 5K | 2× c5.xlarge | 1× RDS db.r5.large | 500 GB S3 | ~$2,500 |
| Growth | 500 | 10M total | 50K | 8× c5.2xlarge | 3× RDS db.r5.2xlarge | 5 TB S3 | ~$15,000 |
| Enterprise | 5,000 | 500M total | 500K | 30× c5.4xlarge | Aurora Serverless v2 | 50 TB S3 | ~$120,000 |
| Salesforce Scale | 150K+ | Billions | 5M+ | Custom k8s clusters | Sharded PostgreSQL | PB-scale | ~$5M+/month |
Cost Breakdown
| Component | % of Total | Notes |
|---|---|---|
| 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 + Monitoring | 7% | CloudFront, Datadog, PagerDuty |
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
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
| Area | Test Case | Type |
|---|---|---|
| Contacts | Create contact with required fields | Unit |
| Contacts | Duplicate detection with fuzzy matching | Unit + Integration |
| Deals | Stage transition with probability update | Unit |
| Deals | Forecast calculation with roll-up | Integration |
| RBAC | Record sharing across role hierarchy | Integration |
| Workflows | Rule trigger with condition evaluation | Unit |
| Workflows | Recursion prevention on self-updating workflows | Integration |
| Tracking pixel open recording | Integration | |
| Import | CSV import with validation errors | Integration |
| Multi-tenant | Cross-tenant data isolation | Integration |
| E2E | Full sales cycle: lead → deal won | E2E |
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?
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?
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?
Q4: How would you handle email sync at scale?
Q5: How would you handle the data model for deals with products and pricing?
Q6: How would you implement lead scoring with ML?
Q7: How would you handle territory-based record assignment?
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?
Q9: How would you handle the dashboard builder at scale?
Q10: How would you handle document versioning?
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