Back to Blog

Architecture / SaaS / FeathersJS / Node.js / Spring Boot / Fintech

Multi-Tenant SaaS Architecture: Lessons from Building an ePayment Platform

How we designed a multi-tenant system for container depot services across Singapore — tenant isolation in MongoDB, banking middleware, and the trade-offs we made.

|10 min read

Introduction

When we built an ePayment platform for container depot services in Singapore, the first architectural decision was how to handle multi-tenancy. Multiple depot operators, transporters, and banks needed to share the platform while keeping their invoices, approval workflows, and transaction data completely isolated.

This post covers the architecture we chose, involving a FeathersJS-based web service and a specialized Java-based banking gateway.

Tenancy Models: Shared Schema with MongoDB

We chose a shared database, shared schema approach using MongoDB and Mongoose. In our platform, "tenants" are modeled as companies (Organizations).

Every major record—from invoices to users—is linked to a company. For example, an invoice explicitly tracks who it's billBy (the depot) and billFor (the transporter), both of which are references to the companies collection.

1 // Mongoose Schema for Invoices
2 const invoices = new Schema({
3 billNumber: { type: String },
4 billBy: { type: Schema.Types.ObjectId, ref: 'companies', required: true },
5 billFor: { type: Schema.Types.ObjectId, ref: 'companies', required: true },
6 containerNumber: { type: String, required: true },
7 amount: { type: Number },
8 status: { type: String }
9 });

This model allowed us to easily generate network-wide reports while maintaining strict data boundaries through FeathersJS hooks that automatically inject the user's organizationId into queries.

Integration Middleware: Banking Gateway

A unique aspect of our architecture is the separation between business logic and banking integrations. While the main ePayment Web Service handles the SaaS logic, we built a dedicated Spring Boot service as a Banking Integration Gateway to interface with the national payment network.

This service acts as a secure gateway, handling JKS keystores, digital signatures, and 2-way SSL with the bank's servers. By offloading the complex payment protocol to a specialized Java service, we kept our main Node.js application lean and focused on the multi-tenant workflow.

The Hard Parts: Approval Matrices

Each depot operator has its own rules for who can approve an invoice and at what amount. Instead of hardcoding these rules, we built an Approval Matrix service.

Each organization defines its own approval-matrix, mapping users to different levels (e.g., Level 1 for small amounts, Level 3 for large transactions). This "workflow multi-tenancy" was significantly more complex to implement than simple data isolation, as it required dynamic resolution of notification targets and status transitions based on the tenant's specific configuration.

Background Jobs with Agenda

We used Agenda (a MongoDB-backed job scheduler) instead of a traditional message queue like RabbitMQ. This allowed us to keep our infrastructure simple, as we already relied on MongoDB for our primary storage.

Jobs are scheduled with tenant context, allowing us to process batch tasks like invoice generation or payment status reconciliations independently for each organization.

1 // Example job processing with Agenda
2 agenda.define('generate-monthly-invoices', async (job) => {
3 const { companyId, month } = job.attrs.data;
4 // Process only for the specific organization
5 const invoices = await app.service('invoices').find({
6 query: { billBy: companyId, month }
7 });
8 // ... generation logic
9 });

What I'd Do Differently

  • Role-Based Access Control (RBAC) Refinement: Our organization-type-roles grew complex quickly. Implementing a more granular, capability-based system from the start would have simplified the logic.
  • Service Isolation: While our banking gateway was a great start, offloading more "utility" functions (like PDF generation or heavy Excel exports) to specialized microservices would have improved the main app's responsiveness during peak billing periods.

Conclusion

Multi-tenant architecture in a regulated industry like container logistics requires more than just a tenant_id column. It requires a robust organization model, flexible approval workflows, and a secure way to interface with external financial networks. By combining the agility of FeathersJS with the security of a Spring Boot banking gateway, we created a platform that scales across the industry while maintaining the strict isolation each operator demands.

This work spanned several years across Singapore-based logistics clients — see more of my SaaS and fintech experience or read Designing CI/CD Pipelines for Multi-Environment Deployments for how we shipped these platforms to production.

Written by Erik Yuntantyo·Software Engineer·About me