Jan 15, 2025
Building Oniyore: A Multi-Tenant B2B Platform
8 min · Architecture · Backend · Go · PostgreSQL · SaaS
Building Oniyore: A Multi-Tenant B2B Platform
The journey of building a SaaS platform that scales horizontally while maintaining data isolation, security, and rapid deployment capabilities is one of the most challenging yet rewarding aspects of full-stack engineering. At Oniyore, we've built exactly this - a multi-tenant B2B platform designed to deploy new client websites in under an hour.
The Challenge: Multi-Tenancy at Scale
When you're building a platform that serves multiple clients (tenants), you face a fundamental architectural question: How do you isolate client data, maintain security, and keep deployment simple?
The traditional approaches are:
1. Database-per-tenant: Each client gets their own database. Maximum isolation but operational complexity.
2. Schema-per-tenant: Each client gets their own PostgreSQL schema within a shared database. Good balance of isolation and simplicity.
3. Row-level security (RLS): Single database, tenant isolation via RLS policies. Simple ops but security risks if misconfigured.
We chose schema-per-tenant because it offers the best balance for a rapidly scaling platform.
Architecture Overview
Here's how Oniyore is structured:
┌─────────────────────────────────────────────────────────┐
│ Frontend Tier │
│ (Next.js 15 + React 19 + TypeScript) │
│ ┌──────────────────┬──────────────────┬──────────────┐ │
│ │ Client 1 Site │ Client 2 Site │ Client N │ │
│ │ (Vercel) │ (Vercel) │ (Vercel) │ │
│ └──────────────────┴──────────────────┴──────────────┘ │
└─────────────────────────────────────────────────────────┘
│ │ │
├──────────────────┴──────────────────┘
│
┌─────────────────────────────────────────────────────────┐
│ API Gateway / Load Balancer │
└─────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────┐
│ Backend Service Tier │
│ ┌──────────────────────────────────────────────────┐ │
│ │ option-backend (Go + Gin) │ │
│ │ - REST API (/api/v1/content/*, /api/v1/admin/*) │ │
│ │ - Authentication & Authorization │ │
│ │ - Tenant routing & isolation │ │
│ │ - Form handling & analytics │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ option-agent (Node.js) │ │
│ │ - Content generation (Claude API) │ │
│ │ - Web scraping (Puppeteer) │ │
│ │ - Social platform search (Reddit, HN, etc) │ │
│ │ - Job consumer (async task processing) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────┐
│ Data Persistence Tier │
│ ┌────────────────────────────────────────────────┐ │
│ │ PostgreSQL 16 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ suba_ │ │ client2_ │ │ clientN_ │ │ │
│ │ │ prod │ │ prod │ │ prod │ │ │
│ │ │ (schema) │ │ (schema) │ │ (schema) │ │ │
│ │ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ Redis 7 (Cache & Job Queue) │ │
│ │ - Session store │ │
│ │ - Rate limiting │ │
│ │ - Job queue for async tasks │ │
│ └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Schema-Per-Tenant Implementation
With schema-per-tenant, each client gets an isolated namespace within PostgreSQL:
// backend/pkg/middleware/tenant.go
func TenantMiddleware(c *gin.Context) {
// Extract tenant from domain or header
tenant := extractTenant(c)
// Create new database context with tenant schema
db := gorm.DB{} // Initialize with shared connection
db = db.WithContext(c.Request.Context())
// Set search path to tenant's schema
db = db.Exec("SET search_path TO $1", tenant.Schema)
// Store in request context for use in handlers
c.Set("db", db)
c.Set("tenant", tenant)
c.Next()
}
This approach provides:
- Data Isolation: Each client's data is in a separate schema, invisible to other clients
- Simple Deployment: No need to manage multiple database instances
- Shared Infrastructure: Same PostgreSQL instance serves all clients
- Easy Scaling: Adding a new client just means creating a new schema and running migrations
Service Separation: Backend vs Agent
One of the critical architectural decisions was separating concerns between two distinct services:
option-backend (Synchronous)
The REST API that serves immediate requests:
- User-facing endpoints
- Authentication & authorization
- Content CRUD operations
- Form submissions
- Rate limiting & security
option-agent (Asynchronous)
The AI-powered task processor:
- Content generation via Claude API
- Web scraping for competitive analysis
- Social platform searching
- Long-running operations
- Job queue consumption
This separation means:
[Client Request] → option-backend → [Immediate Response]
↓
[Job to Redis Queue]
↓
option-agent
↓
[Process Async, Write Results]
Why this matters: The backend never blocks on slow operations. When a client requests content generation, the backend immediately queues the job and returns a response. The agent picks it up asynchronously and stores results when complete.
Deployment Strategy
One of Oniyore's core values is rapid deployment. A new client site should go live in under an hour.
Step 1: Clone Frontend Template
cp -r option-site suba-site
cd suba-site
# Edit configuration for new client
Step 2: Create Backend Tenant
# Create PostgreSQL schema
psql << EOF
CREATE SCHEMA suba_production;
GRANT ALL ON SCHEMA suba_production TO app_user;
EOF
# Run migrations
psql -U postgres -d maindb \
-c "SET search_path TO suba_production;" \
-f migrations/001_init.sql
Step 3: Deploy Frontend
git push
# Connected to Vercel for auto-deployment
# Site live at suba.example.com
Step 4: Configure Backend
# backend/config/tenants.yaml
tenants:
- id: suba_machinery
domain: suba.example.com
schema: suba_production
api_key: <generated-key>
Step 5: Verify
curl https://api.example.com/v1/tenants/suba/config
curl https://suba.example.com/api/products
# Both should respond successfully
Database Schema Design
Each tenant schema contains identical tables with proper indexing:
-- shared across all tenant schemas
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10, 2),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
INDEX idx_products_tenant_id (tenant_id),
INDEX idx_products_name (name)
);
CREATE TABLE features (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_features_tenant_id (tenant_id)
);
CREATE TABLE form_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(50) NOT NULL,
form_type VARCHAR(50),
data JSONB,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_submissions_tenant_id (tenant_id),
INDEX idx_submissions_created (created_at)
);
Security Considerations
Schema isolation provides the first line of defense, but we implement additional layers:
1. TLS for all communication: All data in transit is encrypted
2. API key authentication: Each tenant gets a unique API key
3. Row-level validation: Even if a query somehow crosses schemas, we validate tenant_id matches
4. Rate limiting: Prevent abuse through Redis-backed rate limiters
5. Input validation: All user input validated server-side and sanitized
Performance Optimization
With multiple tenants on shared infrastructure, performance isolation is critical:
// Database connection pooling
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
ConnPool: &gorm.ConnPool{
MaxIdleConns: 5,
MaxOpenConns: 20,
MaxLifetime: time.Hour,
},
})
// Redis caching layer
type CacheRepository struct {
redis *redis.Client
db *gorm.DB
}
func (r *CacheRepository) GetProduct(ctx context.Context, id string) (*Product, error) {
// Try cache first
cached, err := r.redis.Get(ctx, fmt.Sprintf("product:%s", id)).Result()
if err == nil {
return parseProduct(cached), nil
}
// Fall through to database
product := &Product{}
if err := r.db.WithContext(ctx).Where("id = ?", id).First(product).Error; err != nil {
return nil, err
}
// Cache for 1 hour
r.redis.Set(ctx, fmt.Sprintf("product:%s", id), product, time.Hour)
return product, nil
}
Lessons Learned
1. Schema Isolation is Powerful
Starting with schema-per-tenant was the right choice. It's operationally simple while providing strong data isolation.
2. Service Separation Prevents Bottlenecks
Separating sync (backend) from async (agent) work means slow operations never block user requests.
3. Rapid Deployment is a Feature
When you can deploy a new client in under an hour, you compete on speed. Every minute of deployment time costs money and client satisfaction.
4. Monitoring is Non-Negotiable
With multiple tenants, one problematic client can't take down everyone. Implement tenant-aware monitoring:
prometheus.NewCounter(prometheus.CounterOpts{
Name: "api_requests_total",
Help: "Total API requests",
LabelNames: []string{"tenant", "endpoint", "method", "status"},
})
5. Testing Must Be Tenant-Aware
Every test should create a temporary tenant schema and verify isolation:
func TestProductIsolation(t *testing.T) {
tenant1 := createTestTenant(t, "tenant1")
tenant2 := createTestTenant(t, "tenant2")
// Create product in tenant1
product := createProduct(t, tenant1, "Product A")
// Verify tenant2 cannot see it
products := getProducts(t, tenant2)
assert.NotContains(t, products, product)
}
Conclusion
Building Oniyore taught us that multi-tenant architecture isn't about choosing one technique - it's about layering multiple approaches:
1. Schema isolation for data protection
2. Service separation for operational resilience
3. Rapid deployment for competitive advantage
4. Monitoring at scale for reliability
The result is a platform that can scale to hundreds of clients while maintaining security, performance, and the ability to deploy a new site in under an hour.
That's the Oniyore story - and it's just the beginning.