0% completed
Notification System: Data Storage and Schema
On This Page
Step 6: Data Storage and Schema
SQL Tables
tenants
users
user_preferences
notification_templates
scheduled_notifications
audit_logs
NoSQL Collections
notifications
notification_status
Step 6: Data Storage and Schema
So far we have moved notifications through services, queues, and workers. Now we decide where the data behind them is kept.
This design stores two kinds of data. One kind is small, changes slowly, and has to be right. The other is huge, is written constantly, and can be a little behind. No single database is good at both, so we use two.
SQL tables hold the small, structured data: tenants, users, preferences, and templates. A wrong preference here sends a message to somebody who refused it. Relational tables give constraints, foreign keys, and transactions to keep that from happening.
NoSQL collections hold the history: every notification sent, and every change to its status. This data is written constantly and grows to billions of records. It is read by one key, usually the user id. It can be a little behind without harm. A NoSQL store scales that load by adding nodes.
That one split explains every choice in this step. Configuration must be right and is small. History must scale and is not small.
The diagram below shows both groups and how they connect. After it comes a table for each store, listing every field, its type, and what it is for. A note after each table says why it is shaped that way.
SQL Tables
The SQL side holds data that is small and changes slowly: tenants, users, preferences, templates, scheduled jobs, and audit logs. The tables are normalized, and foreign keys tie them together. A foreign key is a column that must match a row in another table, so a broken reference cannot be stored.
tenants
One row per tenant. A tenant is an application or organization that uses the notification system.
| Field Name | Data Type | Description |
|---|---|---|
| tenant_id | INT (PK) | Unique identifier for the tenant (primary key). |
| name | VARCHAR(255) | Name of the tenant application or organization. |
| contact_email | VARCHAR(255) | Contact email for the tenant's administrator or support. |
| created_at | DATETIME | Timestamp when the tenant was registered. |
| status | VARCHAR(50) | Status of the tenant (for example, "active", "inactive"), used to enable/disable notifications for this tenant. |
Every other table points at a tenant, which is how each application's data stays apart from the rest. The row itself holds little: a name, a contact, and a status flag that switches the tenant on or off. It rarely changes, so a relational table fits it well.
users
One row per person who receives notifications. Every user belongs to a tenant.
| Field Name | Data Type | Description |
|---|---|---|
| user_id | INT (PK) | Unique identifier for the user (primary key). |
| tenant_id | INT (FK) | Identifier of the tenant this user belongs to (foreign key to tenants). |
| name | VARCHAR(100) | Full name of the user. |
| VARCHAR(255) | Email address of the user (used for email notifications). | |
| phone | VARCHAR(20) | Phone number of the user (used for SMS or phone notifications). |
| created_at | DATETIME | Timestamp when the user account was created. |
| status | VARCHAR(50) | Account status of the user (for example, "active", "disabled"). |
The tenant_id foreign key ties each user to the right application. Because the database enforces it, a notification can only point at a user who exists. Joins to preferences or templates stay simple when we need them.
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) | Identifier of the user (primary key, foreign key to users). Each user has one preferences record. |
| email_notifications | BOOLEAN | Whether the user wants to receive email notifications (true = opt-in). |
| sms_notifications | BOOLEAN | Whether the user wants to receive SMS/text notifications. |
| push_notifications | BOOLEAN | Whether the user wants to receive push/in-app notifications. |
| language_preference | VARCHAR(10) | (Optional) Preferred language or locale for notifications (for example, "en", "es"). |
| updated_at | DATETIME | Timestamp of the last update to this user's preferences. |
Preferences live apart from the profile, so we can add a new preference field without touching user data. Each row is one flag per channel. The check before a send is then a single lookup: has this user opted in to email?
notification_templates
Reusable message templates. A template is written once and used by many notifications.
| Field Name | Data Type | Description |
|---|---|---|
| template_id | INT (PK) | Unique identifier for the notification template. |
| tenant_id | INT (FK) | Tenant that owns this template (foreign key to tenants), allowing tenants to have custom templates. |
| name | VARCHAR(100) | Template name or key (for example, "WelcomeEmail", "PasswordReset"). |
| subject | VARCHAR(255) | Subject line for the notification (if applicable, for example, for email notifications). |
| body | TEXT | Body content of the template, with placeholders for dynamic data (for example, "Hello {username}, ..."). |
| channel | VARCHAR(50) | Channel/type of notification this template is for (for example, "email", "sms", "push"). |
| created_at | DATETIME | Timestamp when the template was created. |
| updated_at | DATETIME | Timestamp when the template was last modified. |
Each template belongs to one tenant and one channel, and holds placeholders for the user's data. Templates change rarely and are shared by many sends, so they need strong consistency. An update is one transaction, and every later send uses the approved text. Versioning can be added if needed.
scheduled_notifications
Notifications that should go out later, like a reminder or an announcement at a set time.
| Field Name | Data Type | Description |
|---|---|---|
| schedule_id | INT (PK) | Unique identifier for the scheduled notification entry. |
| tenant_id | INT (FK) | Tenant context for the notification (foreign key to tenants), helpful for multi-tenant filtering. |
| user_id | INT (FK) | The intended recipient user's ID (foreign key to users). |
| template_id | INT (FK) | Reference to the notification template to use (foreign key to notification_templates). |
| scheduled_time | DATETIME | Date and time when the notification is scheduled to be sent. |
| status | VARCHAR(50) | Current status of the scheduled notification (for example, "scheduled", "sent", "canceled"). |
| created_at | DATETIME | Timestamp when this schedule was created. |
| created_by | VARCHAR(50) | Identifier of who scheduled the notification (could be a user ID or system/admin user). |
Each row ties one user, one template, and one send time. Transactions stop the same job from being scheduled twice. The query the scheduler runs is then simple: find every row due in the next hour.
The status field moves from scheduled to sent or canceled as the job is handled. When the time arrives, the system reads the row and creates a real notification event. That event is recorded in the NoSQL notifications collection, described 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 identifier for the audit log entry. |
| tenant_id | INT (FK) | Tenant associated with the event (foreign key to tenants), if applicable. |
| user_id | INT (FK) | User associated with the event (if applicable, for example, the user who was notified or whose preferences changed). |
| action | VARCHAR(100) | Description of the action or event (for example, "NOTIFICATION_SENT", "PREFERENCE_UPDATED", "TEMPLATE_MODIFIED"). |
| details | TEXT | Additional details about the event (for example, notification content snippet, old vs new values for changes, error messages if any). |
| performed_by | VARCHAR(100) | Who performed the action (could be a user id, admin id, or system process name). |
| timestamp | DATETIME | Date and time when the event was logged. |
Compliance needs records that cannot be changed and cannot be lost. A SQL table gives that through ACID guarantees, so a write either completes fully or not at all, and what is written stays.
A sent notification can be logged here as well as in the NoSQL history, so regulators get records that are easy to query. Preference and template changes are logged too, with who did what and when. Filter by tenant or user to build an audit report.
NoSQL Collections
The tables above decide what gets sent. The two collections below record what was sent, and there are far more of those records than there are rows above.
The NoSQL collections hold that high-volume data: the notifications themselves and their delivery status. A store like MongoDB or Cassandra scales to millions of records and a high write rate by adding nodes. It also allows a flexible schema, so one document can carry fields the next one does not. Each notification is one document. Each change to its status is a separate document.
notifications
The notification history. Each record is one notification event: one message sent to one user.
| Field Name | Data Type | Description |
|---|---|---|
| notification_id | UUID/String (PK) | Unique identifier for the notification event (could be a UUID or auto-generated ObjectID in a document store). |
| tenant_id | INT or String | Tenant identifier for context (duplicates the tenant for quick filtering in NoSQL, since joins aren't used). |
| user_id | INT or String | ID of the user who received the notification. |
| template_id | INT or String | Reference to the template used (if any) for this notification. |
| message | TEXT/JSON | The content of the notification sent (could be text, JSON for structured content, etc.). |
| channel | STRING | Delivery channel used (for example, "email", "SMS", "push"). |
| sent_at | DATETIME | Timestamp when the notification was sent (or created in the system). |
| metadata | JSON (optional) | Additional metadata for the notification (for example, context info like subject line, recipient info, or template parameters used). |
Every notification for every user is logged here, and the transactional SQL store never takes that load. Each document holds the content and the context, which is what an in-app inbox or history feature reads.
The record keeps both the message text and the template_id, and that is not redundant. The text is what was sent, so it survives a later template edit. The id says which template produced it, so every message from a bad template can be found later. The channel says how it was delivered, so a user's history can be filtered by channel.
tenant_id and user_id are copied into every record. That duplicates relational data on purpose. NoSQL reads do not join, and the common query is "all notifications for this user" or "for this tenant". So each document carries everything a read needs. That is the trade NoSQL makes: some duplication for fast reads and easy scaling.
notification_status
Tracks the delivery status of a notification, apart from its content. It records whether a notification was sent, delivered, read, or failed, and how that changed over time.
| Field Name | Data Type | Description |
|---|---|---|
| status_id | UUID/String (PK) | Unique identifier for this status record (a generated ID, or one derived from the notification id and timestamp). |
| notification_id | UUID/String | Identifier of the notification this status update corresponds to (foreign key reference to a record in the notifications collection). |
| user_id | INT or String | ID of the user who is the recipient of the notification (for convenient querying by user, duplicates from notifications). |
| status | STRING | Delivery status value (for example, "sent", "delivered", "opened", "failed", "read"). |
| updated_at | DATETIME | Timestamp when this status was recorded (when the status changed or was reported). |
| details | JSON (optional) | Additional details about the status, if any (for example, error message if failed, device info for delivery, or read timestamp). |
The two collections are split because the two halves are written so differently. The content is written once. The status changes several times: sent at time A, delivered at time B, read at time C.
Each of those is a new document in notification_status, so the large notification record is never rewritten. Status updates are frequent, and appending small documents is cheap. It also avoids many writers updating one record at the same time.
Each entry carries notification_id and user_id for easy lookup. The status history can then be queried on its own, for analytics or debugging. The pattern is common in other systems: a receivers-and-statuses collection that links a notification to a recipient and a state. Logging status this way shows who received or read each notification, and it scales without heavy joins.
Dead letter queue storage. Some notifications fail every retry. Those go to a dead letter queue, a separate store for messages that could not be delivered. A person can look at them later.
One option is a table FailedNotifications(notification_id, user_id, channel, error_code, last_attempt_time) that the operations team reviews. Another is the dead letter queue built into the message queue, like a Kafka topic or a RabbitMQ DLQ.
Once the cause is fixed, these records can be reprocessed. They are also evidence when a provider misbehaves.
Next: Step 7, which examines each component in turn.
Reading Progress
0%
On This Page
Step 6: Data Storage and Schema
SQL Tables
tenants
users
user_preferences
notification_templates
scheduled_notifications
audit_logs
NoSQL Collections
notifications
notification_status