Back

Multi-Tenant Architecture - Implementation

MD Rashid Hussain
MD Rashid Hussain
Sept-2024  -  7 minutes to read
Image source attributed to: https://www.gooddata.com

For quick revision on multi-tenant architecture, refer to my previous blog post on Multi-Tenant Architecture basics and Multi-Tenant Architecture - Planning and Design. In this blog post, I will be discussing the implementation of multi-tenant architecture in a real-world scenarios. well, almost.

We are going to use golang for our backend service. The backend will be a monolithic REST API service that will serve the frontend with the data in the form of JSON. We do no render any HTML in the backend. The frontend will be a separate service that will consume the backend API.

The backend will have a similar structure:

├── bin
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── go.sum
├── main.go
├── Makefile
├── models
│   └── main.go
├── modules
│   ├── auth
│   │   ├── controller.go
│   │   └── main.go
│   ├── host
│   │   ├── controller.go
│   │   └── main.go
│   └── permissions
│       ├── model.cof
│       └── main.go
├── public
│   ├── hello.txt
│   ├── icons
│   │   ├── favicon.ico
│   │   └── icon-full.png
│   └── uploads
└── utils
    ├── auth.go
    ├── db.go
    ├── helpers.go
    ├── module.go
    └── permissions.go
  • models - Contains the database models. (we are using gorm for database operations)
  • modules - Contains the modules for the application. Each module will have its own controller, model, and routes.
  • public - Contains the public files like images, icons, etc.
  • utils - Contains the utility functions like database connection, authentication, etc.
  • permissions are handled using the casbin library.
  • REST APIs implemented with the GoFiber framework.

For the full implementation, please look into the Awesome Backend repository. For the sake of simplicity, we are focusing on the multi-tenant part of the application.

// Tenant model
type Tenant struct {
	ID                       uint         `gorm:"primary_key;column:id" json:"id"`
	Name                     string       `gorm:"column:name;not null;unique" json:"name" validate:"required"`
	TenantUrl                string       `gorm:"column:tenantUrl;not null" json:"tenantUrl" validate:"required"`
	// We can also add fields to handle custom domains, primary and secondary domains,
	// but they are beyond the scope of this article
	CreatedAt                time.Time    `gorm:"column:createdAt; default:current_timestamp" json:"createdAt"`
	TenantDBConnectionString string       `gorm:"column:tenantDBConnectionString;not null;unique" json:"tenantDBConnectionString" validate:""`
	TenantOwnerID            uint         `json:"tenantOwnerId" gorm:"column:tenantOwnerId;not null" validate:"required"`
	TenantOwner              *TenantOwner `json:"tenantOwner" gorm:"column:tenantOwnerId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" validate:""`
}
// TenantOwner model
type TenantOwner struct {
	ID        uint      `gorm:"primary_key;column:id" json:"id"`
	Name      string    `gorm:"column:name;not null" json:"name" validate:"required"`
	Email     string    `gorm:"column:email;not null" json:"email" validate:"required"`
	Password  string    `gorm:"column:password;not null" json:"password" validate:"required"`
	CreatedAt time.Time `gorm:"column:createdAt; default:current_timestamp" json:"createdAt"`
}

Get Origin header from the request

origin := ctx.GetReqHeaders()["Origin"][0]

Maintain a map of database connections

type TenantConnection struct {
	Connection *gorm.DB
	CreatedAt  time.Time
}
 
// map of tenantUrl to tenant db connection
var tenantsDBMap = make(map[string]TenantConnection)

Here we maintain a CreatedAt field also, just to refresh connections in some predefined time interval. This is to ensure that if someone manually stops a certain client, the database connections also gets refreshed. This CONNECTION_REFRESH_INTERVAL depends on the type of app built and the severity of actions performed on the app.

At the time of picking the database connection, we can also check if it is past CONNECTION_REFRESH_INTERVAL time, if then, create a new connection, (and add to the tenant connection cache)

So, in a nutshell, the database connection function works like this

func GetTenantDB(tenantUrl string) (*gorm.DB, error) {
	if tenantUrl == "" {
		return nil, errors.New("tenantUrl is empty")
	}
 
	if tenantsDBMap[tenantUrl].Connection == nil || time.Since(tenantsDBMap[tenantUrl].CreatedAt) > CONNECTION_REFRESH_INTERVAL {
		db := GetHostDB()
		var tenant models.Tenant
		err := db.Where("\"tenantUrl\" = ?", tenantUrl).First(&tenant).Error
		if err != nil {
			fmt.Println("Error fetching tenant: ", err)
			return nil, err
		}
 
		// this check may be removed, because it is a required field in the DB schema
		if tenant.TenantDBConnectionString == "" {
			return nil, fmt.Errorf("tenantDBConnectionString is empty")
		}
 
		fmt.Println("Creating new tenant db connection for", tenantUrl)
		gormDB, err := GetDbConnection(tenant.TenantDBConnectionString)
		if err != nil {
			fmt.Println("Error initializing tenant db: ", err)
			return nil, err
		}
 
		tenantsDBMap[tenantUrl] = TenantConnection{
			Connection: gormDB,
			CreatedAt:  time.Now(),
		}
	}
 
	return tenantsDBMap[tenantUrl].Connection, nil
}
 
func GetDbFromRequestOrigin(ctx *fiber.Ctx) (*gorm.DB, error) {
	return GetTenantDB(ctx.GetReqHeaders()["Origin"][0])
}

This GetDbFromRequestOrigin function can be directly used to get the tenant db connection. Now you can do any CRUD operations with this database connection.

Now, creating new Tenants is not an easy process. In the demo application, I have keep this in manual mode. But you can try one of these methods.

A sepatate application: You can checkout Awesome Frontend admin app, where I set up creating a new tenant from a separate app. The only users there are the tenant owners.

A separate view: In the same application, users can create their own tenants, but it would be sligtly tricky in terms of how are you segregating requests coming from a different tenant origin to create a new tenant. Other than this, it will be pretty straightforeward

Some kind of Email Invites etc..

This is a very basic implementation of multi-tenant architecture. There are many things that can be improved, like handling custom domains, primary and secondary domains, etc. But this should give you a good starting point to implement multi-tenant architecture in your application.

For those looking for a more complete implementation with code, Please checkout the repositories Awesome Frontend and Awesome Backend for a complete code example.