Data Modeling · Software Engineering · Frequently Asked
Create the database design for an advertising demand platform. The system represents the complete advertiser structure: Advertiser → Campaign → Ad Group → Ad, while Creative is a reusable resource associated with ads. Treat this strictly as a data-modeling problem: concentrate on entities, relationships, column selections, and schema compromises, rather than infrastructure or distributed architecture.
This exercise evaluates how well you turn product needs into a sound relational design. Interviewers will look for appropriate normalization, sensible data types, lifecycle-state handling, and an extensible approach to targeting. Before proposing implementation details, clarify the responsibility of each entity.
| Requirement | Target | Rationale |
|---|---|---|
| Data integrity | No orphaned ads or targeting rules | The hierarchy must remain valid |
| Query performance | Dashboard loads < 200ms, ad serving config pull < 50ms | Both advertisers and the ad server issue frequent reads |
| Auditability | Complete change history for campaigns and ad groups | Needed for compliance and advertiser confidence |
| Extensibility | Introduce targeting dimensions without schema modifications | Advertising targeting changes quickly |
| Metric | Value |
|---|---|
| Advertisers | ~10,000 |
| Campaigns | ~200,000 |
| Ad Groups | ~1,000,000 |
| Ads | ~3,000,000 |
| Creatives | ~500,000 |
| Targeting rules per ad group | ~5-15 |
This is an OLTP system. Dashboard activity produces a moderate write rate, but read access is especially important: the ad server retrieves active campaign configurations at high volume, and advertisers filter dashboard views by status and dates.
Advertiser
├── id: UUID (PK)
├── name: VARCHAR(255)
├── billing_email: VARCHAR(255)
├── billing_address: TEXT
├── payment_method_token: VARCHAR(255)
├── status: VARCHAR(20) -- active, suspended, closed
├── created_at: TIMESTAMP
└── updated_at: TIMESTAMP
Campaign
├── id: UUID (PK)
├── advertiser_id: UUID (FK → Advertiser)
├── name: VARCHAR(255)
├── objective: VARCHAR(50) -- awareness, consideration, conversion
├── total_budget_cents: BIGINT -- money stored as cents, never float
├── daily_budget_cents: BIGINT
├── pacing: VARCHAR(20) -- even, accelerated
├── start_date: DATE
├── end_date: DATE
├── status: VARCHAR(20) -- draft, active, paused, completed, archived
├── created_at: TIMESTAMP
└── updated_at: TIMESTAMP
AdGroup
├── id: UUID (PK)
├── campaign_id: UUID (FK → Campaign)
├── advertiser_id: UUID -- denormalized for query performance
├── name: VARCHAR(255)
├── bid_strategy: VARCHAR(30) -- cpm, cpc, cpv, target_cpa
├── bid_amount_cents: BIGINT
├── frequency_cap_count: INTEGER -- NULL means no cap
├── frequency_cap_window_hours: INTEGER -- NULL means no cap
├── status: VARCHAR(20) -- draft, active, paused, archived
├── created_at: TIMESTAMP
└── updated_at: TIMESTAMP
Creative
├── id: UUID (PK)
├── advertiser_id: UUID (FK → Advertiser)
├── name: VARCHAR(255)
├── asset_type: VARCHAR(20) -- image, video, html5
├── asset_url: TEXT
├── width: INTEGER
├── height: INTEGER
├── duration_seconds: INTEGER -- NULL for images
├── review_status: VARCHAR(20) -- pending, approved, rejected
├── rejection_reason: TEXT
├── created_at: TIMESTAMP
└── updated_at: TIMESTAMP
Ad
├── id: UUID (PK)
├── ad_group_id: UUID (FK → AdGroup)
├── creative_id: UUID (FK → Creative)
├── name: VARCHAR(255)
├── rotation_weight: INTEGER -- relative weight for rotation (default 1)
├── status: VARCHAR(20) -- draft, active, paused, archived
├── created_at: TIMESTAMP
└── updated_at: TIMESTAMP
TargetingRule
├── id: UUID (PK)
├── ad_group_id: UUID (FK → AdGroup)
├── dimension: VARCHAR(50) -- genre, device, geo, age_range, etc.
├── operator: VARCHAR(20) -- include, exclude, in, not_in, gte, lte, between
├── values: JSONB -- operator-specific (e.g., ["US", "CA"], [18, 24] for between, or 18 for gte)
├── created_at: TIMESTAMP
└── updated_at: TIMESTAMP
AuditLog
├── id: UUID (PK)
├── entity_type: VARCHAR(50) -- campaign, ad_group, creative
├── entity_id: UUID
├── user_id: UUID
├── action: VARCHAR(20) -- create, update, delete, status_change
├── changes: JSONB -- {"field": {"old": X, "new": Y}}
├── created_at: TIMESTAMP -- append-only, no updated_at
| Parent | Child | Relationship | Cascade Delete? |
|---|---|---|---|
| Advertiser | Campaign | 1:N | No — archive instead |
| Advertiser | Creative | 1:N | No — creatives may be referenced by ads |
| Campaign | AdGroup | 1:N | No — archive instead |
| AdGroup | Ad | 1:N | Yes — ads are tightly coupled |
| AdGroup | TargetingRule | 1:N | Yes — rules are owned by ad group |
| Creative | Ad | 1:N (shared) | No — ad should be paused, not deleted |
An ad group aimed at "thriller or drama genres, Canada only, excluding tablets, ages 25 through 34" would contain these four TargetingRule records:
| dimension | operator | values |
|---|---|---|
genre | include | ["thriller", "drama"] |
geo | include | ["CA"] |
device | exclude | ["tablet"] |
age_range | between | [25, 34] |
Within one dimension, the rules are evaluated with OR semantics (thriller OR drama). Across separate dimensions, they are combined with AND semantics (a genre match AND a geo match AND no tablet match).
Campaigns and creatives each use their own lifecycle states and allowed transitions. Ad groups and ads generally follow the campaign pattern (draft → active → paused → archived).
Campaign State Flow:
draft → activeactive → paused, completed, or archivedpaused → active or archivedcompleted → archivedCreative Review State Flow:
pending → approved or rejectedrejected → pending for another reviewapproved → pending when the creative requires another reviewCascading behavior:
active status because the ad server evaluates campaign status throughout the hierarchy.active, the ad group is active, the ad is active, and the creative is approved.| Approach | Pros | Cons | Use When |
|---|---|---|---|
| DB Enum | Database-level type checking, compact storage | Adding values requires an ALTER TYPE migration | Values are genuinely stable (for example, include/exclude operators) |
| VARCHAR + app validation | Simple to evolve and requires no migrations | Lacks database enforcement and uses more storage | Status fields are likely to change |
| Lookup table | Referential consistency and room for metadata | Requires another JOIN and complicates reads | Related attributes are needed (for example, objective descriptions) |
Suggested choices for this schema:
| Field | Approach | Rationale |
|---|---|---|
campaign.status | VARCHAR + app validation | State options can change (for example, adding pending_review) |
campaign.objective | VARCHAR + app validation | Additional objectives arrive quarterly |
creative.asset_type | VARCHAR + app validation | Formats such as interactive are introduced from time to time |
ad_group.bid_strategy | VARCHAR + app validation | Product capabilities can alter bidding strategies |
targeting_rule.dimension | VARCHAR (no constraint) | New targeting dimensions are added often |
In real-world ad platforms, VARCHAR plus application-side validation is commonly used for every enum-like attribute. It eliminates migration friction when product requirements shift. In exchange, validation must be implemented carefully in application logic and enforced by API handlers.
Below are the main access patterns and the indexes intended to support them:
1. Advertiser dashboard — "List all of my campaigns ordered by status"
CREATE INDEX idx_campaign_advertiser_status
ON campaign (advertiser_id, status);
2. Ad server configuration retrieval — "Return every active ad group and its targeting data for serving"
CREATE INDEX idx_adgroup_active_campaign
ON ad_group (campaign_id)
WHERE status = 'active';
CREATE INDEX idx_targeting_adgroup
ON targeting_rule (ad_group_id);
3. Creative review queue — "List every creative awaiting review"
CREATE INDEX idx_creative_review_status
ON creative (review_status)
WHERE review_status = 'pending';
4. Audit-history lookup — "Retrieve the modification history for this campaign"
CREATE INDEX idx_audit_entity
ON audit_log (entity_type, entity_id, created_at DESC);
5. Ad group search using denormalized advertiser_id
CREATE INDEX idx_adgroup_advertiser
ON ad_group (advertiser_id);
Partial indexes, which use WHERE conditions, matter here because they include only rows satisfying the filter. This keeps them compact, which is essential when most campaigns are archived or completed while reads predominantly target active records.
Why keep advertiser_id denormalized on AdGroup?
Otherwise, finding every ad group for a particular advertiser requires joining through Campaign. With 1M ad groups, that JOIN is costly for a commonly used dashboard request. The trade-off is preserving consistency by changing advertiser_id if a campaign moves to another advertiser, though such reassignment does not occur in practice.
Why separate Creative from Ad?
A single creative may be reused by many ads and ad groups. Embedding creative data in Ad would repeat both asset metadata and the review state. In addition, the review process is independent: a creative is reviewed once, then reused.
To avoid data exposure across advertisers, require ad_group.advertiser_id to equal creative.advertiser_id. This can be checked in application code, or advertiser_id can be added to Ad and enforced with foreign keys.
Why use a separate TargetingRule table rather than JSONB on AdGroup?
Putting the entire targeting definition into one JSONB document on AdGroup may seem appealing, but:
The dedicated table using dimension, operator, and values provides both benefits: JSONB keeps value lists flexible, while the dimension and ad_group_id columns retain a queryable, indexable structure.
Why represent money as BIGINT cents?
Floating-point calculations introduce rounding problems (). Integer cents eliminate those errors. For example, $12.75 is saved as 1275. Calculations remain integer-based, and conversion to dollars occurs only in the presentation layer.
advertiser_id on AdGroup, a standalone Creative entity, and integer-cent money storage.dimension, operator, and JSONB values permits new targeting dimensions without schema revisions, which is vital for a rapidly evolving ads product.advertiser_id on AdGroup is a purposeful performance optimization with a stated reason, rather than accidental duplication.[object Object]