How to Design a Unified CRM Platform like HubSpot — A Senior+ Guide
Building marketing, sales, service, and CMS hubs with AI-powered CRM for 200K+ customers
1. Introduction — What Is HubSpot and Why It Matters
HubSpot is one of the world's leading customer relationship management platforms, serving over 200,000 customers across more than 120 countries. Founded in 2006 by Brian Halligan and Dharmesh Shah at MIT, HubSpot pioneered the concept of "inbound marketing" — the idea that businesses should attract customers through valuable content rather than interruptive advertising. Today, HubSpot has evolved far beyond its marketing origins into a comprehensive, unified CRM platform that encompasses marketing, sales, customer service, content management, and operations — all powered by artificial intelligence.
The platform operates on a freemium model that has been instrumental in its explosive growth. The free CRM tier allows startups and small businesses to manage contacts, track deals, and monitor customer interactions without paying a single dollar. This strategy creates a massive top-of-funnel pipeline where users experience the platform's value and naturally upgrade to paid tiers — Starter, Professional, and Enterprise — as their businesses scale. HubSpot reported over $2.1 billion in annual revenue for 2025, with a customer base that continues to grow at roughly 20 percent year over year.
At its core, HubSpot is organized into five interconnected hubs. The Marketing Hub provides tools for email marketing, social media management, landing pages, forms, advertising, and campaign analytics. The Sales Hub offers deal pipeline management, email tracking, meeting scheduling, sequences (automated email follow-ups), and sales analytics. The Service Hub delivers ticketing systems, knowledge bases, customer feedback surveys, live chat, and customer portals. The CMS Hub provides a drag-and-drop website builder with smart content, SEO recommendations, and serverless functions. Finally, the Operations Hub syncs data across systems, automates business processes, and provides data quality tools.
What makes HubSpot's architecture particularly interesting from a system design perspective is how these five hubs share a unified CRM backbone. Unlike competitors such as Salesforce (which historically required extensive customization through Apex and Visualforce) or Zoho (which often feels like separate products stitched together), HubSpot was designed from the ground up as a single platform. Every interaction — whether it is a marketing email opened, a sales call logged, a support ticket resolved, or a webpage viewed — feeds into the same contact record. This creates a 360-degree view of the customer that powers all five hubs simultaneously.
HubSpot's AI assistant, known as ChatSpot (now integrated as AI-powered tools across all hubs), leverages large language models to help users generate content, summarize conversations, score leads predictively, forecast revenue, and automate routine tasks. The AI layer sits on top of the unified data model, meaning it has access to the full context of every customer relationship. This gives HubSpot a significant advantage over point solutions that only see a slice of customer data.
Building a platform of this scale and complexity requires careful attention to multi-tenancy, data isolation, extensibility, performance, and reliability. In this system design walkthrough, we will dissect every major component of HubSpot's architecture — from the contact management core to the marketing automation engine, from the ticketing system to the CMS builder, and from the AI layer to the multi-region deployment strategy. Whether you are preparing for a senior or staff-level system design interview, or you are architecting a CRM platform for your own organization, this guide provides the depth and breadth you need.
2. Functional and Non-Functional Requirements
Before diving into architecture, we must clearly define what the system needs to do and how it must perform. Requirements gathering is the foundation of any system design exercise, and for a platform as complex as HubSpot, getting this right is critical.
Functional Requirements
The core functional requirements span all five hubs. For contact management, the system must support creating, reading, updating, and deleting contacts. Each contact can have an unlimited number of custom properties beyond the default set (first name, last name, email, phone, company). Contacts must be associated with companies, deals, tickets, and activities. The system must automatically deduplicate contacts based on email address and merge records when duplicates are detected. Contact timelines must display every interaction chronologically — emails, calls, meetings, notes, form submissions, page views, and more.
For deal pipeline management, users must be able to create custom deal pipelines with configurable stages (for example: Prospecting, Qualification, Proposal, Negotiation, Closed Won, Closed Lost). Deals must track amounts, close dates, associated contacts, and associated companies. The system must support multiple concurrent pipelines per account. Sales automation features must trigger actions when deals enter specific stages — creating tasks for sales rep sending internal notifications, or updating deal properties automatically.
Email tracking requires the system to detect when recipients open emails, click links within emails, or download attachments. The tracking must work via invisible pixel tracking for opens and redirect-based click tracking for links. Sales sequences must support automated multi-step email follow-ups with configurable delays between steps, branching logic based on recipient actions (opened vs. not opened), and automatic enrollment or unenrollment criteria.
The marketing hub must support creating and hosting landing pages through a visual drag-and-drop editor, building forms that embed on external websites, scoring leads based on demographic fit and behavioral engagement, and managing multi-channel campaigns that coordinate email, social media, ads, and blog content. The service hub must provide a ticketing system with SLA tracking, automatic ticket routing based on agent availability and expertise, and customer satisfaction surveys sent after ticket resolution.
The CMS hub must enable non-technical users to build and manage websites through a visual editor, serve smart content that adapts based on visitor characteristics (logged-in users see different content than anonymous visitors), and provide SEO recommendations for every page. The AI layer must generate content drafts, score leads using machine learning models, forecast deal outcomes, and power the conversational ChatSpot assistant.
Non-Functional Requirements
Scalability is paramount — the system must handle 200,000+ tenant organizations with some tenants managing millions of contacts. API response times must stay under 200 milliseconds for p95 and under 500 milliseconds for p99. The system must maintain 99.99% uptime (less than 52.6 minutes of downtime per year). Data durability must be absolute — no customer data can ever be lost. The platform must support horizontal scaling to handle traffic spikes during peak business hours in different time zones.
Security and compliance requirements include SOC 2 Type II certification, GDPR compliance for European customers, HIPAA compliance for healthcare customers (Enterprise tier), and CCPA compliance for California residents. Every API call must be authenticated and authorized. Tenant data must be completely isolated — no tenant should ever be able to access another tenant's data through any mechanism, including side-channel attacks.
| Requirement Category | Specific Requirement | Target Metric |
|---|---|---|
| Availability | System uptime | 99.99% |
| Latency | API p95 response time | < 200ms |
| Latency | API p99 response time | < 500ms |
| Throughput | API requests per second | 500K+ RPS |
| Durability | Data loss tolerance | Zero |
| Scalability | Concurrent tenants | 200K+ |
| Scalability | Contacts per tenant | 10M+ (Enterprise) |
| Compliance | Regulatory certifications | SOC2, GDPR, HIPAA, CCPA |
| Security | Authentication | OAuth 2.0 + API keys |
| Extensibility | Custom properties per object | 500+ |
3. Capacity Estimation and Back-of-Envelope Math
Capacity estimation grounds the design in physical reality. Numbers drive every architectural decision — from database sharding strategy to cache sizing to regional deployment topology. Let us work through the math for a HubSpot-scale CRM platform.
User and Tenant Counts
With 200,000 paying customers and an estimated 2 million free CRM users, we are looking at approximately 2.2 million tenant organizations. Each tenant has between 1 and 500 users, with a median of approximately 5 users for SMB accounts and 50 users for Enterprise accounts. Assuming a weighted average of 8 users per tenant across all tiers, we have roughly 17.6 million user accounts. However, not all users are active simultaneously. Assuming 30% of users are active during peak hours, we need to support approximately 5.3 million concurrent sessions.
Contact Volume
HubSpot's free CRM allows up to 15 million contacts per account (with limits on marketing contacts in lower tiers). If we assume the average tenant has 50,000 contacts and the top 10% of tenants (Enterprise) have an average of 2 million contacts, the total contact volume is approximately 50,000 x 180,000 + 2,000,000 x 20,000 = 9 billion + 40 billion = 49 billion contacts. For our design, let us estimate conservatively at 30 billion contacts. Each contact record, including all properties, averages approximately 2 KB, giving us a raw data volume of 60 TB for contacts alone.
Deal Volume
Assuming each tenant has an average of 500 active deals and 5,000 historical deals, with 200,000 tenants, we have 100 million active deals and 1 billion historical deals. Each deal record is approximately 1.5 KB, totaling approximately 1.65 TB of deal data. However, deals are far less read-heavy than contacts — they are primarily accessed by sales reps focused on their own pipeline, not by marketing automation systems scanning millions of records.
Email Tracking Volume
Enterprise marketing campaigns can send millions of emails per month. If we assume 10% of tenants send an average of 100,000 tracked emails per month, we have 20,000 x 100,000 = 2 billion tracked emails per month, or approximately 67 million per day, or approximately 775 per second on average. During peak campaign hours (Tuesday-Thursday, 9 AM-12 PM in the recipient's time zone), traffic could spike to 10x average, giving us approximately 7,750 email open events per second at peak.
API Call Volume
Assuming each active user makes approximately 100 API calls per hour during active sessions, with 5.3 million concurrent users, we get approximately 530 million API calls per hour, or approximately 147,000 per second on average. At peak (2x average), we need to handle approximately 294,000 requests per second.
| Metric | Average | Peak (2x-10x) |
|---|---|---|
| Active users | 5.3M concurrent | 8M concurrent |
| Contacts | 30 billion total | - |
| Deals | 1.1 billion total | - |
| Email events/day | 67M | 670M (peak hour) |
| API requests/sec | 147K | 294K |
| Storage (contacts) | 60 TB | - |
| Bandwidth | 50 Gbps | 200 Gbps |
Storage and Bandwidth
Total data volume across all objects (contacts, companies, deals, tickets, activities, emails, files, etc.) is estimated at approximately 500 TB, growing at approximately 15 TB per month. With a 3x replication factor plus backups, raw storage requirements are approximately 2 PB. For bandwidth, assuming average API responses of 10 KB and 147K requests per second, outbound bandwidth is approximately 1.47 GB/s or approximately 12 Gbps. Adding in real-time sync, WebSocket connections for live updates, and static asset delivery, total bandwidth peaks at approximately 50-200 Gbps depending on region.
4. Core Data Model Design
The data model is the backbone of any CRM system. HubSpot's data model revolves around a few core objects — contacts, companies, deals, tickets, and activities — connected through associations and enriched by an extensible property system. Let us design this model from first principles.
Core Objects
A Contact represents an individual person. Every contact has a unique identifier, an email address (used for deduplication), a set of default properties (first name, last name, phone, lifecycle stage, lead score, etc.), and an arbitrary number of custom properties defined by the tenant. Contacts are the central node in the CRM graph — everything connects to contacts.
A Company represents an organization. Companies have default properties like domain, industry, number of employees, annual revenue, and location. Companies are associated with contacts (an employee works at a company), deals (a deal involves a company), and tickets (a support request comes from a company).
A Deal represents a sales opportunity. Deals have a pipeline, a stage within that pipeline, an amount, a close date, and an outcome (open, won, lost). Deals are associated with contacts (the people involved), companies (the organization being sold to), and activities (calls, emails, meetings related to the deal).
A Ticket represents a customer support request. Tickets have a priority, a status (new, in progress, waiting, resolved, closed), a channel (email, chat, phone, form), an assignee (the support agent), and SLA deadlines. Tickets are associated with contacts (the requester) and companies.
An Activity represents any interaction or event. This includes emails sent/received, calls logged, meetings scheduled, notes added, form submissions, page views, ad interactions, and workflow events. Activities are always associated with a contact and optionally with deals, companies, or tickets.
Property System
HubSpot's property system is one of its most powerful features. Each core object has a set of default properties defined by HubSpot (for example, firstname, email, lifecyclestage for contacts) plus unlimited custom properties defined by the tenant. Each property has a name, a type (string, number, date, dropdown, multi-select, boolean, etc.), a group, a description, and visibility rules.
Associations
Associations define relationships between objects. A contact can be associated with multiple companies, deals, and tickets. A deal can be associated with multiple contacts and companies. Associations are many-to-many and are stored in a separate association table. Each association can have metadata — for example, the role of a contact on a deal (decision maker, champion, influencer).
The diagram above shows the core entity-relationship model. Note that associations are flexible — tenants can define custom association types between any two objects. This extensibility is crucial for enterprise customers who may have complex organizational structures and relationship models that differ significantly from the default HubSpot configuration.
5. RESTful API Design
HubSpot's API is one of the most well-documented and developer-friendly APIs in the SaaS ecosystem. The current API (v3) follows RESTful conventions, uses JSON for request and response bodies, and supports OAuth 2.0 and API key authentication. Let us design the key endpoints for our CRM platform.
Contact API Endpoints
The contact API supports full CRUD operations plus batch operations and search. Every endpoint uses the /crm/v3/objects/contacts base path. The API supports filtering by any property, pagination via cursors (not offsets, to handle real-time data changes), and field selection to minimize payload sizes.
HTTP
// Create a contact
POST /crm/v3/objects/contacts
Content-Type: application/json
Authorization: Bearer {access_token}
{
"properties": {
"email": "jane@acme.com",
"firstname": "Jane",
"lastname": "Smith",
"phone": "+1-555-0123",
"company": "Acme Corp",
"lifecyclestage": "lead",
"hs_lead_status": "NEW"
}
}
// Response 201 Created
{
"id": "53638271",
"properties": {
"email": "jane@acme.com",
"firstname": "Jane",
"lastname": "Smith",
"createdate": "2026-07-01T10:00:00Z",
"lastmodifieddate": "2026-07-01T10:00:00Z"
},
"createdAt": "2026-07-01T10:00:00Z",
"updatedAt": "2026-07-01T10:00:00Z"
}
// Search contacts
POST /crm/v3/objects/contacts/search
{
"filterGroups": [
{
"filters": [
{
"propertyName": "lifecyclestage",
"operator": "EQ",
"value": "lead"
},
{
"propertyName": "leadscore",
"operator": "GTE",
"value": "50"
}
]
}
],
"properties": ["email", "firstname", "lastname", "leadscore"],
"limit": 50,
"after": "0"
}
Deal API Endpoints
The deal API follows the same patterns but adds pipeline-specific operations. Deals are always scoped to a pipeline, and stage transitions are first-class operations with built-in validation.
HTTP
// Create a deal with associations
POST /crm/v3/objects/deals
{
"properties": {
"dealname": "Enterprise License - Acme Corp",
"pipeline": "default",
"dealstage": "qualifiedtobuy",
"amount": "125000",
"closedate": "2026-09-30T00:00:00Z",
"dealtype": "newbusiness"
},
"associations": [
{
"to": { "id": "53638271" },
"types": [{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 3 }]
}
]
}
// Move deal to next stage
PATCH /crm/v3/objects/deals/9271638
{
"properties": {
"dealstage": "proposal"
}
}
Activity and Timeline API
The timeline API records every interaction against a contact's record. Activities are append-only — once created, they cannot be modified (for audit trail purposes).
HTTP
// Log a call activity
POST /crm/v3/objects/contacts/53638271/timeline
{
"eventType": "call",
"properties": {
"subject": "Discovery call",
"body": "Discussed Q3 requirements and budget.",
"duration": "3245",
"disposition": "connected",
"toNumber": "+1-555-0123"
}
}
// Get contact timeline with cursor pagination
GET /crm/v3/objects/contacts/53638271/timeline?limit=50&after=1688169600000
| Endpoint | Method | Description | Rate Limit |
|---|---|---|---|
/crm/v3/objects/contacts | GET/POST | List or create contacts | 100 req/10s |
/crm/v3/objects/contacts/{id} | GET/PATCH/DELETE | Read, update, or delete contact | 100 req/10s |
/crm/v3/objects/contacts/search | POST | Search contacts with filters | 10 req/10s |
/crm/v3/objects/deals | GET/POST | List or create deals | 100 req/10s |
/crm/v3/objects/deals/{id} | GET/PATCH/DELETE | Read, update, or delete deal | 100 req/10s |
/crm/v3/pipelines/deals/{pipeline} | GET | Get pipeline stages | 100 req/10s |
/crm/v3/objects/contacts/{id}/timeline | GET/POST | Get or create timeline events | 50 req/10s |
/crm/v3/objects/tickets | GET/POST | List or create tickets | 100 req/10s |
/crm/v3/properties/{object} | GET/POST | Manage object properties | 100 req/10s |
Rate limiting uses a token bucket algorithm with per-tenant and per-endpoint quotas. Enterprise tenants receive higher rate limits. The API returns 429 Too Many Requests with a Retry-After header when limits are exceeded. Batch endpoints allow up to 100 records per request to reduce round trips for bulk operations.
6. High-Level System Architecture
The high-level architecture of a HubSpot-like CRM platform is a distributed microservices system organized around the five hubs, all sharing a common CRM core layer. Let us examine the architecture from the top down.
The architecture separates into four distinct layers. The Client Layer includes the React-based web application, native mobile apps, and third-party API clients. The web application communicates with the backend primarily through REST APIs and WebSockets for real-time updates (for example, when a deal stage changes on another user's screen).
The Edge Layer handles cross-cutting concerns. The CDN serves static assets (JavaScript bundles, CSS, images, CMS-hosted website content). The WAF (Web Application Firewall) protects against common attack patterns including SQL injection, cross-site scripting, and DDoS attacks. The API Gateway is the single entry point for all API requests — it handles authentication, rate limiting, request routing, request/response transformation, and API versioning.
The Service Layer is divided into hub services (one per HubSpot hub) and CRM core services (shared by all hubs). Hub services implement hub-specific business logic — for example, the Marketing Hub service handles campaign management, email sending, and landing page rendering. CRM core services implement shared functionality — the Contact Service manages all contact CRUD operations and is called by every hub. This separation allows each hub to evolve independently while sharing the underlying data model.
The Platform Services layer provides infrastructure capabilities. The Workflow Engine executes automated workflows triggered by CRM events. The AI Service powers ChatSpot, lead scoring, and content generation. The Search Service provides full-text search across all CRM objects using Elasticsearch. The Notification Service delivers real-time alerts via email, SMS, push notifications, and in-app banners.
The Data Layer uses PostgreSQL as the primary relational database for all CRM data, with read replicas for analytical queries and reporting. Redis provides distributed caching for frequently accessed records. Elasticsearch powers the search index and activity timeline queries. Apache Kafka handles event streaming — every CRM event is published to Kafka topics, enabling real-time event processing, workflow triggers, and analytics pipelines.
7. Contact Management at Scale
Contact management is the heart of any CRM system. In HubSpot, the contact record is the single source of truth for everything — every email, call, meeting, form submission, page view, deal, and support ticket connects back to a contact. Designing this system to handle billions of contacts while maintaining sub-200ms response times is one of the most challenging aspects of the entire platform.
Unified Contact Profiles
A unified contact profile aggregates data from multiple sources into a single, coherent view. When a new contact is created (either manually by a user, via form submission, via API import, or via email interaction), the system must check for existing contacts with the same email address. If a match is found, the new data is merged into the existing record rather than creating a duplicate. This deduplication logic runs in near-real-time using an email-based unique constraint combined with a background reconciliation process that catches edge cases (multiple email addresses, name variations, etc.).
The contact profile includes three categories of data. First, static properties are explicitly set by users or imported via CSV — names, email addresses, phone numbers, company, lifecycle stage, lead score, and custom properties. Second, computed properties are derived from activity data — last email opened date, total number of website visits, average email open rate, days since last activity. Third, enriched properties are pulled from third-party data providers — company size, industry, social media profiles, technographic data.
Activity Timeline
The activity timeline is the chronological feed of every interaction with a contact. This is one of the most data-intensive components because high-value contacts may have thousands of interactions over their lifetime. The timeline must support infinite scrolling (cursor-based pagination), filtering by activity type (show only emails, or only calls), and real-time updates (new activities appear without page refresh).
Storing and querying the activity timeline requires careful database design. We use a partitioned table with contact_id as the partition key and timestamp as the sort key. This ensures that all activities for a single contact are stored together and can be efficiently retrieved in chronological order. Activities are append-only — once created, they are never updated or deleted.
Custom Properties and Schema Flexibility
HubSpot allows tenants to define up to 500 custom properties per object type. Rather than adding columns to the contact table for every custom property, HubSpot uses a JSONB-based approach that allows unlimited properties without schema changes.
SQL
-- Core contact columns for fast indexed queries
CREATE TABLE contacts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
email VARCHAR(320) NOT NULL,
firstname VARCHAR(255),
lastname VARCHAR(255),
lifecyclestage VARCHAR(50),
leadscore INT DEFAULT 0,
company_domain VARCHAR(255),
properties JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- Property definitions (schema for custom properties)
CREATE TABLE property_definitions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
object_type VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
label VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
group_name VARCHAR(100),
options JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, object_type, name)
);
-- Custom property values
CREATE TABLE property_values (
id BIGSERIAL PRIMARY KEY,
contact_id BIGINT NOT NULL REFERENCES contacts(id),
property_name VARCHAR(100) NOT NULL,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(contact_id, property_name)
);
CREATE INDEX idx_contacts_email ON contacts(tenant_id, email);
CREATE INDEX idx_contacts_lifecycle ON contacts(tenant_id, lifecyclestage);
CREATE INDEX idx_contacts_leadscore ON contacts(tenant_id, leadscore DESC);
CREATE INDEX idx_contacts_properties ON contacts USING GIN(properties);
CREATE INDEX idx_property_values_contact ON property_values(contact_id);
Deduplication Strategy
Contact deduplication runs on three levels. The first level is a real-time unique constraint on the (tenant_id, email) index — if a contact creation request uses an email that already exists for the tenant, it is rejected or triggers a merge. The second level is a background job that scans for duplicates caused by case differences, extra whitespace, or near-matches from form submissions. The third level is a merge API that allows users to combine two contacts, choosing which properties to keep from each record and consolidating the activity timelines.
8. Deal Pipeline and Sales Automation
The deal pipeline is the visual representation of a sales process — a series of stages that a deal progresses through from initial contact to closed won or closed lost. HubSpot supports multiple pipelines per account, allowing organizations to have different processes for new business, upsells, renewals, and partner deals.
Pipeline Configuration
Each pipeline is defined by a sequence of ordered stages, each with a probability percentage (used for weighted pipeline reporting), a deal probability decay rate (optional — deals that sit in a stage too long can be automatically flagged), and a set of required properties. Pipelines are configurable per tenant through the UI, API, or bulk import. The system enforces invariants — a deal cannot skip stages (unless explicitly allowed), closed deals cannot be reopened without creating a new deal linked to the original.
C#
public class DealPipeline
{
public string PipelineId { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
public bool IsDefault { get; set; }
public List<PipelineStage> Stages { get; set; }
public DateTime CreatedAt { get; set; }
}
public class PipelineStage
{
public string StageId { get; set; }
public string Name { get; set; }
public int DisplayOrder { get; set; }
public decimal WinProbability { get; set; }
public int StageTimeoutDays { get; set; }
public List<string> RequiredProperties { get; set; }
public List<StageTransitionRule> TransitionRules { get; set; }
}
public class StageTransitionRule
{
public string FromStageId { get; set; }
public string ToStageId { get; set; }
public bool RequiresApproval { get; set; }
public string RequiredRole { get; set; }
}
public class DealService
{
private readonly IDealRepository _repo;
private readonly IWorkflowEngine _workflowEngine;
private readonly IEventBus _eventBus;
private readonly IActivityService _activityService;
public async Task<Deal> MoveDealToStageAsync(
string dealId, string newStageId, string userId)
{
var deal = await _repo.GetDealAsync(dealId);
var pipeline = await _repo.GetPipelineAsync(deal.PipelineId);
var currentStageIndex = pipeline.Stages
.FindIndex(s => s.StageId == deal.DealStage);
var newStageIndex = pipeline.Stages
.FindIndex(s => s.StageId == newStageId);
if (newStageIndex < currentStageIndex)
{
var rule = pipeline.Stages[currentStageIndex].TransitionRules
.FirstOrDefault(r => r.ToStageId == newStageId);
if (rule?.RequiresApproval == true)
{
await RequestApprovalAsync(deal, rule.RequiredRole, userId);
return deal;
}
}
var oldStage = deal.DealStage;
deal.DealStage = newStageId;
deal.UpdatedAt = DateTime.UtcNow;
await _repo.UpdateDealAsync(deal);
await _activityService.LogActivityAsync(new Activity
{
Type = "deal_stage_change",
ContactId = deal.PrimaryContactId,
DealId = dealId,
Properties = new Dictionary<string, string>
{
["old_stage"] = oldStage,
["new_stage"] = newStageId,
["moved_by"] = userId
}
});
await _eventBus.PublishAsync("deal.stage_changed",
new DealStageChangedEvent
{
DealId = dealId,
TenantId = deal.TenantId,
OldStage = oldStage,
NewStage = newStageId,
Timestamp = DateTime.UtcNow
});
await _workflowEngine.TriggerWorkflowsAsync(
"deal_stage_changed", deal.TenantId,
new { deal, oldStage, newStage = newStageId });
return deal;
}
}
Sales Automation and Task Creation
When a deal moves to a new stage, the workflow engine evaluates all active workflows for that trigger event. Common automations include: when a deal reaches the Proposal stage, automatically create a task for the sales rep to follow up in 3 days; when a deal moves to Negotiation, send an internal Slack notification to the sales manager; when a deal is closed won, create a onboarding ticket in the Service Hub and update the contact's lifecycle stage to Customer. These automations execute asynchronously via Kafka events, ensuring that the deal update API responds quickly while side effects happen in the background.
9. Email Tracking and Sequences
Email tracking is one of HubSpot's most valuable features for sales teams. When a sales rep sends an email through HubSpot (or connects their Gmail/Outlook for tracking), the system can detect when the recipient opens the email, clicks any link, or downloads an attachment. This data feeds into the contact's activity timeline and influences lead scoring, workflow triggers, and sales rep prioritization.
Open Tracking Mechanism
Open tracking works by embedding an invisible 1x1 pixel image in every tracked email. When the recipient's email client loads the image, it sends an HTTP request to HubSpot's tracking server. The request includes the email's unique tracking ID, a timestamp, the recipient's IP address, and the email client's user agent string. The tracking server logs the event, resolves the IP to a geographic location, and publishes the event to Kafka for downstream processing.
The tracking pixel URL follows the pattern https://track.hubspot.com/tracker/{tracking_id}.gif. The tracking server must handle millions of simultaneous requests during peak campaign hours — a Tuesday morning email blast to 5 million recipients could generate millions of open events within a 30-minute window. The server must respond with the pixel image in under 50 milliseconds to avoid triggering spam filters.
Click Tracking Mechanism
Click tracking works by rewriting all links in tracked emails. When the sales rep includes a link to https://acme.com/pricing, HubSpot rewrites it to https://track.hubspot.com/click/{tracking_id}/{encoded_url}. When the recipient clicks the link, they hit the tracking server, which logs the click event (including which link was clicked, at what time, from what device) and then performs an HTTP 301 redirect to the original URL. The redirect must be fast — any delay adds to the recipient's page load time.
Sales Sequences
Sales sequences are automated multi-step email follow-ups that run on behalf of individual sales reps. A sequence might include five emails spaced 2 days apart: an initial outreach, a follow-up with a case study, a value proposition email, a social proof email, and a final breakup email. The sequence engine must handle several complexities: steps only execute during business hours, sequences pause when the recipient replies (auto-unenroll on reply), sequences branch based on recipient behavior, and sequences must respect sending limits to protect the sender's email reputation.
| Event Type | Tracking Method | Data Captured | Average Volume |
|---|---|---|---|
| Email Open | Invisible pixel | Timestamp, IP, user agent | 200M/day |
| Link Click | URL rewrite + redirect | Timestamp, URL, IP, user agent | 50M/day |
| Attachment Download | CDN tracking | Timestamp, file name, IP | 5M/day |
| Email Reply | IMAP/SMTP monitoring | Timestamp, reply body | 30M/day |
| Bounce | SMTP bounce handling | Bounce type, reason | 10M/day |
The combination of open tracking, click tracking, and reply detection creates a rich engagement signal that feeds into lead scoring. A contact who opens every email and clicks links consistently scores much higher than one who ignores outreach. This intelligence helps sales teams prioritize their time on the most engaged prospects.
10. Marketing Hub — Landing Pages, Forms, and Campaigns
The Marketing Hub is HubSpot's original product and remains one of its most powerful offerings. It provides tools for creating landing pages, building forms, managing email campaigns, scoring leads, and measuring marketing attribution.
Landing Page Builder
The landing page builder is a WYSIWYG editor that allows non-technical marketers to create professional landing pages without writing code. Under the hood, the builder uses a block-based editor (similar to WordPress Gutenberg) where users drag pre-built content blocks — headlines, text, images, forms, CTAs, videos, testimonials — onto a page canvas and customize them through a properties panel.
Each landing page is stored as a JSON document that describes the page structure. When a visitor requests the page, the CMS service renders the JSON into HTML using server-side rendering. The rendered HTML is cached at multiple levels — the CDN caches static pages (no personalization), the application cache caches pages with light personalization, and fully dynamic pages are rendered on demand.
JSON
{
"pageId": "lp_9271638",
"tenantId": "t_4821",
"name": "Q3 Product Launch",
"slug": "/q3-launch",
"published": true,
"blocks": [
{
"type": "hero",
"properties": {
"headline": "Introducing Our Q3 Product",
"subheadline": "Faster, smarter, and built for scale",
"backgroundImage": "https://cdn.hubspot.com/lp/q3-hero.jpg",
"ctaText": "Get Early Access",
"ctaLink": "#signup-form"
}
},
{
"type": "form",
"properties": {
"formId": "form_8271",
"fields": ["firstname", "lastname", "email", "company"],
"submitText": "Submit",
"redirectUrl": "/thank-you"
}
},
{
"type": "testimonials",
"properties": {
"testimonials": [
{
"quote": "This product transformed our workflow.",
"author": "Jane Smith, VP Marketing at Acme"
}
]
}
}
],
"seo": {
"metaTitle": "Q3 Product Launch - Early Access",
"metaDescription": "Be the first to try our Q3 product...",
"canonicalUrl": "https://acme.com/q3-launch"
}
}
Lead Scoring
Lead scoring assigns a numerical score to each contact based on two dimensions: demographic fit (how well the contact matches the ideal customer profile) and behavioral engagement (how actively the contact interacts with marketing content). The scoring model is customizable per tenant — one company might weight job title heavily, while another might weight company size more heavily.
HubSpot uses both rule-based scoring (manual point assignments for specific actions — downloaded a whitepaper = +10 points, visited pricing page = +20 points, unsubscribed = -50 points) and predictive scoring (a machine learning model that analyzes historical conversion data to predict the probability of a contact becoming a customer). The predictive model runs daily, recalculating scores for all contacts.
Campaign Management
A campaign groups related marketing assets (landing pages, emails, social posts, ads, blog posts) under a single umbrella for unified reporting. When a contact interacts with any asset in the campaign, those interactions are attributed to the campaign. This enables marketers to measure campaign ROI — how many leads were generated, at what cost, and how many converted to customers. The attribution model supports first-touch, last-touch, linear, and custom multi-touch models.
11. Service Hub — Ticketing and SLA Management
The Service Hub transforms HubSpot from a sales-focused tool into a complete customer lifecycle platform. At its core is a ticketing system that tracks every customer support request from creation to resolution.
Ticketing System
Every customer support interaction — whether it comes in via email, live chat, phone call, contact form, or social media — creates a ticket. The ticket captures the customer's request, assigns it a priority and status, routes it to the appropriate agent, and tracks it through resolution. Tickets are associated with the requesting contact and their company, giving agents full context about the customer's history.
Ticket routing uses a rules engine that considers multiple factors: the channel the ticket came from, the customer's tier (Enterprise customers get priority routing), the topic (billing questions go to the finance team), agent availability and current workload, and agent expertise and language skills. The routing algorithm balances load across agents while ensuring SLA compliance.
C#
public class TicketRoutingService
{
private readonly IAgentRepository _agentRepo;
private readonly ISlaEngine _slaEngine;
private readonly ITenantConfigService _configService;
public async Task<Ticket> RouteTicketAsync(Ticket ticket)
{
var tenantConfig = await _configService
.GetRoutingConfigAsync(ticket.TenantId);
var eligibleAgents = await _agentRepo
.GetAvailableAgentsAsync(ticket.TenantId);
var scoredAgents = eligibleAgents.Select(agent => new
{
Agent = agent,
Score = CalculateRoutingScore(agent, ticket, tenantConfig)
})
.OrderByDescending(x => x.Score)
.ToList();
if (scoredAgents.Count == 0)
{
ticket.Status = "waiting";
ticket.AssignedTo = null;
await SetSlaDeadlineAsync(ticket, "queue_wait");
return ticket;
}
var bestAgent = scoredAgents.First().Agent;
ticket.AssignedTo = bestAgent.Id;
ticket.Status = "new";
ticket.Team = bestAgent.Team;
await SetSlaDeadlineAsync(ticket, "first_response");
return ticket;
}
private decimal CalculateRoutingScore(
Agent agent, Ticket ticket, RoutingConfig config)
{
decimal score = 0;
if (agent.Teams.Contains(ticket.Team))
score += config.TeamMatchWeight;
score += (decimal)(agent.ExpertiseScore
* config.ExpertiseWeight);
var loadFactor = 1m -
((decimal)agent.CurrentTicketCount / agent.MaxTickets);
score += loadFactor * config.LoadBalanceWeight;
if (ticket.Priority == "high" && agent.IsSenior)
score += config.SeniorityBonus;
if (config.LanguageRouting &&
agent.Languages.Contains(ticket.Language))
score += config.LanguageMatchWeight;
return score;
}
}
SLA Management
Service Level Agreements define response time and resolution time targets for tickets. SLAs are configured per tenant and can vary by ticket priority, channel, and customer tier. For example, a high-priority Enterprise ticket might require a first response within 1 hour and resolution within 4 hours, while a low-priority Free tier ticket might allow 24 hours for first response and 5 business days for resolution. The SLA engine calculates deadlines when tickets are created or when their priority changes, monitors approaching deadlines, and escalates overdue tickets to managers.
Customer Feedback Surveys
After a ticket is resolved, the Service Hub automatically sends a Customer Satisfaction (CSAT) survey. The survey includes a rating question (1-5 stars or a 1-10 scale), an optional comment field, and an optional Net Promoter Score (NPS) question. Survey responses are tied to the contact record and the specific ticket, enabling analysis of satisfaction trends over time. The system prevents survey fatigue by limiting survey frequency — a contact will not receive more than one survey per week regardless of how many tickets are resolved.
12. CMS Hub — Website Builder and Smart Content
HubSpot's CMS Hub is a content management system designed specifically for business websites. Unlike WordPress or Drupal, HubSpot's CMS is fully managed — hosting, SSL, CDN, security patches, and performance are all handled by the platform.
Drag-and-Drop Website Builder
The website builder uses the same block-based editor as the landing page builder but with additional capabilities for multi-page websites. Users can create page templates (reusable layouts that define the header, footer, sidebar, and content areas), theme settings (global colors, fonts, spacing), and module libraries (reusable content components like CTAs, forms, and social follow buttons).
Templates are the foundation of the CMS. A template defines the structural layout of a page type — for example, a blog post template includes areas for the title, author, publication date, body content, related posts, and comments. When a content creator creates a new blog post, they select the template and fill in the content areas. This separation of structure (template) and content (page data) ensures visual consistency across the website.
Smart Content
Smart content is HubSpot's personalization engine for the CMS. It allows different visitors to see different content on the same page based on their characteristics. For example, a visitor who is a known customer might see a "Welcome back!" headline with account-specific content, while an anonymous visitor sees the default marketing headline. A visitor from a specific company (identified by IP-based company identification) might see case studies from their industry.
Smart content rules operate on three levels: page-level (entire pages are personalized), module-level (specific content blocks change based on visitor attributes), and email-level (email content adapts per recipient). The personalization data comes from the CRM — when a known contact visits the website, HubSpot looks up their contact record and evaluates smart content rules against their properties.
SEO Recommendations
The CMS Hub includes built-in SEO tools that analyze every page and provide actionable recommendations. The system checks for: missing or duplicate meta titles and descriptions, missing alt text on images, pages with thin content (fewer than 300 words), broken internal and external links, missing canonical tags, improper heading hierarchy (H1 then H3 without H2), page speed issues (large images, unminified CSS/JavaScript), and mobile responsiveness problems.
The CMS also provides serverless functions that allow developers to build custom API endpoints, form handlers, and dynamic data fetchers without managing servers. These functions run on HubSpot's serverless platform (similar to AWS Lambda) and can access the CRM data through internal APIs, enabling highly personalized web experiences.
13. AI Integration — ChatSpot and Predictive Intelligence
Artificial intelligence is no longer a separate feature in HubSpot — it is woven into every hub. HubSpot's AI capabilities range from the conversational ChatSpot assistant to predictive lead scoring, content generation, deal forecasting, and intelligent recommendations.
ChatSpot Architecture
ChatSpot is HubSpot's conversational AI assistant that combines the power of large language models (LLMs) with real-time CRM data. Unlike generic chatbots that only know what is in their training data, ChatSpot can access the user's CRM data in real-time — pulling contact details, summarizing deal pipelines, generating reports, and taking CRM actions through natural language commands. A sales rep might type "Show me all deals over $50K closing this quarter" and ChatSpot translates that into a CRM query, executes it, and presents the results.
The architecture involves several components. The natural language processing layer uses an LLM fine-tuned for CRM domain understanding. When a user submits a query, the NLP layer parses the intent, extracts entities (deal amounts, date ranges, contact names), and generates either a CRM API call or a text generation request. The CRM integration layer executes the API call and returns the results. The response generation layer formats the results into a natural language response with optional data visualizations.
C#
public class ChatSpotService
{
private readonly ILlmClient _llm;
private readonly ICrmQueryEngine _queryEngine;
private readonly IContentGenerator _contentGen;
private readonly IPredictiveScorer _scorer;
public async Task<ChatSpotResponse> ProcessQueryAsync(
string userId, string tenantId, string query)
{
var parsedIntent = await _llm.ParseIntentAsync(query);
switch (parsedIntent.Intent)
{
case "query_crm_data":
var crmQuery = await _llm.GenerateCrmQueryAsync(
query, parsedIntent.Entities);
var results = await _queryEngine
.ExecuteAsync(tenantId, crmQuery);
var summary = await _llm.SummarizeResultsAsync(
query, results);
return new ChatSpotResponse
{
Type = "data_query",
Summary = summary,
Data = results,
SuggestedFollowUps = parsedIntent.FollowUps
};
case "generate_content":
var context = await GetContactContextAsync(
tenantId, parsedIntent.Entities);
var content = await _contentGen
.GenerateAsync(query, context);
return new ChatSpotResponse
{
Type = "content_generation",
Content = content,
SuggestedActions = new[]
{ "Edit", "Send", "Save as draft" }
};
case "forecast_deals":
var forecast = await _scorer
.ForecastPipelineAsync(tenantId);
return new ChatSpotResponse
{
Type = "forecast",
Forecast = forecast,
Confidence = forecast.ConfidenceScore
};
case "score_lead":
var contactId = parsedIntent.Entities
.GetValueOrDefault("contact_id");
var score = await _scorer
.ScoreContactAsync(tenantId, contactId);
return new ChatSpotResponse
{
Type = "lead_score",
Score = score,
Factors = score.ContributingFactors
};
default:
return new ChatSpotResponse
{
Type = "error",
Message = "I didn't understand that request."
};
}
}
}
Predictive Lead Scoring
HubSpot's predictive lead scoring uses a gradient-boosted decision tree model trained on historical conversion data from the tenant's CRM. The model analyzes dozens of features — demographic properties (job title, company size, industry), behavioral signals (email engagement, website visits, form submissions), temporal patterns (time since last activity, engagement velocity), and firmographic data (company revenue, growth rate, technology stack). The model outputs a score from 0 to 100 representing the probability that a contact will become a customer within the next 90 days.
The model is retrained weekly using the latest conversion data. For tenants with insufficient data (fewer than 100 historical conversions), the system falls back to a model trained on aggregate data across similar tenants and then fine-tunes it with the tenant's specific data as it accumulates.
Content Generation
HubSpot's AI can generate marketing copy, blog post outlines, email subject lines, social media posts, and ad copy. The content generation system uses a fine-tuned LLM combined with the tenant's brand guidelines and the context of the target audience. Generated content includes confidence scores and suggested alternatives, allowing users to select the best option or edit the output before publishing.
14. Workflow Engine — Automation at Scale
The workflow engine is one of the most critical components of HubSpot's platform. It allows users to build automated sequences of actions triggered by CRM events, time-based schedules, or manual enrollment. Workflows power everything from simple follow-up reminders to complex multi-step marketing nurture campaigns.
Workflow Model
A workflow consists of three parts: enrollment criteria (which contacts/deals/tickets enter the workflow), a sequence of steps (actions, delays, and conditional branches), and settings (who can enroll, how many times a contact can enter, what happens when a contact exits). Enrollment criteria can be event-based (when a deal is created), property-based (when a contact's lifecycle stage changes to MQL), list-based, or manual.
Each workflow step is one of several types: Action steps (send an email, create a task, update a property, send a webhook), Delay steps (wait for a specific duration, wait until a specific date), Condition steps (if/then branching based on contact properties or list membership), and Goal steps (stop the workflow when a specific condition is met).
Execution Engine
The workflow execution engine is a distributed system that processes millions of workflow steps per day. When a contact is enrolled in a workflow, the engine calculates the time for each step and creates scheduled execution records in a durable queue. Delay steps are implemented as scheduled jobs — when a contact reaches a "wait 2 days" step, the engine calculates the resume timestamp and stores it. A scheduler service runs every minute, scanning for delayed steps whose wait time has elapsed, and enqueues them for execution.
Action steps execute asynchronously through the event bus. When the engine needs to send an email, it publishes an "email_send_requested" event to Kafka, and the email service picks it up and processes it. This decoupled architecture means the workflow engine does not need to wait for external systems to complete. If the action fails, the event bus handles retries with exponential backoff.
Enrollment and Throughput Management
A common use case is enrolling an entire list of 500,000 contacts into a workflow simultaneously. If the engine tried to process all 500,000 enrollments at once, it would overwhelm the email service. Instead, the engine uses a throughput governor that paces enrollment. New enrollments are batched (for example, 10,000 per hour for email workflows) and distributed evenly across the time window.
| Workflow Step Type | Examples | Execution Mode |
|---|---|---|
| Action | Send email, create task, update property | Async (Kafka event) |
| Delay | Wait 2 days, wait until Monday 9 AM | Scheduled job |
| Condition | If lifecycle = MQL, If opened email | Sync evaluation |
| Goal | Stop if deal is closed won | Sync evaluation |
| Webhook | POST to external URL | Async with retry |
| Notification | Send Slack message, email alert | Async (notification svc) |
15. Reporting and Dashboards
Reporting is the analytical backbone of HubSpot. Every hub generates data, and the reporting engine transforms that raw data into actionable insights. HubSpot provides pre-built reports (deal funnel, email campaign performance, ticket resolution time) and a custom report builder that allows users to create reports from any CRM data.
Report Types
There are five primary report types. Single-object reports analyze a single CRM object — for example, a count of contacts by lifecycle stage. Cross-object reports join data across objects — for example, average deal size for contacts from a specific marketing campaign. Funnel reports visualize conversion rates across stages — for example, how many leads become MQLs, how many MQLs become SQLs. Time-series reports track metrics over time — for example, monthly recurring revenue growth. Attribution reports connect marketing activities to revenue outcomes.
Dashboard Rendering
Dashboards are rendered client-side using the data returned by the reporting API. Each widget on the dashboard makes an independent API call to fetch its data, and the results are cached aggressively because dashboard data does not need to be real-time — a 5-minute stale window is acceptable for most reporting use cases. The backend uses materialized views in PostgreSQL for common report queries and pre-computed aggregations stored in Redis for instant dashboard loads.
Revenue Forecasting
Revenue forecasting uses a combination of pipeline analysis and machine learning to predict future revenue. The pipeline-based forecast sums weighted deal amounts (deal value multiplied by stage probability) for deals expected to close in a given period. The ML-based forecast uses historical win rates, deal velocity, seasonality patterns, and external signals to produce a more accurate prediction. The two forecasts are presented side by side with confidence intervals, allowing sales leaders to make informed decisions.
Reporting data must be kept fresh but cannot be computed in real-time for every dashboard load. HubSpot uses a tiered freshness model: executive dashboards update every 30 minutes, operational dashboards update every 5 minutes, and real-time dashboards update every 30 seconds using streaming aggregation from Kafka.
16. App Marketplace and Integrations
HubSpot's App Marketplace hosts over 1,500 third-party integrations, from Salesforce and Slack to Google Workspace and Zoom. The marketplace is a critical ecosystem that extends HubSpot's functionality and creates network effects — the more integrations available, the more valuable the platform becomes.
Integration Architecture
HubSpot supports three integration patterns. Native integrations are built and maintained by HubSpot's engineering team, running on HubSpot's infrastructure with deep CRM data access. Marketplace integrations are built by third-party developers using HubSpot's public APIs and OAuth 2.0 for authentication. Custom integrations are built by individual customers for their specific use cases.
OAuth and Authentication
All integrations authenticate through OAuth 2.0. When a user installs an app from the marketplace, they are redirected to HubSpot's authorization page where they grant the app specific permissions (scopes). HubSpot supports granular scopes — an app might request read-only access to contacts but read-write access to deals. After authorization, HubSpot issues an access token and a refresh token.
HTTP
// OAuth 2.0 Authorization Flow
GET /oauth/v1/authorize
?client_id={app_id}
&redirect_uri={callback_url}
&scope=crm.objects.contacts.read%20crm.objects.deals.write
&state={csrf_token}
// After user grants permission, HubSpot redirects to callback:
// GET {callback_url}?code={auth_code}&state={csrf_token}
// Exchange code for tokens
POST https://api.hubapi.com/oauth/v1/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id={app_id}
&client_secret={app_secret}
&redirect_uri={callback_url}
&code={auth_code}
// Response:
{
"access_token": "eyJhbGciOiJIUzI1...",
"refresh_token": "rt_7f3a8b2c...",
"expires_in": 21600,
"token_type": "bearer",
"scope": "crm.objects.contacts.read crm.objects.deals.write"
}
Data Synchronization
Many integrations require bidirectional data synchronization — for example, syncing HubSpot contacts with a Salesforce CRM. HubSpot provides a webhooks system that pushes events to third-party apps in real-time. When a contact is created or updated, HubSpot sends an HTTP POST to the registered webhook URL with the event payload. The receiving app processes the event and can use the HubSpot API to pull full record details or push updates back.
For high-volume integrations, HubSpot provides a batch API that supports reading and writing up to 100 records per request. For extremely high-volume use cases (millions of records), HubSpot offers a data export API that generates CSV files of CRM data for bulk import into external systems.
17. Multi-Tenant Architecture
HubSpot is a multi-tenant SaaS platform — thousands of customer organizations share the same infrastructure while their data remains completely isolated. Multi-tenancy is fundamental to HubSpot's business model because it enables the freemium tier while also supporting enterprise customers with strict data isolation requirements.
Tenant Isolation Model
HubSpot uses a shared-everything architecture with logical tenant isolation. All tenants share the same database cluster, application servers, and infrastructure, but every database query is scoped to a tenant_id column. This approach maximizes resource utilization but requires rigorous enforcement of tenant boundaries at every layer of the stack.
Tenant isolation is enforced at four levels. At the API Gateway level, every request is authenticated and the tenant context is extracted from the access token. The gateway injects a X-Tenant-Id header. At the service level, every database query includes a WHERE tenant_id = ? clause, enforced by a shared middleware component. At the database level, row-level security (RLS) policies in PostgreSQL provide a safety net. At the cache level, Redis keys are prefixed with the tenant ID to prevent cache poisoning attacks.
Custom Properties and Permissions
Each tenant can define custom properties, custom object types, custom deal pipelines, custom workflow actions, and custom report types. These customizations are stored as metadata in the database and interpreted by the application layer. The property definition service must validate property definitions (prevent injection attacks, enforce type constraints, limit the number of properties per tenant), cache definitions aggressively, and propagate changes in near-real-time.
Permissions in HubSpot follow a role-based access control (RBAC) model with team-level scoping. Default roles include Owner, Administrator, Marketing Manager, Sales Manager, Service Manager, and individual contributor roles. Each role defines a set of permissions, and permissions can be further scoped by team — a sales rep on the East team can only see East team contacts.
Tenant Tiering and Performance
Not all tenants are equal. Free tier tenants get basic functionality with limited API rate limits and storage. Starter tier tenants get higher limits and additional features. Professional tier tenants get advanced automation, reporting, and API access. Enterprise tier tenants get the highest limits, dedicated support, custom objects, advanced permissions, and HIPAA compliance. The system uses a tiering service that maps each tenant to their subscription level and enforces tier-specific limits.
18. Database Design Deep Dive
The database design for a HubSpot-scale CRM is one of the most complex aspects of the system. We need to handle billions of records, support efficient queries across multiple dimensions, maintain full audit trails, and provide the schema flexibility needed for custom properties.
Activity Timeline Storage
The activity timeline is the most write-intensive and read-intensive component. Every interaction with every contact generates an activity record, and users frequently scroll through contact timelines. We use a time-partitioned table optimized for the most common query pattern — "show me the latest activities for this contact."
SQL
-- Activity timeline with partitioning
CREATE TABLE activities (
id BIGSERIAL,
tenant_id BIGINT NOT NULL,
contact_id BIGINT NOT NULL,
deal_id BIGINT,
company_id BIGINT,
ticket_id BIGINT,
activity_type VARCHAR(50) NOT NULL,
subject VARCHAR(500),
body TEXT,
metadata JSONB DEFAULT '{}',
created_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, tenant_id, created_at)
) PARTITION BY RANGE (created_at);
-- Monthly partitions
CREATE TABLE activities_2026_07 PARTITION OF activities
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE activities_2026_08 PARTITION OF activities
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
-- Indexes optimized for timeline queries
CREATE INDEX idx_activities_contact_timeline
ON activities(tenant_id, contact_id, created_at DESC);
CREATE INDEX idx_activities_deal
ON activities(tenant_id, deal_id, created_at DESC);
CREATE INDEX idx_activities_type
ON activities(tenant_id, activity_type, created_at DESC);
CREATE INDEX idx_activities_metadata
ON activities USING GIN(metadata);
Property Store Design
The property store must support fast reads (every contact fetch needs property values) and fast writes (properties are updated frequently during automation). We use a hybrid approach: the most commonly accessed properties (first 50 default properties) are stored as regular columns in the contact table for fast indexed queries, while all other properties are stored in a separate property_values table using an EAV pattern.
For even faster access, the full property set for a contact is cached in Redis as a JSON blob. When a property is updated, the cache is invalidated and rebuilt on the next read. For contacts with extremely high read volumes, a batch property fetch API loads properties in bulk with a single database query.
Association Graph
Associations between objects form a graph that must support efficient traversal in both directions. We store associations in a dedicated table with composite indexes for both directions.
SQL
-- Association table supporting many-to-many relationships
CREATE TABLE associations (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
from_object_type VARCHAR(50) NOT NULL,
from_object_id BIGINT NOT NULL,
to_object_type VARCHAR(50) NOT NULL,
to_object_id BIGINT NOT NULL,
association_type VARCHAR(50) NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, from_object_type, from_object_id,
to_object_type, to_object_id, association_type)
);
-- Bidirectional indexes for efficient lookups
CREATE INDEX idx_assoc_from
ON associations(tenant_id, from_object_type, from_object_id);
CREATE INDEX idx_assoc_to
ON associations(tenant_id, to_object_type, to_object_id);
CREATE INDEX idx_assoc_type
ON associations(tenant_id, association_type);
The association table is cached aggressively because association data changes relatively infrequently compared to property values. When an association is created or deleted, the cache entries for both associated objects are invalidated. For complex association queries (find all contacts associated with deals worth more than $100K created in the last 30 days), the query planner joins across multiple tables with appropriate index support.
19. Caching Strategy
Caching is essential for maintaining sub-200ms API response times at HubSpot's scale. Without caching, every API call would require multiple database queries. With caching, the most frequently accessed data lives in Redis and is served in under 1 millisecond.
Contact Cache
Contact records are the most frequently accessed objects in the system. We use a read-through cache pattern with Redis as the cache store. The cache key is contact:{tenant_id}:{contact_id}, and the value is a serialized JSON blob containing the contact's core properties, custom properties, and computed properties. The TTL is 5 minutes for active contacts and 1 hour for inactive contacts.
Cache invalidation is triggered by three events: explicit property update (the API updates the cache immediately after writing to the database), association change (creating or deleting a deal association invalidates the contact cache), and activity creation (a new email or call invalidates the contact cache to refresh computed properties). For batch operations, the entire tenant's contact cache is invalidated and rebuilt lazily.
Dashboard Cache
Dashboard data is the most aggressively cached because it does not need real-time freshness. We use a multi-tier caching strategy: the application layer caches report results in Redis with a 5-minute TTL, the CDN caches static dashboard renders, and the database layer uses materialized views that are refreshed on a schedule. This means a dashboard load typically hits the Redis cache and returns in under 10 milliseconds.
| Cache Target | Cache Key Pattern | TTL | Invalidation Trigger | Avg Hit Rate |
|---|---|---|---|---|
| Contact record | contact:{tid}:{cid} | 5 min | Property update, activity | 92% |
| Contact properties | props:{tid}:{cid} | 5 min | Property update | 95% |
| Pipeline summary | pipeline:{tid}:{pid} | 60 sec | Deal stage change | 88% |
| Dashboard report | report:{tid}:{rid} | 5 min | TTL expiry | 90% |
| Property definitions | propdefs:{tid}:{obj} | 1 hour | Definition CRUD | 99% |
| User permissions | perms:{tid}:{uid} | 15 min | Role change | 97% |
| Search index | search:{tid}:{query_hash} | 2 min | Data update | 75% |
| Tenant config | config:{tid} | 1 hour | Config change | 99% |
Cache Warming
Cold caches cause a thundering herd problem — when a cache entry expires and thousands of concurrent requests try to rebuild it simultaneously. To prevent this, we use a cache warming strategy. During off-peak hours, a background job pre-warms the caches for the most active tenants. For on-demand warming, we use a lock-based approach using Redis SETNX as a distributed lock — only one request rebuilds a cache entry while others wait and then read the freshly built cache.
20. Multi-Region Design
HubSpot serves customers worldwide, and many enterprise customers have data residency requirements — European customers may require that their data stays in the EU, healthcare customers may require US-only data storage. HubSpot's multi-region architecture balances global availability with data sovereignty constraints.
Regional Architecture
HubSpot currently operates in three primary regions: Americas (US East), EMEA (EU West, Ireland), and APAC (Singapore). Each region is a fully independent deployment with its own database cluster, application servers, CDN edge, and Kafka cluster. Customer data is assigned to a region based on the tenant's billing address and data residency preferences.
The region routing happens at the CDN and API Gateway level. When a user accesses HubSpot, the DNS resolves to the nearest regional endpoint. The API Gateway checks the tenant's assigned region and routes the request to the correct regional deployment. If the regional deployment is unavailable, the system can fail over to a secondary region for read operations, but write operations are blocked until the primary region recovers.
Data Residency Compliance
Data residency is enforced at the tenant configuration level. When a tenant signs up, they specify their data residency preference. The system assigns the tenant to the appropriate region and ensures all data — including backups, logs, and derived data — stays within the region. Even search indexes, analytics aggregates, and AI model training data are region-scoped.
Cross-Region Challenges
Multi-region deployments introduce several challenges. Schema migrations must be applied to all regions simultaneously. Configuration changes must be synced from the control plane to all regions. Monitoring and alerting must aggregate data from all regions into a single observability dashboard. Disaster recovery must account for region-specific failure scenarios while maintaining data sovereignty constraints.
21. Cost Estimation
Running a HubSpot-scale CRM platform is a significant financial undertaking. Let us estimate the monthly infrastructure costs for serving 200,000 tenants with 30 billion contacts across three regions.
Compute Costs
The application layer requires approximately 500 instances of general-purpose compute (8 vCPU, 32 GB RAM each) to handle the current request load with headroom for traffic spikes. At approximately $0.20 per instance-hour (reserved instances), the monthly compute cost for the application layer is approximately $730,000 per region, or $2.19 million across three regions.
The database layer requires larger, memory-optimized instances. PostgreSQL primary and replica clusters for each region use 16x r6i.4xlarge instances (128 vCPU, 1024 GB RAM each) for a total of $450,000 per region per month. Elasticsearch clusters use approximately 100 instances per region at $200,000 per region per month. Redis clusters use approximately 50 memory-optimized instances per region at $100,000 per region per month.
Storage and Bandwidth Costs
Primary database storage (500 TB across all objects, 3x replication) requires approximately 1.5 PB of SSD storage at approximately $0.15 per GB-month, totaling $225,000 per month per region. Backup storage requires approximately 5 PB of HDD storage at $0.025 per GB-month, totaling $125,000 per month per region. Object storage requires approximately 200 TB at $0.023 per GB-month, totaling $4,600 per month per region.
Outbound bandwidth is approximately 50 Gbps average, 200 Gbps peak. At $0.09 per GB, the monthly bandwidth cost is approximately $300,000 per region. CDN costs are approximately $150,000 per region per month.
Total Cost Summary
| Cost Category | Per Region/Month | Total (3 Regions) |
|---|---|---|
| Application Compute | $730,000 | $2,190,000 |
| Database (PostgreSQL) | $450,000 | $1,350,000 |
| Elasticsearch | $200,000 | $600,000 |
| Redis | $100,000 | $300,000 |
| Database Storage | $225,000 | $675,000 |
| Backup Storage | $125,000 | $375,000 |
| Object Storage | $4,600 | $13,800 |
| Bandwidth | $300,000 | $900,000 |
| CDN | $150,000 | $450,000 |
| Kafka | $200,000 | $600,000 |
| Monitoring and Logging | $50,000 | $150,000 |
| AI/ML Infrastructure | $100,000 | $300,000 |
| Total | $2,634,600 | $7,903,800 |
22. Interview Questions and Answers
The following questions cover the most commonly asked system design interview topics related to CRM platforms like HubSpot. These are designed for senior and staff-level interviews.
Question 1: How would you design the multi-tenant architecture for a CRM serving 200K organizations?
Answer: I would use a shared-everything architecture with logical tenant isolation. Every database table includes a tenant_id column, and every query is scoped by tenant. I would enforce isolation at four levels: the API gateway extracts tenant context from the OAuth token, the service layer injects tenant filters via middleware, PostgreSQL row-level security provides a safety net, and Redis keys are tenant-prefixed. For performance isolation, I would implement tenant tiering with rate limits and resource quotas per subscription level. For the most demanding Enterprise tenants who require dedicated resources, I would offer a single-tenant deployment option.
Question 2: How would you design the workflow engine to handle millions of delayed actions?
Answer: The workflow engine uses a two-phase scheduling approach. When a contact reaches a delay step (for example, "wait 3 days"), the engine calculates the resume timestamp and stores it in a scheduled_actions table. A lightweight scheduler service runs every minute, queries for actions whose delay has elapsed, and publishes them to Kafka for execution. This decouples scheduling from execution. To handle the scale, I would partition the scheduled_actions table by resume_timestamp and use a distributed job scheduler. The throughput governor ensures that email-sending workflows do not exceed the tenant's sending rate limits.
Question 3: How would you design the activity timeline for billions of events?
Answer: The activity timeline uses a time-partitioned table with the partition key being a combination of tenant_id and contact_id. This ensures that all activities for a single contact are co-located for efficient range scans. The primary query pattern ("show me the latest 50 activities for contact X") is served by the index on (tenant_id, contact_id, created_at DESC). For contacts with extremely long timelines, I would use a two-tier storage approach: recent activities (last 90 days) in the primary table, and older activities in a compressed archive table. For real-time updates, I would use WebSockets — when a new activity is created, the service publishes an event that is pushed to any user currently viewing that contact's timeline.
Question 4: How would you handle contact deduplication at scale?
Answer: Deduplication operates on three levels. Real-time: a unique index on (tenant_id, email) catches exact duplicates at write time. Background: a daily batch job scans for near-duplicates based on email normalization (lowercasing, trimming, stripping dots for Gmail addresses), name similarity (Levenshtein distance), and phone number normalization. User-initiated: a merge API allows users to combine two contacts, choosing which properties to keep from each and consolidating activity timelines. The merge operation is transactional — it updates all associations to point to the surviving contact, moves all activities to the surviving contact, and soft-deletes the losing contact.
Question 5: How would you design the search functionality across all CRM objects?
Answer: I would use Elasticsearch as the primary search engine with a dedicated index per CRM object type. When a record is created or updated, the service publishes an event to Kafka, and a search sync consumer updates the Elasticsearch index. For complex searches spanning multiple objects, I would use application-level query composition. For auto-complete, I would use Elasticsearch completion suggesters with a dedicated analyzer for partial matches. The search index is rebuilt from scratch weekly to catch any drift between the primary database and the search index.
Question 6: How would you design the email tracking system for millions of opens and clicks?
Answer: The tracking system has two components: the tracking server and the event processor. The tracking server is a lightweight, stateless service optimized for fast responses — it receives pixel load requests (opens) and link click redirects, logs the event to Kafka, and returns the pixel image or performs the redirect. It does not perform any database writes on the hot path. The event processor consumes from Kafka, enriches events, updates the contact's activity timeline, updates the email's engagement metrics, evaluates workflow triggers, and updates lead scores. This decoupled architecture allows the tracking server to handle millions of concurrent requests with sub-50ms latency.
Question 7: How would you design the property system to support unlimited custom properties?
Answer: I would use a combination of regular relational columns for the 50 most commonly queried default properties and a JSONB column for everything else. PostgreSQL's JSONB type supports indexing (via GIN indexes), querying (via JSON operators), and partial updates. Custom properties are stored in the JSONB column and can be queried efficiently. For properties that need to be queried frequently, I would create materialized views or secondary indexes. The property system also supports computed properties — properties whose values are derived from other properties or activity data — calculated by background jobs.
Question 8: How would you ensure data consistency in a distributed CRM system?
Answer: I would use a pragmatic approach that balances consistency with availability. For operations requiring strong consistency (creating a deal and associating it with a contact), I would use database transactions within a single service boundary. For cross-service operations, I would use the Saga pattern with compensating transactions. For the event bus, I would use Kafka's exactly-once semantics. For eventual consistency scenarios (search index updates, dashboard refresh), I would use event timestamps and conflict resolution strategies. A CRM does not require the same consistency level as a banking system — a 5-second delay in updating a search index is acceptable, but creating a duplicate contact is not.
Question 9: How would you handle real-time collaboration on the same deal?
Answer: I would implement optimistic concurrency control using version numbers. Each deal record has a version column that increments on every update. When a user loads a deal, they receive the current version number. When they save changes, the update includes the version number. If the version matches, the update proceeds. If another user has updated the deal in the meantime, the version mismatch triggers a conflict notification. The UI shows both versions side by side and lets the user choose which changes to keep. For real-time presence (seeing who else is viewing a deal), I would use WebSocket connections with a presence service.
Question 10: How would you design the reporting engine for complex cross-object queries?
Answer: I would isolate analytical workloads from transactional workloads using read replicas and materialized views. Complex reports execute against read replicas. For frequently accessed reports, I would create materialized views that pre-compute joins and aggregations, refreshed every 5-30 minutes. For ad-hoc reports, I would use a separate analytics database (like ClickHouse) that is populated by an ETL pipeline. This OLAP database is optimized for analytical queries with columnar storage and massively parallel processing.
Question 11: How would you handle rate limiting for different subscription tiers?
Answer: I would implement a token bucket rate limiter at the API gateway level with per-tenant and per-endpoint rate limits. Each tenant's subscription tier defines their base rate limits (Free: 100 req/10s, Starter: 500, Professional: 2,000, Enterprise: 10,000). The rate limiter uses Redis to track token counts across gateway instances. When a request is rate-limited, the gateway returns 429 with a Retry-After header. For burst traffic (webhooks delivering 1,000 events rapidly), I would offer a burst allowance that permits short-term spikes above the sustained rate limit.
Question 12: How would you design the AI-powered lead scoring system?
Answer: The lead scoring system uses two models. The rule-based model assigns points based on manually configured rules. The ML model uses a gradient-boosted decision tree trained on historical conversion data. Features include demographic properties, behavioral signals, temporal patterns, and firmographic data. The ML model runs daily in batch, scoring all contacts. The score is stored as a contact property and updated in real-time when new behavioral signals arrive. For tenants with insufficient training data, the system falls back to a global model trained on aggregate data and fine-tunes it as the tenant's data accumulates.
23. Full C# Implementation
Below is a comprehensive C# implementation of the core CRM services. This code demonstrates the Contact Service, Deal Pipeline Service, Workflow Engine, Lead Scoring Service, and Activity Timeline Service. It is designed for clarity and production-readiness, using dependency injection, repository patterns, and event-driven architecture.
C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Text.Json;
namespace HubSpot.Crm.Platform
{
#region Models
public class Contact
{
public long Id { get; set; }
public string TenantId { get; set; }
public string Email { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string LifecycleStage { get; set; }
public int LeadScore { get; set; }
public Dictionary<string, string> Properties { get; set; } = new();
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? DeletedAt { get; set; }
}
public class Deal
{
public long Id { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
public string PipelineId { get; set; }
public string StageId { get; set; }
public decimal Amount { get; set; }
public DateTime? CloseDate { get; set; }
public string Outcome { get; set; }
public long PrimaryContactId { get; set; }
public long CompanyId { get; set; }
public int Version { get; set; }
public Dictionary<string, string> Properties { get; set; } = new();
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class Activity
{
public long Id { get; set; }
public string TenantId { get; set; }
public long ContactId { get; set; }
public long? DealId { get; set; }
public long? TicketId { get; set; }
public string ActivityType { get; set; }
public string Subject { get; set; }
public string Body { get; set; }
public Dictionary<string, string> Metadata { get; set; } = new();
public long? CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
}
public class Pipeline
{
public string PipelineId { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
public bool IsDefault { get; set; }
public List<PipelineStage> Stages { get; set; } = new();
}
public class PipelineStage
{
public string StageId { get; set; }
public string Name { get; set; }
public int DisplayOrder { get; set; }
public decimal WinProbability { get; set; }
public int TimeoutDays { get; set; }
public List<StageTransitionRule> TransitionRules { get; set; } = new();
}
public class StageTransitionRule
{
public string FromStageId { get; set; }
public string ToStageId { get; set; }
public bool RequiresApproval { get; set; }
public string RequiredRole { get; set; }
}
public class Workflow
{
public string WorkflowId { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
public string TriggerType { get; set; }
public Dictionary<string, object> EnrollmentCriteria { get; set; } = new();
public List<WorkflowStep> Steps { get; set; } = new();
public bool IsActive { get; set; }
}
public class WorkflowStep
{
public string StepId { get; set; }
public string StepType { get; set; }
public string ActionType { get; set; }
public Dictionary<string, object> Config { get; set; } = new();
public string NextStepId { get; set; }
public string ConditionTrueStepId { get; set; }
public string ConditionFalseStepId { get; set; }
}
public class WorkflowEnrollment
{
public long Id { get; set; }
public string WorkflowId { get; set; }
public string TenantId { get; set; }
public long ContactId { get; set; }
public string CurrentStepId { get; set; }
public string Status { get; set; }
public DateTime? DelayUntil { get; set; }
public DateTime EnrolledAt { get; set; }
}
public class LeadScoreResult
{
public long ContactId { get; set; }
public int Score { get; set; }
public string Category { get; set; }
public List<ScoreFactor> Factors { get; set; } = new();
public DateTime ScoredAt { get; set; }
}
public class ScoreFactor
{
public string FactorName { get; set; }
public int PointsContributed { get; set; }
public string Reason { get; set; }
}
public class DealAssociation
{
public string ObjectType { get; set; }
public long ObjectId { get; set; }
public string AssociationType { get; set; }
}
#endregion
#region Interfaces
public interface IEventBus
{
Task PublishAsync<T>(string topic, T eventData);
}
public interface ICacheService
{
Task<T> GetAsync<T>(string key);
Task SetAsync<T>(string key, T value, TimeSpan? ttl = null);
Task InvalidateAsync(string key);
Task InvalidatePatternAsync(string pattern);
}
public interface IActivityService
{
Task<Activity> LogActivityAsync(Activity activity);
Task<List<Activity>> GetTimelineAsync(
string tenantId, long contactId,
int limit = 50, long? afterId = null);
}
public interface ICrmRepository
{
Task<Contact> FindContactByEmailAsync(
string tenantId, string email);
Task<Contact> GetContactAsync(
string tenantId, long contactId);
Task<Contact> CreateContactAsync(Contact contact);
Task UpdateContactAsync(Contact contact);
Task<List<Contact>> SearchContactsAsync(
string tenantId, List<SearchFilter> filters,
int limit, string afterCursor);
Task<Deal> GetDealAsync(string dealId);
Task<Deal> CreateDealAsync(Deal deal);
Task UpdateDealAsync(Deal deal);
Task<Pipeline> GetPipelineAsync(
string tenantId, string pipelineId);
Task CreateAssociationAsync(
string fromType, long fromId,
string toType, long toId, string assocType);
Task<List<WorkflowEnrollment>>
GetActiveEnrollmentsAsync(string workflowId);
Task UpdateEnrollmentAsync(WorkflowEnrollment enrollment);
Task<List<Contact>> GetContactsForScoringAsync(
string tenantId, int batchSize);
}
public interface IWorkflowEngine
{
Task TriggerWorkflowsAsync(
string triggerType, string tenantId, object context);
}
public interface IPredictiveScorer
{
Task<LeadScoreResult> ScoreContactAsync(
string tenantId, long contactId);
}
public interface ILlmClient
{
Task<ParsedIntent> ParseIntentAsync(string query);
Task<string> GenerateCrmQueryAsync(
string query, Dictionary<string, string> entities);
Task<string> SummarizeResultsAsync(
string query, object results);
}
public interface IContentGenerator
{
Task<string> GenerateAsync(
string prompt, object context);
}
public interface ISlaEngine
{
Task<DateTime> CalculateSlaDeadlineAsync(
string tenantId, string priority, string channel);
}
public class ParsedIntent
{
public string Intent { get; set; }
public Dictionary<string, string> Entities { get; set; } = new();
public List<string> FollowUps { get; set; } = new();
}
public class SearchFilter
{
public string PropertyName { get; set; }
public string Operator { get; set; }
public string Value { get; set; }
}
public class NotFoundException : Exception
{
public NotFoundException(string msg) : base(msg) { }
}
#endregion
C#
#region Contact Service Implementation
public class ContactService
{
private readonly ICrmRepository _repo;
private readonly ICacheService _cache;
private readonly IEventBus _eventBus;
private readonly IActivityService _activityService;
public ContactService(
ICrmRepository repo, ICacheService cache,
IEventBus eventBus, IActivityService activityService)
{
_repo = repo;
_cache = cache;
_eventBus = eventBus;
_activityService = activityService;
}
public async Task<Contact> CreateContactAsync(
string tenantId,
Dictionary<string, string> properties)
{
var email = properties
.GetValueOrDefault("email")?.ToLower().Trim();
if (string.IsNullOrEmpty(email))
throw new ArgumentException("Email is required");
var existing = await _repo
.FindContactByEmailAsync(tenantId, email);
if (existing != null)
return await MergeContactPropertiesAsync(
existing, properties);
var contact = new Contact
{
TenantId = tenantId,
Email = email,
FirstName = properties
.GetValueOrDefault("firstname"),
LastName = properties
.GetValueOrDefault("lastname"),
LifecycleStage = properties
.GetValueOrDefault("lifecyclestage")
?? "subscriber",
LeadScore = 0,
Properties = properties,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
contact = await _repo.CreateContactAsync(contact);
await _cache.InvalidatePatternAsync(
$"contacts:{tenantId}:*");
await _eventBus.PublishAsync("contact.created",
new
{
contact.Id,
contact.TenantId,
contact.Email,
contact.CreatedAt
});
await _activityService.LogActivityAsync(new Activity
{
TenantId = tenantId,
ContactId = contact.Id,
ActivityType = "contact_created",
Subject = $"Contact created: {email}",
CreatedAt = DateTime.UtcNow
});
return contact;
}
public async Task<Contact> GetContactAsync(
string tenantId, long contactId)
{
var cacheKey =
$"contact:{tenantId}:{contactId}";
var cached = await _cache
.GetAsync<Contact>(cacheKey);
if (cached != null) return cached;
var contact = await _repo
.GetContactAsync(tenantId, contactId);
if (contact == null)
throw new NotFoundException(
$"Contact {contactId} not found");
await _cache.SetAsync(cacheKey, contact,
TimeSpan.FromMinutes(5));
return contact;
}
public async Task<Contact> UpdateContactAsync(
string tenantId, long contactId,
Dictionary<string, string> updates)
{
var contact = await GetContactAsync(
tenantId, contactId);
foreach (var kvp in updates)
{
contact.Properties[kvp.Key] = kvp.Value;
switch (kvp.Key)
{
case "firstname":
contact.FirstName = kvp.Value; break;
case "lastname":
contact.LastName = kvp.Value; break;
case "email":
contact.Email =
kvp.Value?.ToLower().Trim(); break;
case "lifecyclestage":
contact.LifecycleStage = kvp.Value; break;
}
}
contact.UpdatedAt = DateTime.UtcNow;
await _repo.UpdateContactAsync(contact);
await _cache.InvalidateAsync(
$"contact:{tenantId}:{contactId}");
await _cache.InvalidatePatternAsync(
$"contacts:{tenantId}:*");
await _eventBus.PublishAsync("contact.updated",
new
{
contact.Id, contact.TenantId,
Updates = updates, contact.UpdatedAt
});
return contact;
}
public async Task<List<Contact>> SearchContactsAsync(
string tenantId, List<SearchFilter> filters,
int limit = 50, string afterCursor = null)
{
return await _repo.SearchContactsAsync(
tenantId, filters, limit, afterCursor);
}
private async Task<Contact>
MergeContactPropertiesAsync(
Contact existing,
Dictionary<string, string> newProps)
{
foreach (var kvp in newProps)
{
if (string.IsNullOrEmpty(
existing.Properties
.GetValueOrDefault(kvp.Key)))
{
existing.Properties[kvp.Key] = kvp.Value;
}
}
existing.UpdatedAt = DateTime.UtcNow;
await _repo.UpdateContactAsync(existing);
await _cache.InvalidateAsync(
$"contact:{existing.TenantId}:{existing.Id}");
return existing;
}
}
#endregion
C#
#region Deal Pipeline Service
public class DealPipelineService
{
private readonly ICrmRepository _repo;
private readonly ICacheService _cache;
private readonly IEventBus _eventBus;
private readonly IActivityService _activityService;
private readonly IWorkflowEngine _workflowEngine;
public DealPipelineService(
ICrmRepository repo, ICacheService cache,
IEventBus eventBus, IActivityService activityService,
IWorkflowEngine workflowEngine)
{
_repo = repo;
_cache = cache;
_eventBus = eventBus;
_activityService = activityService;
_workflowEngine = workflowEngine;
}
public async Task<Deal> CreateDealAsync(
string tenantId, string pipelineId,
Dictionary<string, string> properties,
List<DealAssociation> associations = null)
{
var pipeline = await _repo
.GetPipelineAsync(tenantId, pipelineId);
var firstStage = pipeline.Stages
.OrderBy(s => s.DisplayOrder).First();
var deal = new Deal
{
TenantId = tenantId,
Name = properties.GetValueOrDefault("dealname"),
PipelineId = pipelineId,
StageId = firstStage.StageId,
Amount = decimal.Parse(
properties.GetValueOrDefault("amount", "0")),
CloseDate = properties.ContainsKey("closedate")
? DateTime.Parse(properties["closedate"])
: null,
Outcome = "open",
Properties = properties,
Version = 1,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
deal = await _repo.CreateDealAsync(deal);
if (associations != null)
{
foreach (var assoc in associations)
await _repo.CreateAssociationAsync(
"deal", deal.Id,
assoc.ObjectType, assoc.ObjectId,
assoc.AssociationType);
}
await _eventBus.PublishAsync("deal.created",
new
{
deal.Id, deal.TenantId,
deal.PipelineId, deal.StageId,
deal.CreatedAt
});
await _activityService.LogActivityAsync(new Activity
{
TenantId = tenantId,
ContactId = deal.PrimaryContactId,
DealId = deal.Id,
ActivityType = "deal_created",
Subject = $"Deal created: {deal.Name}",
Metadata = new Dictionary<string, string>
{
["amount"] = deal.Amount.ToString(),
["pipeline"] = pipelineId
},
CreatedAt = DateTime.UtcNow
});
return deal;
}
public async Task<Deal> MoveDealToStageAsync(
string dealId, string newStageId, string userId)
{
var deal = await _repo.GetDealAsync(dealId);
var pipeline = await _repo
.GetPipelineAsync(deal.TenantId, deal.PipelineId);
var currentIdx = pipeline.Stages
.FindIndex(s => s.StageId == deal.DealStage);
var newIdx = pipeline.Stages
.FindIndex(s => s.StageId == newStageId);
if (newIdx < currentIdx)
{
var rule = pipeline.Stages[currentIdx]
.TransitionRules
.FirstOrDefault(
r => r.ToStageId == newStageId);
if (rule?.RequiresApproval == true)
{
await RequestApprovalAsync(
deal, rule.RequiredRole, userId);
return deal;
}
}
var oldStage = deal.DealStage;
deal.DealStage = newStageId;
deal.UpdatedAt = DateTime.UtcNow;
if (newStageId.Contains("closed_won"))
deal.Outcome = "won";
else if (newStageId.Contains("closed_lost"))
deal.Outcome = "lost";
await _repo.UpdateDealAsync(deal);
await _activityService.LogActivityAsync(new Activity
{
TenantId = deal.TenantId,
ContactId = deal.PrimaryContactId,
DealId = deal.Id,
ActivityType = "deal_stage_change",
Subject = "Deal stage changed",
Metadata = new Dictionary<string, string>
{
["old_stage"] = oldStage,
["new_stage"] = newStageId,
["moved_by"] = userId
},
CreatedAt = DateTime.UtcNow
});
await _eventBus.PublishAsync(
"deal.stage_changed", new
{
deal.Id, deal.TenantId,
deal.PipelineId,
OldStage = oldStage,
NewStage = newStageId,
deal.Amount, deal.Outcome,
Timestamp = DateTime.UtcNow
});
await _workflowEngine.TriggerWorkflowsAsync(
"deal_stage_changed", deal.TenantId,
new
{
deal, oldStage,
newStage = newStageId
});
await _cache.InvalidateAsync(
$"deal:{deal.TenantId}:{deal.Id}");
await _cache.InvalidateAsync(
$"pipeline_summary:{deal.TenantId}:" +
$"{deal.PipelineId}");
return deal;
}
public async Task<decimal> GetWeightedPipelineValueAsync(
string tenantId, string pipelineId)
{
var cacheKey =
$"pipeline_summary:{tenantId}:{pipelineId}";
var cached = await _cache
.GetAsync<decimal>(cacheKey);
if (cached > 0) return cached;
var pipeline = await _repo
.GetPipelineAsync(tenantId, pipelineId);
var weightedValue = pipeline.Stages.Sum(
s => s.WinProbability);
await _cache.SetAsync(cacheKey, weightedValue,
TimeSpan.FromMinutes(1));
return weightedValue;
}
private Task RequestApprovalAsync(
Deal deal, string requiredRole, string userId)
{
return _eventBus.PublishAsync(
"deal.approval_requested", new
{
deal.Id, deal.TenantId,
RequiredRole = requiredRole,
RequestedBy = userId,
Timestamp = DateTime.UtcNow
});
}
}
#endregion
C#
#region Workflow Engine Implementation
public class WorkflowEngineService : IWorkflowEngine
{
private readonly ICrmRepository _repo;
private readonly IEventBus _eventBus;
private readonly ICacheService _cache;
private readonly IActivityService _activityService;
public WorkflowEngineService(
ICrmRepository repo, IEventBus eventBus,
ICacheService cache,
IActivityService activityService)
{
_repo = repo;
_eventBus = eventBus;
_cache = cache;
_activityService = activityService;
}
public async Task TriggerWorkflowsAsync(
string triggerType, string tenantId,
object context)
{
var workflows = await GetActiveWorkflowsAsync(
tenantId, triggerType);
foreach (var workflow in workflows)
{
if (!EvaluateEnrollmentCriteria(
workflow, context))
continue;
var contacts = ExtractContacts(context);
foreach (var contactId in contacts)
{
var alreadyEnrolled = await IsAlreadyEnrolled(
workflow.WorkflowId, contactId);
if (alreadyEnrolled) continue;
await EnrollContactAsync(
workflow, contactId);
}
}
}
private async Task EnrollContactAsync(
Workflow workflow, long contactId)
{
var firstStep = workflow.Stages?.FirstOrDefault()
?? workflow.Steps.FirstOrDefault();
if (firstStep == null) return;
var enrollment = new WorkflowEnrollment
{
WorkflowId = workflow.WorkflowId,
TenantId = workflow.TenantId,
ContactId = contactId,
CurrentStepId = firstStep.StepId,
Status = "active",
EnrolledAt = DateTime.UtcNow
};
await ExecuteStepAsync(workflow, enrollment,
firstStep);
}
public async Task ExecuteStepAsync(
Workflow workflow,
WorkflowEnrollment enrollment,
WorkflowStep step)
{
switch (step.StepType)
{
case "action":
await ExecuteActionAsync(
enrollment, step);
var nextStep = workflow.Steps
.FirstOrDefault(
s => s.StepId == step.NextStepId);
if (nextStep != null)
await ExecuteStepAsync(
workflow, enrollment, nextStep);
break;
case "delay":
var delayMinutes = (int)step.Config
.GetValueOrDefault("delayMinutes", 0);
enrollment.DelayUntil =
DateTime.UtcNow.AddMinutes(delayMinutes);
enrollment.CurrentStepId = step.StepId;
enrollment.Status = "waiting";
await _repo.UpdateEnrollmentAsync(
enrollment);
break;
case "condition":
var met = await EvaluateConditionAsync(
enrollment.ContactId, step.Config);
var branchStepId = met
? step.ConditionTrueStepId
: step.ConditionFalseStepId;
var branchStep = workflow.Steps
.FirstOrDefault(
s => s.StepId == branchStepId);
if (branchStep != null)
await ExecuteStepAsync(
workflow, enrollment, branchStep);
break;
case "goal":
var goalMet = await EvaluateConditionAsync(
enrollment.ContactId, step.Config);
if (goalMet)
{
enrollment.Status = "completed";
await _repo.UpdateEnrollmentAsync(
enrollment);
}
else
{
var goalNext = workflow.Steps
.FirstOrDefault(
s => s.StepId == step.NextStepId);
if (goalNext != null)
await ExecuteStepAsync(
workflow, enrollment, goalNext);
}
break;
}
}
private async Task ExecuteActionAsync(
WorkflowEnrollment enrollment,
WorkflowStep step)
{
switch (step.ActionType)
{
case "send_email":
await _eventBus.PublishAsync(
"workflow.email_requested", new
{
enrollment.ContactId,
enrollment.TenantId,
TemplateId = step.Config
.GetValueOrDefault("templateId"),
EnrollmentId = enrollment.Id
});
break;
case "create_task":
await _eventBus.PublishAsync(
"workflow.task_created", new
{
enrollment.ContactId,
enrollment.TenantId,
Subject = step.Config
.GetValueOrDefault("subject"),
DueDate = DateTime.UtcNow.AddDays(
(int)step.Config
.GetValueOrDefault(
"dueDays", 1))
});
break;
case "update_property":
var propName = step.Config
.GetValueOrDefault("propertyName")
?.ToString();
var propValue = step.Config
.GetValueOrDefault("propertyValue")
?.ToString();
await _eventBus.PublishAsync(
"workflow.property_update", new
{
enrollment.ContactId,
enrollment.TenantId,
PropertyName = propName,
PropertyValue = propValue
});
break;
case "send_webhook":
await _eventBus.PublishAsync(
"workflow.webhook_requested", new
{
enrollment.ContactId,
enrollment.TenantId,
Url = step.Config
.GetValueOrDefault("url"),
Method = step.Config
.GetValueOrDefault(
"method", "POST")
});
break;
}
}
private async Task<bool> EvaluateConditionAsync(
long contactId,
Dictionary<string, object> config)
{
var property = config
.GetValueOrDefault("propertyName")?.ToString();
var op = config
.GetValueOrDefault("operator")?.ToString();
var value = config
.GetValueOrDefault("value")?.ToString();
var contact = await _repo.GetContactAsync(
null, contactId);
if (contact == null) return false;
var actualValue = contact.Properties
.GetValueOrDefault(property) ?? "";
return op switch
{
"EQ" => actualValue == value,
"NEQ" => actualValue != value,
"CONTAINS" => actualValue.Contains(value),
"GTE" => decimal.Parse(actualValue)
>= decimal.Parse(value),
"LTE" => decimal.Parse(actualValue)
<= decimal.Parse(value),
_ => false
};
}
private List<long> ExtractContacts(object context)
{
var type = context.GetType();
var contactIdProp = type.GetProperty("ContactId");
if (contactIdProp != null)
return new List<long>
{ (long)contactIdProp.GetValue(context) };
var contactsProp = type.GetProperty("ContactIds");
if (contactsProp != null)
return (List<long>)contactsProp
.GetValue(context);
return new List<long>();
}
private bool EvaluateEnrollmentCriteria(
Workflow workflow, object context)
{
return workflow.IsActive;
}
private Task<bool> IsAlreadyEnrolled(
string workflowId, long contactId)
{
return Task.FromResult(false);
}
private async Task<List<Workflow>>
GetActiveWorkflowsAsync(
string tenantId, string triggerType)
{
return new List<Workflow>();
}
}
#endregion
C#
#region Lead Scoring Service
public class LeadScoringService : IPredictiveScorer
{
private readonly ICrmRepository _repo;
private readonly ICacheService _cache;
private readonly IEventBus _eventBus;
private static readonly Dictionary<string, int>
_ruleScores = new()
{
["jobtitle_c_level"] = 25,
["jobtitle_vp"] = 20,
["jobtitle_director"] = 15,
["companysize_1000plus"] = 20,
["companysize_200_999"] = 15,
["companysize_50_199"] = 10,
["industry_technology"] = 10,
["visited_pricing_page"] = 20,
["downloaded_whitepaper"] = 15,
["attended_webinar"] = 12,
["opened_email"] = 5,
["clicked_email_link"] = 8,
["visited_blog_3plus"] = 10,
["submitted_contact_form"] = 25,
["unsubscribed_email"] = -50,
["bounced_email"] = -20,
["inactive_90_days"] = -30
};
public LeadScoringService(
ICrmRepository repo, ICacheService cache,
IEventBus eventBus)
{
_repo = repo;
_cache = cache;
_eventBus = eventBus;
}
public async Task<LeadScoreResult>
ScoreContactAsync(string tenantId, long contactId)
{
var cacheKey =
$"lead_score:{tenantId}:{contactId}";
var cached = await _cache
.GetAsync<LeadScoreResult>(cacheKey);
if (cached != null) return cached;
var contact = await _repo
.GetContactAsync(tenantId, contactId);
if (contact == null)
throw new NotFoundException(
$"Contact {contactId} not found");
var factors = new List<ScoreFactor>();
int totalScore = 0;
totalScore += ScoreJobTitle(
contact.Properties, factors);
totalScore += ScoreCompanySize(
contact.Properties, factors);
totalScore += ScoreIndustry(
contact.Properties, factors);
totalScore += ScoreEngagement(
contact.Properties, factors);
totalScore += ScoreBehavior(
contact.Properties, factors);
totalScore += ApplyDecay(
contact.Properties, factors);
totalScore = Math.Max(0, Math.Min(100, totalScore));
var category = totalScore switch
{
>= 80 => "high_intent",
>= 50 => "medium_intent",
>= 20 => "low_intent",
_ => "cold"
};
var result = new LeadScoreResult
{
ContactId = contactId,
Score = totalScore,
Category = category,
Factors = factors,
ScoredAt = DateTime.UtcNow
};
await _cache.SetAsync(cacheKey, result,
TimeSpan.FromHours(1));
await _eventBus.PublishAsync(
"contact.score_changed", new
{
contact.Id, contact.TenantId,
OldScore = contact.LeadScore,
NewScore = totalScore,
Category = category,
Timestamp = DateTime.UtcNow
});
contact.LeadScore = totalScore;
contact.Properties["leadscore"] =
totalScore.ToString();
await _repo.UpdateContactAsync(contact);
return result;
}
private int ScoreJobTitle(
Dictionary<string, string> props,
List<ScoreFactor> factors)
{
var title = props
.GetValueOrDefault("jobtitle")?.ToLower() ?? "";
if (title.Contains("ceo") || title.Contains("cto")
|| title.Contains("cfo"))
{
factors.Add(new ScoreFactor
{
FactorName = "jobtitle_c_level",
PointsContributed = 25,
Reason = "C-level executive"
});
return 25;
}
if (title.Contains("vp") ||
title.Contains("vice president"))
{
factors.Add(new ScoreFactor
{
FactorName = "jobtitle_vp",
PointsContributed = 20,
Reason = "VP-level executive"
});
return 20;
}
if (title.Contains("director"))
{
factors.Add(new ScoreFactor
{
FactorName = "jobtitle_director",
PointsContributed = 15,
Reason = "Director-level"
});
return 15;
}
return 0;
}
private int ScoreCompanySize(
Dictionary<string, string> props,
List<ScoreFactor> factors)
{
if (int.TryParse(
props.GetValueOrDefault("numemployees"),
out var count))
{
if (count >= 1000)
{
factors.Add(new ScoreFactor
{
FactorName = "companysize_1000plus",
PointsContributed = 20,
Reason = "Enterprise (1000+ employees)"
});
return 20;
}
if (count >= 200)
{
factors.Add(new ScoreFactor
{
FactorName = "companysize_200_999",
PointsContributed = 15,
Reason = "Mid-market (200-999)"
});
return 15;
}
if (count >= 50)
{
factors.Add(new ScoreFactor
{
FactorName = "companysize_50_199",
PointsContributed = 10,
Reason = "Small business (50-199)"
});
return 10;
}
}
return 0;
}
private int ScoreIndustry(
Dictionary<string, string> props,
List<ScoreFactor> factors)
{
var industry = props
.GetValueOrDefault("industry")?.ToLower() ?? "";
if (industry.Contains("technology") ||
industry.Contains("saas"))
{
factors.Add(new ScoreFactor
{
FactorName = "industry_technology",
PointsContributed = 10,
Reason = "Technology/SaaS industry"
});
return 10;
}
return 0;
}
private int ScoreEngagement(
Dictionary<string, string> props,
List<ScoreFactor> factors)
{
int score = 0;
if (props.ContainsKey("visited_pricing_page"))
{
factors.Add(new ScoreFactor
{
FactorName = "visited_pricing_page",
PointsContributed = 20,
Reason = "Visited pricing page"
});
score += 20;
}
if (props.ContainsKey("downloaded_whitepaper"))
{
factors.Add(new ScoreFactor
{
FactorName = "downloaded_whitepaper",
PointsContributed = 15,
Reason = "Downloaded whitepaper"
});
score += 15;
}
if (props.ContainsKey("submitted_contact_form"))
{
factors.Add(new ScoreFactor
{
FactorName = "submitted_contact_form",
PointsContributed = 25,
Reason = "Submitted contact form"
});
score += 25;
}
return score;
}
private int ScoreBehavior(
Dictionary<string, string> props,
List<ScoreFactor> factors)
{
int score = 0;
if (int.TryParse(
props.GetValueOrDefault("email_opens_30d"),
out var opens) && opens > 0)
{
var pts = Math.Min(opens * 2, 10);
factors.Add(new ScoreFactor
{
FactorName = "email_engagement",
PointsContributed = pts,
Reason = $"{opens} email opens in 30d"
});
score += pts;
}
return score;
}
private int ApplyDecay(
Dictionary<string, string> props,
List<ScoreFactor> factors)
{
if (props.ContainsKey("inactive_90_days"))
{
factors.Add(new ScoreFactor
{
FactorName = "inactivity_decay",
PointsContributed = -30,
Reason = "No activity in 90+ days"
});
return -30;
}
return 0;
}
}
#endregion
#region ChatSpot Service
public class ChatSpotService
{
private readonly ILlmClient _llm;
private readonly ICrmRepository _repo;
private readonly IContentGenerator _contentGen;
private readonly IPredictiveScorer _scorer;
private readonly ICacheService _cache;
public ChatSpotService(
ILlmClient llm, ICrmRepository repo,
IContentGenerator contentGen,
IPredictiveScorer scorer,
ICacheService cache)
{
_llm = llm;
_repo = repo;
_contentGen = contentGen;
_scorer = scorer;
_cache = cache;
}
public async Task<ChatSpotResponse>
ProcessQueryAsync(
string userId, string tenantId, string query)
{
var intent = await _llm.ParseIntentAsync(query);
switch (intent.Intent)
{
case "query_crm_data":
var crmQuery = await _llm
.GenerateCrmQueryAsync(
query, intent.Entities);
var results = await ExecuteCrmQuery(
tenantId, crmQuery);
var summary = await _llm
.SummarizeResultsAsync(
query, results);
return new ChatSpotResponse
{
Type = "data_query",
Summary = summary,
Data = results,
SuggestedFollowUps = intent.FollowUps
};
case "generate_content":
var context = await GetContextAsync(
tenantId, intent.Entities);
var content = await _contentGen
.GenerateAsync(query, context);
return new ChatSpotResponse
{
Type = "content_generation",
Content = content,
SuggestedActions = new[]
{
"Edit", "Send",
"Save as draft"
}
};
case "score_lead":
var cid = intent.Entities
.GetValueOrDefault("contact_id");
if (long.TryParse(cid, out var contactId))
{
var score = await _scorer
.ScoreContactAsync(
tenantId, contactId);
return new ChatSpotResponse
{
Type = "lead_score",
Summary = $"Score: {score.Score}" +
$" ({score.Category})",
Data = score
};
}
return new ChatSpotResponse
{
Type = "error",
Summary = "Please specify " +
"a valid contact ID."
};
default:
return new ChatSpotResponse
{
Type = "error",
Summary = "I didn't understand " +
"that request. Try asking about " +
"contacts, deals, or lead scores."
};
}
}
private Task<object> ExecuteCrmQuery(
string tenantId, string crmQuery)
{
return Task.FromResult<object>(new { });
}
private Task<object> GetContextAsync(
string tenantId,
Dictionary<string, string> entities)
{
return Task.FromResult<object>(new { });
}
}
public class ChatSpotResponse
{
public string Type { get; set; }
public string Summary { get; set; }
public object Data { get; set; }
public List<string> SuggestedFollowUps { get; set; }
public List<string> SuggestedActions { get; set; }
}
#endregion
}
24. Conclusion
Designing a unified CRM platform like HubSpot is one of the most challenging system design exercises in the SaaS industry. It requires deep expertise across distributed systems, database design, multi-tenant architecture, real-time event processing, machine learning, and front-end engineering. In this guide, we have walked through every major component of the system — from the foundational data model to the sophisticated AI layer, from the workflow engine that automates millions of daily actions to the multi-region deployment that ensures data sovereignty for global customers.
The key architectural decisions that define HubSpot's platform are the shared CRM backbone (all five hubs share one unified data model), the event-driven architecture (Kafka connects all components asynchronously), the flexible property system (JSONB-based custom properties without schema changes), the multi-tenant isolation (four-layer defense: gateway, service, database RLS, cache), and the AI integration (ChatSpot and predictive scoring leveraging the full CRM context).
For system design interviews, the CRM platform archetype tests your ability to reason about complex domain models, design scalable APIs, handle real-time event processing, manage multi-tenancy with strict isolation, and build extensible platforms. The questions at the end of this guide cover the most common interview scenarios. Practice walking through the architecture from requirements to deployment, always grounding your decisions in the specific constraints (200K tenants, 30 billion contacts, 99.99% uptime, sub-200ms latency).
The C# implementation provided demonstrates production-quality patterns: dependency injection, repository pattern, event-driven side effects, cache-through strategies, and clean separation of concerns. These patterns are directly applicable to any enterprise-scale CRM or SaaS platform you might build. The key is to keep the core domain services simple and focused, pushing complexity to the infrastructure layer (Kafka for async, Redis for caching, Elasticsearch for search, PostgreSQL for transactions).
As CRM platforms continue to evolve, the biggest trends shaping the architecture are deeper AI integration (autonomous agents that can conduct outreach, qualify leads, and resolve support tickets without human intervention), real-time personalization at scale (every website visit, email, and chat interaction adapts in real-time based on CRM data), and composable architecture (customers selecting exactly the hubs and features they need and paying only for what they use). Understanding these foundational design principles will prepare you for the next generation of CRM platform engineering.