Build a call detail record system for a mobile carrier. Process millions of calls and texts, compute billing in real time, and analyze network usage patterns.

Note
This tutorial assumes you have a running 3-node Ferrosa cluster. If you have not set one up yet, follow the cluster-setup guide first — it only takes about 5 minutes.

Create the Keyspace

Telecom data is high volume and high value. A single carrier can generate billions of call records per month. Let’s create a keyspace with triple replication so nothing gets lost.

CREATE KEYSPACE IF NOT EXISTS telecom
  WITH replication = {
    'class': 'SimpleStrategy',
    'replication_factor': 3
  };

With RF=3, your call detail records survive any single node failure. In production, you would use NetworkTopologyStrategy to spread replicas across data centers, but SimpleStrategy is perfect for learning.

Design the Schema

Telecom data has a natural time-series shape: every call, text, and data session has a subscriber, a timestamp, and a set of measurements. We will design four tables around the most common access patterns.

Call Records

This is the core table. We use a compound partition key of (subscriber_id, call_date) so that each day’s records for a subscriber land on the same partition. This keeps partitions from growing unbounded over months of data.

CREATE TABLE IF NOT EXISTS call_records (
    subscriber_id    text,
    call_date        date,
    call_time        timestamp,
    call_id          uuid,
    call_type        text,       -- voice, sms, data
    duration_seconds int,
    data_bytes       bigint,
    destination      text,
    cell_tower       text,
    cost             decimal,
    PRIMARY KEY ((subscriber_id, call_date), call_time, call_id)
) WITH CLUSTERING ORDER BY (call_time DESC, call_id ASC);

Subscribers

Basic subscriber information: plan details, usage limits, and current balance.

    subscriber_id        text PRIMARY KEY,
    name                 text,
    plan_type            text,
    monthly_limit_minutes int,
    monthly_limit_data_gb int,
    balance              decimal,
    activated_at         timestamp
);

Billing Summary

Pre-aggregated monthly totals. Rather than scanning millions of call records at bill time, we maintain running totals. The billing month is stored as text (like '2026-03') for easy range queries.

    subscriber_id       text,
    billing_month       text,
    total_voice_minutes int,
    total_sms           int,
    total_data_mb       bigint,
    total_cost          decimal,
    PRIMARY KEY (subscriber_id, billing_month)
) WITH CLUSTERING ORDER BY (billing_month DESC);

Network Events

Cell tower health, outages, and maintenance logs. Partitioned by tower and date so network operations teams can quickly pull up a tower’s history.

    cell_tower   text,
    event_date   date,
    event_time   timestamp,
    event_type   text,
    severity     text,
    description  text,
    PRIMARY KEY ((cell_tower, event_date), event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

Four tables, each designed for a specific team: billing, customer service, network operations. That is how you model telecom data — start with the queries, work backward to the schema.

Add Subscribers

Let’s create three subscribers with different plan types. In a real carrier, you might have millions — but the access pattern is the same.

-- ── Subscribers ──

INSERT INTO subscribers (subscriber_id, name, plan_type, monthly_limit_minutes, monthly_limit_data_gb, balance, activated_at)
VALUES ('SUB-1001', 'Alice Nakamura', 'unlimited', -1, 50, 85.00, '2024-06-15 10:00:00');

INSERT INTO subscribers (subscriber_id, name, plan_type, monthly_limit_minutes, monthly_limit_data_gb, balance, activated_at)
VALUES ('SUB-1002', 'Ben Carlisle', 'prepaid', 500, 10, 22.50, '2025-01-20 14:30:00');

INSERT INTO subscribers (subscriber_id, name, plan_type, monthly_limit_minutes, monthly_limit_data_gb, balance, activated_at)

Alice has an unlimited plan (we use -1 for no minute cap). Ben is on prepaid with a smaller balance. Carmen has a family plan with shared limits. Each represents a different billing scenario.

Ingest Call Records

Now let’s simulate a day’s worth of activity. In production, these records would stream in from network switches at thousands per second. Here we will insert them one at a time, but the schema handles bulk writes just as well.

-- ── Call Records ──

-- Alice Nakamura: March 17, 2026
INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1001', '2026-03-17', '2026-03-17 08:12:00', uuid(), 'voice', 245, 0, '+1-555-0199', 'TWR-NYC-042', 0.00);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1001', '2026-03-17', '2026-03-17 09:45:00', uuid(), 'data', 0, 52428800, 'streaming', 'TWR-NYC-042', 0.00);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1001', '2026-03-17', '2026-03-17 12:30:00', uuid(), 'sms', 0, 0, '+1-555-0234', 'TWR-NYC-015', 0.00);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1001', '2026-03-17', '2026-03-17 14:05:00', uuid(), 'voice', 480, 0, '+1-555-0301', 'TWR-NYC-015', 0.00);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1001', '2026-03-17', '2026-03-17 18:20:00', uuid(), 'data', 0, 104857600, 'video-call', 'TWR-NYC-042', 0.00);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1001', '2026-03-17', '2026-03-17 21:10:00', uuid(), 'sms', 0, 0, '+1-555-0199', 'TWR-NYC-042', 0.00);

-- Ben Carlisle: March 17, 2026 (prepaid, costs apply)
INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1002', '2026-03-17', '2026-03-17 07:30:00', uuid(), 'voice', 120, 0, '+1-555-0455', 'TWR-CHI-008', 0.24);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1002', '2026-03-17', '2026-03-17 10:15:00', uuid(), 'sms', 0, 0, '+1-555-0488', 'TWR-CHI-008', 0.10);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1002', '2026-03-17', '2026-03-17 13:00:00', uuid(), 'data', 0, 20971520, 'browsing', 'TWR-CHI-012', 0.50);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1002', '2026-03-17', '2026-03-17 16:45:00', uuid(), 'voice', 900, 0, '+44-20-7946-0958', 'TWR-CHI-008', 4.50);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1002', '2026-03-17', '2026-03-17 19:30:00', uuid(), 'sms', 0, 0, '+1-555-0455', 'TWR-CHI-012', 0.10);

-- Carmen Delgado: March 17, 2026
INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1003', '2026-03-17', '2026-03-17 06:50:00', uuid(), 'voice', 360, 0, '+1-555-0612', 'TWR-MIA-003', 0.36);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1003', '2026-03-17', '2026-03-17 11:20:00', uuid(), 'data', 0, 209715200, 'app-download', 'TWR-MIA-003', 1.00);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1003', '2026-03-17', '2026-03-17 15:00:00', uuid(), 'voice', 1200, 0, '+52-55-1234-5678', 'TWR-MIA-007', 6.00);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1003', '2026-03-17', '2026-03-17 17:40:00', uuid(), 'sms', 0, 0, '+1-555-0612', 'TWR-MIA-003', 0.10);

INSERT INTO call_records (subscriber_id, call_date, call_time, call_id, call_type, duration_seconds, data_bytes, destination, cell_tower, cost)
VALUES ('SUB-1003', '2026-03-17', '2026-03-17 20:15:00', uuid(), 'data', 0, 524288000, 'streaming', 'TWR-MIA-003', 2.50);

That is 20 call records across three subscribers. Notice how Ben’s international call to London at 16:45 costs $4.50 — that is the kind of detail billing systems need to capture. Alice’s unlimited plan shows $0.00 for everything.

Generate Billing Summaries

In a real system, you would compute these with a batch job or stream processor. For this tutorial, we will insert the pre-computed summaries directly.

-- ── Billing Summaries ──

INSERT INTO billing_summary (subscriber_id, billing_month, total_voice_minutes, total_sms, total_data_mb, total_cost)
VALUES ('SUB-1001', '2026-03', 142, 38, 4200, 85.00);

INSERT INTO billing_summary (subscriber_id, billing_month, total_voice_minutes, total_sms, total_data_mb, total_cost)
VALUES ('SUB-1001', '2026-02', 128, 45, 3800, 85.00);

INSERT INTO billing_summary (subscriber_id, billing_month, total_voice_minutes, total_sms, total_data_mb, total_cost)
VALUES ('SUB-1002', '2026-03', 67, 22, 1500, 18.40);

INSERT INTO billing_summary (subscriber_id, billing_month, total_voice_minutes, total_sms, total_data_mb, total_cost)
VALUES ('SUB-1002', '2026-02', 45, 18, 980, 12.75);

INSERT INTO billing_summary (subscriber_id, billing_month, total_voice_minutes, total_sms, total_data_mb, total_cost)
VALUES ('SUB-1003', '2026-03', 210, 55, 8500, 110.00);

INSERT INTO billing_summary (subscriber_id, billing_month, total_voice_minutes, total_sms, total_data_mb, total_cost)
VALUES ('SUB-1003', '2026-02', 195, 62, 7200, 110.00);

Log Network Events

Cell towers generate their own stream of events: maintenance windows, capacity warnings, and outages. Let’s add a few realistic entries.

-- ── Network Events ──

INSERT INTO network_events (cell_tower, event_date, event_time, event_type, severity, description)
VALUES ('TWR-NYC-042', '2026-03-17', '2026-03-17 03:00:00', 'maintenance', 'info', 'Scheduled firmware update completed successfully');

INSERT INTO network_events (cell_tower, event_date, event_time, event_type, severity, description)
VALUES ('TWR-NYC-042', '2026-03-17', '2026-03-17 14:22:00', 'capacity', 'warning', 'Active connections at 85% capacity, consider load balancing');

INSERT INTO network_events (cell_tower, event_date, event_time, event_type, severity, description)
VALUES ('TWR-CHI-008', '2026-03-17', '2026-03-17 09:15:00', 'outage', 'critical', 'Power failure detected, switching to backup generator');

INSERT INTO network_events (cell_tower, event_date, event_time, event_type, severity, description)
VALUES ('TWR-CHI-008', '2026-03-17', '2026-03-17 09:18:00', 'recovery', 'info', 'Backup generator online, service restored in 3 minutes');

INSERT INTO network_events (cell_tower, event_date, event_time, event_type, severity, description)
VALUES ('TWR-MIA-003', '2026-03-17', '2026-03-17 20:00:00', 'capacity', 'warning', 'Streaming traffic surge, bandwidth utilization at 92%');

Notice the Chicago tower had a brief outage at 9:15 AM but recovered quickly. In a real NOC (Network Operations Center), these events would trigger alerts and populate dashboards.

Run Operational Queries

Let’s run the queries that different teams in a telecom company use every day.

Call History for a Subscriber

Customer service needs this constantly. Because we partitioned by (subscriber_id, call_date), pulling up a single day is a single-partition read — the fastest query Ferrosa can do.

-- Alice's calls on March 17
SELECT call_time, call_type, duration_seconds, destination, cost
FROM call_records
WHERE subscriber_id = 'SUB-1001'

Daily Usage Summary

How much data did a subscriber use today? This is the kind of query that powers "check my usage" features in mobile apps.

-- Ben's data usage on March 17
SELECT call_time, data_bytes, destination
FROM call_records
WHERE subscriber_id = 'SUB-1002'
  AND call_date = '2026-03-17'

Billing Summary

Pull up a subscriber’s bill history, most recent first.

ALLOW FILTERING;

-- Carmen's billing history

Network Events by Tower

When a tower has issues, the NOC team needs its event log immediately.

FROM billing_summary
WHERE subscriber_id = 'SUB-1003';

-- All events for Chicago tower on March 17

Subscriber Balance

A quick lookup for prepaid customers who want to check their remaining credit.

FROM network_events
WHERE cell_tower = 'TWR-CHI-008'
  AND event_date = '2026-03-17';

High-Cost Calls

Find expensive calls — useful for fraud detection or helping customers understand their bills.

SELECT name, plan_type, balance, monthly_limit_minutes, monthly_limit_data_gb
FROM subscribers
WHERE subscriber_id = 'SUB-1002';

-- Ben's calls that cost more than $1.00
SELECT call_time, call_type, destination, duration_seconds, cost

Subscriber Profile Update

When a prepaid customer tops up their account, you update their balance. This is a single-partition write — fast and atomic.

WHERE subscriber_id = 'SUB-1002'
  AND call_date = '2026-03-17'
  AND cost > 1.00

Voice Calls Only

Customer service often needs to pull up just voice calls for a dispute. Filter by call_type to narrow it down.

-- Ben tops up his prepaid balance by $20
UPDATE subscribers
SET balance = 42.50
WHERE subscriber_id = 'SUB-1002';

Carmen made two voice calls — a local one and a 20-minute international call to Mexico. That international call at $6.00 would be the one to look at if she calls in to dispute her bill.

Month-Over-Month Comparison

Pull two months of billing data for a subscriber to spot usage trends. This is useful for "your usage this month" dashboards.

SELECT call_time, destination, duration_seconds, cost
FROM call_records
WHERE subscriber_id = 'SUB-1003'
  AND call_date = '2026-03-17'
  AND call_type = 'voice'

Alice’s data usage went from 3,800 MB in February to 4,200 MB in March. If she is approaching her 50 GB cap, the app could trigger a proactive notification.

Tip
Partition design tip: The compound partition key (subscriber_id, call_date) keeps each day’s records in a bounded partition. Without the date component, a subscriber with years of history would create a massive partition — exactly the kind of problem Ferrosa’s data model helps you avoid.

What You Learned

You just built the data layer for a mobile carrier. Here is a summary of everything you accomplished:

  • Designed a compound partition key to keep call record partitions bounded by day

  • Used descending clustering order so the most recent records always come back first

  • Built a pre-aggregated billing table to avoid expensive full-table scans at bill time

  • Modeled network events with tower-and-date partitioning for fast NOC lookups

  • Ran operational queries across billing, customer service, and network operations use cases

The data model you built here handles the three things telecom systems care about most: high write throughput for call records, fast lookups for customer service, and time-bounded queries for billing. Ferrosa’s partition-based architecture makes all three efficient at any scale.