1. Multi-Tenancy Is an Architecture Decision, Not a Feature
My first SaaS was a time-tracking system for cafes (now one of my portfolio projects). I initially built it as a single-tenant app and tried to "add multi-tenancy later." Big mistake.
There are three main approaches to multi-tenancy:
- Shared database, shared schema — a tenant_id column on every table. Simplest but riskiest.
- Shared database, separate schemas — each tenant gets their own PostgreSQL schema. Great balance.
- Separate databases — maximum isolation but painful to manage at scale.
I ended up going with schema-per-tenant in PostgreSQL, which gave me true data isolation without the overhead of separate databases. Read more about this approach in my deep dive on PostgreSQL schema-per-tenant architecture.
# The decision matrix I use now:
# < 10 tenants -> separate schemas
# 10-1000 tenants -> shared schema + tenant_id
# 1000+ tenants -> shared schema + sharding
2. Build Billing Integration From Day One
I built the entire product first, then tried to add Stripe billing. This meant retrofitting user flows, adding subscription checks to every protected view, and handling edge cases like expired trials.
What I should have done:
class SubscriptionMiddleware:
def __call__(self, request):
if not request.user.is_authenticated:
return self.get_response(request)
tenant = request.user.tenant
if tenant.subscription_status == 'expired':
if not request.path.startswith('/billing/'):
return redirect('/billing/renew/')
return self.get_response(request)
Lesson: integrate Stripe (or your payment provider) in sprint 1, not sprint 10. Even if it is just a dummy checkout flow, the architecture needs to support it from the start.
3. Onboarding Flow Is Your Most Important Feature
My first version dropped users into an empty dashboard after signup. No tutorial, no sample data, no guidance. The result? A 70% drop-off rate within the first 5 minutes.
What worked:
- Pre-populated sample data so the dashboard looks alive
- A 3-step setup wizard (company name, first employee, first time entry)
- Contextual tooltips on key features
- A "getting started" checklist that tracks progress
After implementing proper onboarding, the 7-day retention rate jumped from 15% to 45%.
4. You Need Monitoring Before You Need Features
In the first month after launch, I had no idea how users were actually using the product. No error tracking, no performance monitoring, no usage analytics. When a critical bug affected 3 tenants, I only found out when they emailed me.
# Minimum monitoring stack for a SaaS:
# 1. Error tracking (Sentry)
import sentry_sdk
sentry_sdk.init(dsn="your-dsn", traces_sample_rate=0.1)
# 2. Application logging
import logging
logger = logging.getLogger(__name__)
logger.info("Tenant %s created employee %s", tenant.id, emp.id)
# 3. Business metrics
def track_event(tenant_id, event, metadata=None):
Metric.objects.create(
tenant_id=tenant_id,
event=event,
metadata=metadata or {},
timestamp=timezone.now()
)
5. Your Pricing Model Will Change — Design for It
I started with a flat monthly fee. Then customers asked for per-employee pricing. Then enterprise clients wanted annual contracts with custom features. Each pricing change required database migrations and billing logic rewrites.
The pattern that works: store pricing as configuration, not code.
class Plan(models.Model):
name = models.CharField(max_length=50)
price_monthly = models.DecimalField(max_digits=8, decimal_places=2)
max_employees = models.IntegerField(null=True) # null = unlimited
max_locations = models.IntegerField(null=True)
features = models.JSONField(default=dict)
# e.g. {"excel_export": true, "api_access": false}
This way, you can create new plans, adjust limits, and add features without touching code.
The Bottom Line
Building a SaaS is 20% coding and 80% product decisions. Get the architecture right early (multi-tenancy, billing, monitoring), invest heavily in onboarding, and keep your pricing model flexible. If you are planning a SaaS project, I would love to discuss your requirements — I have made these mistakes so you don't have to.