# A Data Model for your Firebase SaaS

> Learn how to architecture the data model of a SaaS application with Firebase Firestore

*Published: 2022-03-19*
*Canonical: https://makerkit.dev/blog/tutorials/data-model-saas-firebase*

---

There are tons of resources on the Internet for making a SaaS data model,
but very few use NoSQL or Firebase Firestore.

Firestore is fairly simple to use, but structuring it well requires
analysis from a different point of view compared to other databases:

- **securing the data** using [Firestore Security Rules](/blog/tutorials/in-depth-guide-firestore-security-rules)
- optimizing queries and writes to **save on billing costs** (remember, we pay
for reads/writes and the data transferred)
- due to the querying limitations in Firestore, ensuring that the
**data-model allows our queries**

Developing an architecture without taking into account the points above can
result in **expensive and inefficient decisions**.

We developed MakerKit with simplicity in mind, in a way such that you
can effortlessly adapt the data model we created to your application's domain.

In this post, we explore **the data model we at MakerKit adopted for our
own SaaS boilerplate**. Feel free to comment, take it and extend it as you wish!

### Overview of a simple SaaS data model

Every SaaS has a different data model, but we can identify certain entities
that will be pretty common across most apps.

These are:
- Users/Accounts
- Groups of users, such as organizations/teams/projects
- Invites a member to join a group
- A plan/membership the group subscribed to

The above is the core part of a SaaS and what any SaaS boilerplate should
provide by default. However, getting it right for every use case and scenario is impossible:
hence, MakerKit's model is exhaustive yet straightforward.

## Users and Accounts

Users/Accounts are the primary and most basic entities of most applications.
Thanks to Firebase, the aspect of authenticating accounts is separate from
the users' data, which helps simplify our job.

Whenever a user creates a new account for your application using Firebase
Authentication, this **will be added to the Auth Database, but not to
Firestore**.

So, how can we make sure that a user also has its relative
record in Firestore? There are a couple of ways.

## Creating user records in Firestore
First, what you should know is that the most common way of creating user
records in Firestore is to create a `users` collection.

We can set as the ID of the user record the same ID created by Firebase
Authentication, so we can keep them easily in sync and make sure it's unique
across the collection.

Let's assume the ID created by Firebase is `423631`, then we will create a
record in Firestore at the path: `/users/423631`.

#### Creating a record after sign-up

The simplest way is to create a Firestore record right after sign-up. It's
also not the most straightforward when you take into account that **the
transaction isn't atomic** and any
errors can happen between the sign-up and
the creation of the record, which may result in a poor UX and an out-of-sync DB.

#### Creating a record as part of an Onboarding Wizard

The below is my favorite way, especially for collecting valuable data for and about
your users.

After sign-up, your route guard checks that the user is onboarded or
that the user record exists. You can do this using a token's `custom metadata,`
for example.

If not, redirect to the onboarding wizard,
which will take care of creating a user record and its relative organization.

The above is the approach taken by MakerKit.

## Organizations

Organizations, or groups of users, contain information about their members
and their role within the organization.

In MakerKit, organizations have a property named `members,` which is a map
containing information about the member. **We use an object to simplify writing security rules**.

The following is the interface of an organization:

 ```tsx {% title="organization.ts" %}
type UserId = string; // this is the ID assigned by Firebase Auth

interface Organization {
  name: string;
  members: Record<UserId, FirestoreOrganizationMembership>;

  timezone?: string;
  logoURL?: string | null;
  subscription?: OrganizationSubscription;
  customerId?: string;
}
```

Let's focus on the organization members for the time being.

Each member has the following interface:

 ```tsx {% title="member.ts" %}
interface Member {
  role: MembershipRole;
  user: UserRef;
}
```

As you can see, we stored two properties:
- `role` is an enum that defines the user's role. This is very simple
and doesn't allow for granular permissions, which you may needs
- `user` is a Firestore `reference` to the user record for simplifying access to the user
record. It can also be an ID.

### Querying Organizations and Members

How does the data structure above allow us to fulfill the following use-cases?

- Fetch all the organizations the current user belongs to
- Fetch all the members of the selected organization
- Make sure our security rules can allow us to enforce permissions and allow
for efficient querying

#### Fetching data for a specific organization

The user's current organization is passed in via a cookie when the page gets
rendered using SSR. By selecting the first organization, the cookie
will hold the value of the current organization ID selected for the user.

We will use this cookie to fetch the data of the current organization. To do
so, we can query the path `/organizations/{id}`, using the following hook:

 ```tsx {% title="use-fetch-organization.ts" %}
function useFetchOrganization(organizationId: string) {
  const firestore = useFirestore();
  const path = `/organizations`;

  const ref = doc(
    firestore,
    path,
    organizationId
  );

  return useFirestoreDocData(ref);
}
```

##### Security Rules to read an Organization

How can we structure our Firestore Security Rules to allow this
query?

We can check if the authed user ID is within the members `map` of the
organization.

The function below allow us to read the current user ID:

 ```js {% title="firestore.rules" %}
function userId() {
    return request.auth.uid;
}
```

We can now check if the ID exists in the members' `map`:

 ```js {% title="firestore.rules" %}
match /organizations/{organizationId} {
  allow read: if userId() in existingData().members;
}
```

#### Fetching a user's organizations

To list all the organizations a user belongs to, we can check if the user ID
appears to be within the `members` property of the organization.

The hook below will allow us to list the user's organizations:

 ```tsx {% title="use-fetch-user-organizations.ts" %}
export function useFetchUserOrganizations(userId: string) {
  const firestore = useFirestore();
  const path = `organizations`;

  const organizationsCollection = collection(
    firestore,
    path
  );

  const userPath = `members.${userId}`;
  const operator = '!=';
  const constraint = where(userPath, operator, null);

  const organizationsQuery = query(
    organizationsCollection,
    constraint
  );

  return useFirestoreCollectionData(organizationsQuery);
}
```

### Invites

We store `invites` to join an organization as a collection under each
organization, such as `organizations/1/invites/1`.

Because we may need to access an invitation without knowing the organization
ID, we have two ways to go:

- do not store invitations as a `subcollection`, but as a top-level collection
- create an index using the Firestore Console, which allows us to query deep
collection using the `collectionGroup` method. At MakerKit, we decided to go
with the second way

#### Defining the Invite's data model

Below is the interface of an `Invite`:

 ```tsx {% title="membership-invite.ts" %}
interface MembershipInvite {
  email: string;
  role: MembershipRole;
  code: string;
  expiresAt: number;

  organization: {
    id: string;
    name: string;
  };
}
```

We stored the email of the user invited, which can also be helpful if you
want to restrict the invited user's email to the same one provided by the
inviter.

Additionally, it's essential to store the role assigned by the invitee, the
the time when we consider the invite expired, and a unique code `code`
which we can use to expose and retrieve the invite.

We also add (duplicate) some information about the organization to embed it
into the invitation email. Adding the organization saves one more read each time we query the
invite.

The invite gets sent with a URL similar to the following: `/auth/invite/{code}`. We know which invite we need to process by adding the code to the URL.

After the invite is processed, it gets deleted from the DB.

#### Security Rules for Invites

To securely list the invites to join an organization, we need to
check that the member reading the invites belongs to it:

 ```js {% title="firestore.rules" %}
match /organizations/{organizationId} {
    match /invites/{inviteId} {
        allow list: if userIsMemberByOrganizationId(organizationId);
    }
}
```

As you may have noticed above, we can access the organization ID,
which also allows us to query the organization document.

The functions below help us evaluate if, given an organization ID, the
current user belongs to it.

 ```js {% title="firestore.rules" %}
function userIsMemberByOrganizationId(organizationId) {
    return userIsMember(
        getOrganization(organizationId)
    );
}

function getOrganization(organization) {
    return get(/databases/$(database)/documents/organizations/$(organization));
}

function userIsMember(organization) {
    let role =
        getUserRole(organization, userId());

    return role >= 0;
}

function getUserRole(organization, userId) {
    let members = organization.data.members;
    let member = members[userId];

    return member != null ? member.role : -1;
}
```

Yes, it looks very similar, but the code above **is not Javascript**!

## Payments and Subscriptions

Without payments, your SaaS won't get paid. It's needless to say that any
SaaS will eventually need a payment system.

The most popular for startups is probably Stripe, but it's also common
having to integrate different billing
systems for hosting your SaaS on marketplaces: for instance, you may have to
get paid via Apple if you host your app on iOS, or using Shopify Billing if
you allow shops to install the app via the Shopify Marketplace.

In practice, it means we will need a model generic enough to adapt to
multiple payments processors.

### Subscription Model

The model below is added to an `Organization` when creating a plan. We consider it an optional field because
creating a plan **may or may not happen**.

 ```tsx {% title="organization-subscription.ts" %}
type PaymentProcessor = string;

enum OrganizationPlanStatus {
  AwaitingPayment = 'awaitingPayment',
  Paid = 'paid',
}

interface OrganizationSubscription {
    id: string;
    planId: string; // priceId in Stripe
    processor: PaymentProcessor;

    status: OrganizationPlanStatus;
    currency: string | null;

    interval: string | null;
    intervalCount: number | null;

    createdAt: UnixTimestamp;
    periodStartsAt: UnixTimestamp;
    periodEndsAt: UnixTimestamp;
    trialStartsAt: UnixTimestamp | null;
    trialEndsAt: UnixTimestamp | null;
}
```

The above is the bare minimum amount of data you may want to store; depending on
your needs, you may want to collect more information from the payments
processors' webhooks.

### Security Rules for Subscriptions

A membership is probably not something you want clients able to modify, no
matter which role the user has.

To avoid any issues, we will need to write a security rule that disallows
updating the `subscription` fields of an
organization.

 ```js {% title="firestore.rules" %}
match /organizations/{organizationId} {
    allow update: if canUpdateOrganization(organizationId);
}

function canUpdateOrganization(organizationId) {
    let isMember = userIsMemberByOrganizationId(organizationId);
    let noDisallowedFields = fieldsNotInUpdateAction(['members', 'subscription']);

    return isMember && noDisallowedFields;
}
```

As an alternative to this, you can also store subscriptions in their
collection. The only downside to this is that it takes one additional read
to grab the subscriptions' data.

## Final Words

Time to wrap this post. The above is an introduction to how we modeled our
Firestore data structure to support the basics needs of any SaaS application,
while keeping it open for change; as it will inevitably happen, there will
be lots of changes.

I hope you've found this helpful post. Consider [contacting us](/contact) if
you need any help with starting to build your SaaS application with Next.js
and Firebase. We're here to help.
