SaaS Codebase Review: Common Mistakes Founders Make

There's a moment that exposes every shortcut at once: an enterprise prospect asks for a security audit before they sign, and you hand over the repo you were proud of. A few days later the report comes back with production secrets in your commit history, full-table scans freezing the database, and a UserService that's four thousand lines long. A SaaS codebase review is how you find those things before the auditor — or a 3am outage — finds them for you.
These eight come up in nearly every early-stage repo we look at. None are exotic; all are cheap to fix now and expensive to fix later. A defect caught in design is roughly 1x to fix and the same defect in production runs 60–100x more — the exact multiplier is genuinely disputed, but the direction isn't, and teams already burn 33–42% of their time servicing debt they shipped earlier (Stripe). So: the eight mistakes, and the fixes.

Mistake 1: Boolean Soft Deletes That Break Constraints
Founders add is_deleted BOOLEAN to avoid hard deletes — sensible — until a unique constraint meets it:
1ALTER TABLE users ADD CONSTRAINT unique_user_email UNIQUE (tenant_id, email);A user deletes their account, tries to re-register with the same email, and the insert crashes — the "deleted" row still occupies the unique slot. The fix is a nullable timestamp plus a partial unique index:
1CREATE UNIQUE INDEX idx_users_active_email
2ON users (tenant_id, email)
3WHERE deleted_at IS NULL;Now the constraint applies only to active rows, and re-registration just works.
Mistake 2: Secrets in Git History
Hardcode a database password or API key, commit it, and even if you delete it next commit, it's in your history forever. The fix going forward: environment variables, a gitignored .env, and a scanner like GitGuardian in CI to catch the next slip — exactly the discipline in our environment variable management guide. For one already leaked, a new commit isn't enough: rewrite history with git-filter-repo or BFG, force-push, and rotate the key, because you have to assume it's already been scraped.
Mistake 3: No Indexes Until Production Crashes
Everything's instant on a few hundred test rows, so missing indexes hide:
1-- O(N) full-table scan once this table is large.
2SELECT * FROM invoices WHERE tenant_id = 'company_abc' AND status = 'unpaid';At hundreds of thousands of rows that scan locks up your database threads. Add a composite index matching how you query:
1CREATE INDEX idx_invoices_tenant_status ON invoices (tenant_id, status);Index the columns you filter, join, and sort on — not every column — the same indexing discipline that turns a slow dashboard fast.

Mistake 4: God Services
Ship fast and unrelated logic piles into one file — a UserService doing auth, payments, emails, and metrics in four thousand lines. It violates single responsibility, and it's untestable, undebuggable, and unsplittable. Break domains into focused modules; a clean NestJS project structure is what keeps the God service from forming in the first place.
Mistake 5: No Immutable Audit Trail
Enterprise buyers need to know who changed what. Overwrite an invoice's status to "Paid" with no history and you can't answer "who authorized this, and when?" — a failing answer in a security review. Keep an append-only audit_logs table capturing the actor, timestamp, and before/after values on every critical state change. Enterprise buyers don't ask whether you have it; they ask to query it.
Mistake 6: Blocking the Event Loop
Render a PDF, optimize an image, or call a slow third-party API inside the request and you block Node's single thread — every other request hangs behind it until it finishes. Push heavy work to a background queue backed by Redis, return immediately, and notify when it's done. The request thread is for responding, not for grinding.
Mistake 7: No Error Telemetry
If you find out about production crashes from support tickets, you're flying blind. console.log to a rotating file doesn't cut it — a premium client's endpoint can fail silently for hours. Wire in Sentry (or similar) from day one to capture unhandled exceptions with stack traces and alert you before the ticket arrives.

Mistake 8: Weak Password Hashing
A surprising number of repos still hash passwords with MD5 or SHA-1 — fast algorithms, which is precisely wrong for passwords, and trivially cracked with modern hardware and rainbow tables. Use a slow, adaptive KDF: Argon2id (preferred) or bcrypt, with a sensible work factor, via a vetted library — never a hand-rolled hash. The OWASP Password Storage Cheat Sheet has the parameters, and our auth mistakes guide covers the rest of the boring-but-critical layer.
SaaS Codebase Review: The Checklist
| Layer | The mistake | The fix |
|---|---|---|
| Data | Boolean soft delete | deleted_at + partial unique index |
| Secrets | Keys in Git | Env vars + scanner + history purge |
| Queries | No indexes | Composite indexes on real patterns |
| Structure | God services | Focused, single-responsibility modules |
| Audit | Overwrite state | Append-only audit log |
| Concurrency | Blocking the loop | Background queue for heavy work |
| Observability | console.log only | Real error telemetry |
| Auth | MD5/SHA-1 | Argon2id / bcrypt |
None of these eight are hard to fix — they're hard to find once a codebase is large and shipping, which is exactly why you run the review before the audit does. Spend an afternoon on this list now and the enterprise security review becomes a formality instead of a fire drill. Cutting these corners doesn't save time; it borrows it at a brutal interest rate — and the bill always comes due on the deal you most wanted to close.
Frequently Asked Questions
A soft-deleted row still physically exists, so a UNIQUE constraint on email still counts it. When a user deletes their account and re-registers with the same email, the insert fails on the old 'deleted' row. The fix is a nullable deleted_at timestamp plus a partial unique index — UNIQUE (tenant_id, email) WHERE deleted_at IS NULL — so the constraint only applies to active rows and re-registration works.
A new commit deleting the key doesn't remove it — it's still in history forever. You have to rewrite history with git-filter-repo or BFG Repo-Cleaner, force-push, and then rotate the key, because you must assume it's already compromised. Prevent the next one by keeping secrets in environment variables, gitignoring .env, and adding a secret scanner like GitGuardian to CI.
No. Indexes speed up reads but tax every write, since the database has to update the index on each INSERT/UPDATE/DELETE. Index the columns you actually filter, join, or sort on — usually composite indexes matching your real query patterns, like (tenant_id, status). Indexing everything slows writes and wastes space without helping the queries you don't run.
Argon2id (preferred) or bcrypt — slow, adaptive key-derivation functions with a tunable work factor. Never MD5 or SHA-1: they're fast, which is exactly wrong for passwords, and trivially brute-forced with modern hardware and rainbow tables. Use the vetted library, never a hand-rolled hash, and follow the OWASP Password Storage guidance for parameters.
Before they ship. A defect caught in design costs roughly 1x to fix; the same defect in production runs far more — the long-cited figure is 60–100x, and while the exact multiplier is debated, the direction is solid. Teams already spend 33–42% of their time servicing technical debt (Stripe). Fixing these eight during a codebase review is the cheapest they'll ever be.
