Grokking the System Design Interview, Volume II
Vote

0% completed

​

Step 6: Data Storage and Schema

SQL Tables

tenants

users

user_preferences

notification_templates

scheduled_notifications

audit_logs

NoSQL Collections

notifications

notification_status

A wrong row in the preferences table sends an SMS to someone who refused it. A missing row in the delivery history costs one line in a support report. The two kinds of data in this design fail in such different ways that they belong in different databases.

The configuration a send uses (tenants, users, preferences, templates) is small, changes slowly, and must be correct. Relational tables give it constraints, foreign keys, and transactions. The history a send produces (every notification and every change to its status) is written constantly, grows to billions of records, is read by one key, usually the user ID. It can also be a little behind without harm. A NoSQL store handles that load by adding nodes.

The diagram below shows both groups, and a table for each store follows.

The data model. Six SQL tables hold the configuration a send is decided from, and two NoSQL collections hold the history it produces.
The data model. Six SQL tables hold the configuration a send is decided from, and two NoSQL collections hold the history it produces.

SQL Tables

The tables are normalized and connected by foreign keys. A foreign key is a column that must match a row in another table, so it prevents broken references.

tenants

One row per tenant, the application or organization using the service.

Field NameData TypeDescription
tenant_idINT (PK)Unique ID of the tenant (primary key).
nameVARCHAR(255)Name of the application or organization.
contact_emailVARCHAR(255)Email of the tenant's administrator or support team.
created_atDATETIMEWhen the tenant was registered.
statusVARCHAR(50)"active" or "inactive"; turns all sending for this tenant on or off.

Every other table points to a tenant, which keeps each application's data separate, and the status flag switches a tenant's sending on or off in one update.

users

One row per person who receives notifications. Every user belongs to a tenant.

Field NameData TypeDescription
user_idINT (PK)Unique ID of the user (primary key).
tenant_idINT (FK)Tenant this user belongs to (foreign key to tenants).
nameVARCHAR(100)Full name of the user.
emailVARCHAR(255)Address for email notifications.
phoneVARCHAR(20)Number for SMS notifications.
created_atDATETIMEWhen the account was created.
statusVARCHAR(50)Account status, for example, "active" or "disabled".

Because the database enforces the tenant_id foreign key, a notification can only point to a user who exists and belongs to the calling tenant.

user_preferences

One row per user, holding which channels the user has turned on or off.

Field NameData TypeDescription
user_idINT (PK, FK)The user (primary key, foreign key to users), so each user has one record.
email_notificationsBOOLEANWhether the user accepts email notifications.
sms_notificationsBOOLEANWhether the user accepts SMS notifications.
push_notificationsBOOLEANWhether the user accepts push and in-app notifications.
language_preferenceVARCHAR(10)Optional preferred language, for example, "en" or "es".
updated_atDATETIMEWhen the preferences last changed.

Keeping preferences apart from the profile lets new preference fields arrive without touching user data, and the check before a send becomes a single lookup.

notification_templates

Reusable message templates, written once and used by many notifications.

Field NameData TypeDescription
template_idINT (PK)Unique ID of the template.
tenant_idINT (FK)Tenant that owns the template (foreign key to tenants).
nameVARCHAR(100)Template key, for example, "WelcomeEmail" or "PasswordReset".
subjectVARCHAR(255)Subject line, where the channel has one, such as email.
bodyTEXTBody with placeholders, for example, "Hello {username}, ...".
channelVARCHAR(50)Channel the template is for: "email", "sms", or "push".
created_atDATETIMEWhen the template was created.
updated_atDATETIMEWhen the template was last changed.

Each template belongs to one tenant and one channel. An edit is one transaction, so every later send uses the approved text.

scheduled_notifications

Notifications that should go out later, like reminders or announcements at a set time.

Field NameData TypeDescription
schedule_idINT (PK)Unique ID of the scheduled entry.
tenant_idINT (FK)Owning tenant (foreign key to tenants).
user_idINT (FK)Recipient (foreign key to users).
template_idINT (FK)Template to use (foreign key to notification_templates).
scheduled_timeDATETIMEWhen the notification should be sent.
statusVARCHAR(50)"scheduled", "sent", or "canceled".
created_atDATETIMEWhen the schedule was created.
created_byVARCHAR(50)Who scheduled it: a user ID or a system or admin account.

The scheduler's query is simple: every row with status scheduled due in the next minute. As each job is handled, status moves to sent or canceled, and the send itself becomes an ordinary record in the notifications collection below.

audit_logs

A record of important events for compliance and debugging: notifications sent, preference changes, template edits, and admin actions.

Field NameData TypeDescription
log_idBIGINT (PK)Unique ID of the entry.
tenant_idINT (FK)Tenant involved, if any (foreign key to tenants).
user_idINT (FK)User involved, if any, such as the user notified or whose preferences changed.
actionVARCHAR(100)The event, for example, "NOTIFICATION_SENT", "PREFERENCE_UPDATED", or "TEMPLATE_MODIFIED".
detailsTEXTExtra detail, such as old and new values or an error message.
performed_byVARCHAR(100)Who acted: a user ID, an admin ID, or a system process.
timestampDATETIMEWhen the event was logged.

Compliance records must not be lost or silently changed. ACID transactions guarantee that a write either completes fully or not at all, and an audit report filters this table by tenant or user.

NoSQL Collections

The tables above decide what gets sent, and the two collections below record what was sent, which is far more data. A store like MongoDB or Cassandra handles millions of records and a high write rate by adding nodes.

notifications

The notification history. Each record is one message sent to one user.

Field NameData TypeDescription
notification_idUUID/String (PK)Unique ID of the notification, such as a UUID or a document store ObjectID.
tenant_idINT or StringTenant, copied here for quick filtering because NoSQL reads do not join.
user_idINT or StringUser who received the notification.
template_idINT or StringTemplate used, if any.
messageTEXT/JSONThe content actually sent, as text or structured JSON.
channelSTRINGChannel used, for example, "email", "SMS", or "push".
sent_atDATETIMEWhen the notification was sent or created.
metadataJSON (optional)Extra context, such as the subject line or the template parameters.

Keeping both message and template_id is not redundant. The text is what was actually sent, so it survives a later template edit, and the ID records which template produced it, so every message from a faulty template can be found later.

tenant_id and user_id are copied into every record because the common queries are "all notifications for this user" and "for this tenant", and each document must carry what those reads need. Some duplication is the price of fast reads and simple scaling.

The in-app inbox reads this collection by user, newest first, so the store keeps it partitioned by user_id and ordered by sent_at. In Cassandra, that is a partition key of user_id with sent_at as a descending clustering column; in MongoDB, it is a compound index on the same two fields. Either way, "the latest 20 for this user" reads one partition in order, without sorting. Records older than the retention window, such as 90 days, can expire through a time-to-live setting rather than a delete job.

notification_status

Tracks the delivery status of a notification, apart from its content: sent, delivered, read, or failed, and when each change happened.

Field NameData TypeDescription
status_idUUID/String (PK)Unique ID of this status record, generated or derived from the notification ID and timestamp.
notification_idUUID/StringThe notification this update belongs to, in the notifications collection.
user_idINT or StringRecipient, copied from notifications for queries by user.
statusSTRING"sent", "delivered", "opened", "failed", or "read".
updated_atDATETIMEWhen this status was recorded.
detailsJSON (optional)Extra detail, such as the error message or the device that received it.

Content is written once, while status changes several times: sent at one moment, delivered at another, read later. Appending each change as a small document is cheap, never rewrites the larger notification record, and avoids several writers updating one document at the same time. The status history can also be queried separately for analytics and debugging.

Dead letter storage. A notification that fails every retry goes to a dead letter queue, a separate store for undeliverable messages, where a person can inspect it. This can be the queue's own mechanism, such as a dedicated Kafka topic or a RabbitMQ dead letter exchange, or a table FailedNotifications(notification_id, user_id, channel, error_code, last_attempt_time) that the operations team reviews. Once the cause is fixed, the messages can be reprocessed, and they remain evidence when a provider misbehaves.

Reading Progress

0%


Vote for new content

On This Page

SQL Tables

tenants

users

user_preferences

notification_templates

scheduled_notifications

audit_logs

NoSQL Collections

notifications

notification_status