Contents
Most of my client work is a sprint. This one was a marathon — I stayed with a single
platform for the better part of four years, from the first Investor App commit in
2022 to outreach-email fixes shipping in early 2026. That length is the interesting
part. You get to make a call, live with it for eighteen months, and find out whether
you were right.
It started as a venture-capital firm’s internal MVP: a Django + Vue tool where a fundraising startup builds a profile and gets matched against a large, curated database of investors and contacts. My job on paper was “backend engineer.” In practice I was the person deciding what the product should become, and it became a lot.
Growing the fundraising workflow
The MVP was a matching screen. Over the next year it grew into the whole job a founder actually does when raising: weighted match scoring, saved searches, ringfencing investor lists so two startups in the same portfolio don’t get pitched to the same fund, notes, warm-intro templates, and outreach campaigns run straight from the product. The outreach side layered up incrementally — a third-party sequencing API first, then a self-hosted SMTP/IMAP proxy so the platform could own its own sending, with email validation gated behind concurrency and daily-job caps so we didn’t torch domain reputation. None of that was one big design. It was accretion, guided by watching the firm’s team actually use the thing.
Unconventional call #1: whitelabel inside one deployment
In mid-2024 the firm decided the internal tool was good enough to sell — other companies wanted to run it as their own branded matching platform. The conventional move for “give each customer their own instance” is per-client deployments. I didn’t do that. I made it multi-tenant inside one deployment.
A middleware resolves the tenant from the request, and a SiteModel base class scopes
every tenant-owned query so one customer can never read another’s data. Each tenant is
its own Django Site with its own logo, trial and free-tier limits, and — the part that
mattered most — its own AI configuration.
class SiteCompany(SiteModel):
llm_vendor = models.CharField(default="claude") # claude | gpt | gemini | perplexity
keys = models.JSONField(default=dict) # tenant-supplied API credentials
The reason to eat the multi-tenancy complexity up front: one codebase, one CI pipeline, one place to fix a bug for everyone. The trade-off is that data isolation becomes a correctness property you have to defend on every query, not a deployment boundary you get for free. I’d make the same call again — the operational savings of not babysitting N deployments dwarfed the cost of being disciplined about tenant scoping.
Per-tenant, bring-your-own LLM
Whitelabel is what forced the LLM abstraction, and I think that’s the most product-y decision in the whole thing. A whitelabel customer doesn’t want to run their deal analysis through my AI account — they want their own vendor, their own billing, their own compliance posture. So the AI layer resolves the vendor and keys from the tenant:
def client_for(tenant: SiteCompany) -> LLMClient:
# one interface over Claude, GPT, Gemini and Perplexity
return LLMClient(vendor=tenant.llm_vendor, keys=tenant.keys)
Claude is the default; a tenant can switch to GPT, Gemini or Perplexity and drop in their own keys without touching code. Building the abstraction cost more than hardcoding one provider, but it turned “which LLM do you use?” from a blocker in a sales call into a config field.
Match scoring on Elasticsearch, and the reindex storm
By late 2024 the matching heuristic had earned its own subsystem. The score is a weighted blend of how well a startup and an investor line up:
MATCH_WEIGHTS = {
"industry_sector": 0.35,
"investor_type": 0.18,
"stage": 0.18,
"region": 0.10,
"countries": 0.09,
"city": 0.05,
}
I moved scoring off Python/Postgres and onto Elasticsearch so those weighted queries stayed fast as the contact database grew. That worked — and then it bit back. Match scores are derived data, so every related change fired a Django signal that reindexed the affected contact document immediately. At scale that meant potentially thousands of Elasticsearch writes a minute, most of them redundant.
The fix was to stop treating “the index must be perfectly current” as a requirement it never actually was. I decoupled ES writes from signals entirely: instead of reindexing inline, a change just enqueues an id.
PendingElasticsearchSync.objects.get_or_create(content_type=ct, object_id=obj.pk)
A cron’d management command drains that queue in batches of 500 every 15 minutes. Per the project’s own docs, that took Elasticsearch load from thousands of operations a minute down to four batch operations an hour, for search freshness nobody could perceive was any different. The unconventional bit isn’t the queue — it’s being willing to let the index be a few minutes stale on purpose.
Unconventional call #2: prototype on Dify, ship on Celery
The AI deal analyser — feed it a pitch deck, get back a structured investment report — is where I most deliberately used a throwaway. In December 2024 I stood up self-hosted Dify to prototype the analysis workflows, with a small FastAPI sidecar exposing the research/scraping steps as tools Dify could call. Visual workflow editing let me iterate on a ten-section report in days instead of weeks, in front of stakeholders, without a deploy per change.
The conventional wisdom says don’t build throwaway infrastructure. I did, because at that stage the bottleneck was figuring out what the report should say, not shipping production code. Dify was a prototyping surface, and I treated it as disposable from the start.
Two months later, once the shape had stabilized and it actually mattered, I graduated
the whole pipeline into native Django/Celery tasks with a version-controlled prompt
library — one .txt per report section, in git, diffable and reviewable like any
other code. That bought three things Dify couldn’t: prompts under real version
control, testability, and one fewer platform to operate. Prototype fast on the visual
tool, then pay down to boring, versioned code the moment the idea proves out. Knowing
when to make that switch is most of the skill.
What four years taught me
The platform stayed in active production through early 2026. The honest coda: after roughly three years of accretion, the core matching domain had collected enough migrations and dead models that the right move was a greenfield rebuild with cleaner, GIS-aware models rather than another in-place refactor — and I called that too.
The thing I’d underline for a peer: none of these were tickets handed to me. Deciding to go multi-tenant instead of multi-deploy, to let the search index go briefly stale, to prototype on a tool I fully intended to throw away — that’s product judgment compounding over years on the same codebase, which is a very different muscle than shipping a feature and moving on.
