Breaking down login models

You've just added Devise to your Gemfile and bundled. Now it's time to make the migration to add a user and in no time at all you end up with a migration that looks like this.

create_table :users do |t|
  t.string :email
  t.string :firstname
  t.string :lastname
  t.string :encrypted_password
  t.integer :company_id
end

It's a seemingly innocent start which has one conclusion. The "User" model will become a god model that becomes increasingly fat over time. We will explore why this is not ideal and explore data models to build an increasingly complicated login systems.

Single Responsibility Principle

The single responsibility principle states that each class or module needs to be responsible for one thing. This is quite easy to understand but hard to implement. What is the responsibility of the user model? So far we are using it to store and check login information as well as profile data. Without even making the class yet we assigned it two responsibilities!

Avoid the god model

So let's break this login model up.

create_table :users do |t|
  t.string :username
  t.string :encrypted_password
end

create_table :profiles do |t|
  t.integer :company_id
  t.integer :user_id
  t.string :email
  t.string :firstname
  t.string :lastname
end

Our User model will now become the model used to check the login and the Profile model will contain data about that user. Each model is back to one responsibility.

Every SaaS application should start with this as a base. No matter where you go or what features you will have in the future, this data model is going to be extensible without any sort of data migrations.

There is one drawback to this model which requires a bit of extra work. If you want the username to be the persons email, then we have to ensure we update the fields on both the user and profile table. However, given the flexibility gained, it is worth the time investment.

Let's look into the future

Now that you have user and profile information we are up and running and building the application. But there are still quite a few more feature requirements before we have a fully built SaaS application. How do they pay? What permissions does each user have? What if I want a user to belong to more than one company? How do my users invite other users?

Adding user invites

We often need to invite people into our application. There are two ways we can model this. We start with a simple solution and add an invites table. This contains the information about the invite that is sent.

But often times as a feature in our applications we want to give the new user data. I want to add them into the system and start using their data to set up tasks for them or get other data in the application ready for them when they join. The model above starts to break down because the profile will not exist until they sign up. Let's take a look:

This is the first place where we pick up gains from separating our two models. If we tried to do this with a combined user and profile the user is going to fail its password validation requirements. Sure we could fight through it. But if you find yourself fighting the data model, it's probably not the best one to use.

The company model

So this is great. I invited a user, but I want to make sure they can see the same data since they belong to the same company. This is a pretty easy addition of adding the company table and pointing the profile belong_to the company.

Let's go one more step further with the data model. Many people can stop here with their user requirements but there is one more common scenario. I want a single login to access multiple company environments. The first instance of this I can think of is Stripe. One unified login provides access to different companies.

This is useful for instance if you are building a dev tool that should be configurable in multiple environments. Other systems like a hospital network might have different environments for each location. However, the users should be able to have access to their locations without separate logins.

Luckily this is already supported from the above model! However, we are going to expand on devise helper methods. Adding Devise to our user model gave us the helper method current_user . However, we don't really interact with the user model. This is on purpose.

We can add two helper methods into the controller to help us out.

class AuthenticatedController < ApplicationController
   before_filter :authenticate_user
   
   def current_profile
     @_current_profile ||= Profile.find(cookies[:profile_id])
   end
   
   def current_company
     @_current_company ||= current_profile.company
   end
end

This will give us current_user, current_profile, and current_company. If you looked closely you will note that I am getting the current profile from the cookie (a session would be fine here too).

The profile becomes an important model because it contains which user and which company. By passing the profile around you can gain all the authorization and authentication logic you need.

After a user logs in, we will now need to check to see if they have a current_profile selected. If so we direct them to that companies dashboard. If they don't, we allow them to pick and set the cookie appropriately before directing them. In order to change companies, update the cookie value with the new profile.

But doesn't this mean I am duplicating data since email is now on every profile? Not quite. By structuring the model this way you pick up some features for free. Each company can have their own notification email. If you work for company A, you would not want to get alerts on company B's email system. This could be a tragic GDPR violation!

In addition, each company can configure their own profile information. Perhaps an employee picked up a nickname that is used in one company and not others. It is quite easy to customize details now on a per company level.

How do we know who made what?

One problem we run into now is tracking which companies were created by which profile. In order to do this, let's add a new model called Account. An account contains all the companies created by a specific user under it. It is the highest level model in the system and all other user based models will be a child of it.  

We can visually see how ultimately, everything revolves around an Account. Our account links together all the companies one person creates in their dashboard. In a future post we are going to use the Account as the base model for payments.

You could potentially use the company as the basis for payments depending on your requirements. If each company needs to submit payments individually it could make sense to use Company as the base. If you want a unified billing system where a single payment is provided for multiple companies, the Account model makes sense. Either way, once you choose a route, provide documentation to your support teams on why and what is possible with accounts.

Conclusion

When you have a single user model, it becomes common to increasingly overload it because it is the only model which is convenient to add relations to. At the very least, in the beginning split the login and profile models to support any possible login system in the future. Otherwise by the time the features become required, they are going to take extensive, time consuming and risky data migrations. In the worst case, you add an hour or two of dev time, in the best case you will save weeks.

Show Comments