0% completed
Step 6: Data Storage and Schema
On This Page
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.
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 Name | Data Type | Description |
|---|---|---|
| tenant_id | INT (PK) | Unique ID of the tenant (primary key). |
| name | VARCHAR(255) | Name of the application or organization. |
| contact_email | VARCHAR(255) | Email of the tenant's administrator or support team. |
| created_at | DATETIME | When the tenant was registered. |
| status | VARCHAR(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 Name | Data Type | Description |
|---|---|---|
| user_id | INT (PK) | Unique ID of the user (primary key). |
| tenant_id | INT (FK) | Tenant this user belongs to (foreign key to tenants). |
| name | VARCHAR(100) | Full name of the user. |
| VARCHAR(255) | Address for email notifications. | |
| phone | VARCHAR(20) | Number for SMS notifications. |
| created_at | DATETIME | When the account was created. |
| status | VARCHAR(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 Name | Data Type | Description |
|---|---|---|
| user_id | INT (PK, FK) | The user (primary key, foreign key to users), so each user has one record. |
| email_notifications | BOOLEAN | Whether the user accepts email notifications. |
| sms_notifications | BOOLEAN | Whether the user accepts SMS notifications. |
| push_notifications | BOOLEAN | Whether the user accepts push and in-app notifications. |
| language_preference | VARCHAR(10) | Optional preferred language, for example, "en" or "es". |
| updated_at | DATETIME | When 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 Name | Data Type | Description |
|---|---|---|
| template_id | INT (PK) | Unique ID of the template. |
| tenant_id | INT (FK) | Tenant that owns the template (foreign key to tenants). |
| name | VARCHAR(100) | Template key, for example, "WelcomeEmail" or "PasswordReset". |
| subject | VARCHAR(255) | Subject line, where the channel has one, such as email. |
| body | TEXT | Body with placeholders, for example, "Hello {username}, ...". |
| channel | VARCHAR(50) | Channel the template is for: "email", "sms", or "push". |
| created_at | DATETIME | When the template was created. |
| updated_at | DATETIME | When 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 Name | Data Type | Description |
|---|---|---|
| schedule_id | INT (PK) | Unique ID of the scheduled entry. |
| tenant_id | INT (FK) | Owning tenant (foreign key to tenants). |
| user_id | INT (FK) | Recipient (foreign key to users). |
| template_id | INT (FK) | Template to use (foreign key to notification_templates). |
| scheduled_time | DATETIME | When the notification should be sent. |
| status | VARCHAR(50) | "scheduled", "sent", or "canceled". |
| created_at | DATETIME | When the schedule was created. |
| created_by | VARCHAR(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 Name | Data Type | Description |
|---|---|---|
| log_id | BIGINT (PK) | Unique ID of the entry. |
| tenant_id | INT (FK) | Tenant involved, if any (foreign key to tenants). |
| user_id | INT (FK) | User involved, if any, such as the user notified or whose preferences changed. |
| action | VARCHAR(100) | The event, for example, "NOTIFICATION_SENT", "PREFERENCE_UPDATED", or "TEMPLATE_MODIFIED". |
| details | TEXT | Extra detail, such as old and new values or an error message. |
| performed_by | VARCHAR(100) | Who acted: a user ID, an admin ID, or a system process. |
| timestamp | DATETIME | When 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 Name | Data Type | Description |
|---|---|---|
| notification_id | UUID/String (PK) | Unique ID of the notification, such as a UUID or a document store ObjectID. |
| tenant_id | INT or String | Tenant, copied here for quick filtering because NoSQL reads do not join. |
| user_id | INT or String | User who received the notification. |
| template_id | INT or String | Template used, if any. |
| message | TEXT/JSON | The content actually sent, as text or structured JSON. |
| channel | STRING | Channel used, for example, "email", "SMS", or "push". |
| sent_at | DATETIME | When the notification was sent or created. |
| metadata | JSON (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 Name | Data Type | Description |
|---|---|---|
| status_id | UUID/String (PK) | Unique ID of this status record, generated or derived from the notification ID and timestamp. |
| notification_id | UUID/String | The notification this update belongs to, in the notifications collection. |
| user_id | INT or String | Recipient, copied from notifications for queries by user. |
| status | STRING | "sent", "delivered", "opened", "failed", or "read". |
| updated_at | DATETIME | When this status was recorded. |
| details | JSON (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%
On This Page
SQL Tables
tenants
users
user_preferences
notification_templates
scheduled_notifications
audit_logs
NoSQL Collections
notifications
notification_status