Insight

Everyone Talks About AI. Nobody Talks About the Foundation.

 

Any solution, AI or not, needs a good foundation. Data and architecture are the things you can't neglect if you want something functional, efficient, and reliable. 

Architects have to think about everything. Software engineers start at data structures, DevOps at infrastructure and security, UX at the end user's experience. The architect's job is to make the foundation manageable, scalable, reliable, operationally efficient, secure, and affordable, and balance the business aspects as well as the technical ones. 

The Cost of Skipping the Foundation

A system that needs compliance, high security, encryption, and third-party integrations is complex to start with. Make it a multi-tenant SaaS product, and the complexity grows. Under that pressure we forget things, skip them, and create technical debt. Left unaddressed, that debt keeps the solution from producing the outcomes it was built for.

The pattern is familiar. Someone with a strong vision and a deadline says, "We'll clean it up after we prove it works." The demo works, and the demo becomes the product. The cleanup never happens, because now there are customers.

This isn't an AI problem. AI only makes it look new. The model, the feature, and the UI are the visible 10%. The rest decides whether you will survive or not.


Here is what it looked like when I refused to make that trade.

Constraints That Remove the Shortcut

The platform had to be multi-tenant, HIPAA-compliant, and secure enough for PHI and PII. It also had to sync with third-party systems such as EHRs, which meant long-running, failure-prone work that couldn't share a process with anything a user was waiting on. 

  • That combination removes the shortcuts. 
  • You shouldn't put every customer's data in one database with a tenant id column and promise to be careful. 
  • You can't run the sync job inside the web process. 
  • You must not hand out the superuser password to every service you're running. 

Compliance forced me to do the architecture properly. Early, too.
I always believed that every system deserves that discipline, regulated or not.


Separate the Domains

One of the first decisions was about boundaries. I split the platform into separate services:

  • A control plane that owns accounts, auth, sessions, and provisioning
  • An ETL service for uploads and third-party system sync, with a scheduler and queue-driven workers
  • A few other domain-specific services, each separate
  • A frontend, the only thing a browser ever talks to

Extract the Generic Concerns

The models and enumerators, all services need, live in one shared package, so an entity means the same thing everywhere.

Underneath, I extracted authentication, RBAC, encryption, multi-tenant database access, connection pool management, and infrastructure integration into a family of libraries: paper-core, paper-auth, paper-db, paper-infra, and paper-email. They are published in lockstep, so one version applies to the whole family.

This is the step people skip to save time. Authentication, encryption, and tenancy are not features. If each service implements its own, you end up with slightly different security models and no way to fix them all at once. When I later changed how connections were pooled, I changed it in one library, and every service inherited the fix.

Scaling Database Connections with Tenants

The obvious fix for opening a connection per request is a connection pool. Multi-tenancy changes the arithmetic. With a database per customer, you need a pool per tenant:

db connections = pool size × tenants served × running tasks

That multiplies quickly, and Postgres runs out of connections long before it runs out of memory.

Step 1: Small, Disciplined Pools

Each tenant gets a pool of four, with tripled burst headroom that closes after use. Idle pools are disposed of after five minutes, so a task that touches every tenant once doesn't hold them all open. I didn't guess those numbers; I measured them. At burst, I held 240 persistent and 720 database connections. Requests took about 30ms, versus up to 56ms before pooling, and acquiring a pooled connection cost was around 4ms.

The Wall

My current setup, for testing, is 15 tenants, 10 locations per tenant, and 6 users per location, so 900 users. With 4 pools per container instance, each tenant needs about 33 persistent and up to 146 burst-time connections. Across 15 tenants, that's 495 persistent and up to 2,190 burst-time connections. A large Postgres instance tops out around 900.

Only 15 tenants, and I was already way over. The fix would have meant sharding, multiple instances, read/write splits, perhaps regional deployments. All very costly.


Step 2: PgBouncer

PgBouncer sits between your applications and the database and lets many of them share a small set of connections. Think of a receptionist handing out a few desks to a large team instead of giving everyone an office.

There's a trap, too. If each application also keeps a private stash of connections, it hoards the very connections PgBouncer is trying to share. So, I built one switch into the shared library that turns the private stash off, and I flip it in every service at once. Creating a new customer database is the one exception, because that operation needs a direct line to Postgres.

PgBouncer's transaction mode has trade-offs, so I handled them once, in the shared library, behind a single flag. It disables prepared-statement caching, which breaks when a transaction lands on a different server connection. It also turns off local pooling, which would hoard the connections PgBouncer is meant to share. Session state was a non-issue: tenants are separate databases, not schemas.

In the end-to-end run, peak Postgres connections dropped significantly, from 67 to 33, at a cost of about 9% in wall time, with an average wait for a server connection of 41µs and no queued clients.

New tenants need no proxy configuration. A wildcard database entry plus auth query meant all 15 tenants provisioned during the run appeared in the pooler with zero config, something RDS Proxy can't do with a role per tenant.


Step 3: Size for Throughput, Not Services

The bigger gain came from changing the sizing model. Instead of counting pools per service, I calculated connections from throughput. My starting assumptions are 1 request every 5 seconds per user, 3 transactions per request, 14ms per transaction (measured after the initial pooling work):

900 users × 0.2 req/sec × 3 transactions × 0.014 sec = 7.6 ≈ 8 connections 

Persistent Connections: 495 Per-Service Pools | ~8 PgBouncer+Throughput Sizing

Burst-Time Connections: 2,190 Per-Service Pools | ~24 (triple burst) PgBouncer+Throughput Sizing


Reduction: 62x persistent, ~90x burst-time

The throughput figure is an assumption, and it's easy to update as I collect more production data. But it's a far more efficient way to model the system, and it lets it scale. 

The lesson isn't "use PgBouncer." Connection management is an architectural concern you will eventually pay for.
Paying early costs a design decision. Paying late costs an outage.


Isolation Is a Stack of Independent Decisions

Making a system "secure" isn't a single decision. It's a stack of small ones, each assuming the layer above it has failed.

One public door

Only the frontend sits behind nginx. The other services have no public route and send no CORS headers. A vulnerability in a backend service isn't reachable from the internet, because there's no path to it.

The "Least Privilege", per service and per tenant

No service ever connects as the superuser. Each service has its own database role with only the grants it needs. Each tenant has an owner role that can't log in, and a runtime role that owns nothing and can run no DDL (Data Definition Language). Only the migration role can change a schema. If a bug becomes SQL injection, the attacker gets row access, not the ability to drop a table or reach another tenant's database. The audit log is "append-only"—ledger-like—enforced at the database level via a revoked UPDATE/DELETE mechanism.

Sessions die on the next request

Every service checks the session against the control plane on every request. A logout, password reset, or deactivated account takes effect immediately, not whenever a token happens to expire.

Encryption you can rotate

Sensitive values are encrypted with a data key per account, wrapped by a KMS key. Rotation is a command, not a project: it creates new keys, re-encrypts what needs it, and reports what is encrypted under which key. Token-signing keys rotate the same way, without signing anyone out.

None of this was hard to add early. All of it would have been painful to retrofit into a system holding live PHI.


The Tooling Is Architecture Too

Developer's experience gets the least credit; all the focus goes to the end user. I built scripts for the flows that hurt:

  • Starting only the containers a session needs
  • Resetting one piece of the local stack in seconds instead of rebuilding everything
  • Seeding test accounts
  • Migrating every tenant database
  • Reporting exactly how many connections each role holds

That last one is the difference between "I think the pooling works" and "I measured it."

A team that can't easily run, reset, and inspect its own system will skip the checks, and skipped checks are how debt accrues quietly.


Why This Matters Even More With AI

Teams building AI products are especially prone to this. The model is the exciting part; the demos are impressive, and the pressure to ship is intense. But the model sits on top of the same things every system needs: identity, access control, data isolation, encryption, observability, and the ability to change things safely.

An AI feature that leaks one tenant's data into another's context isn't an AI failure. It's an architecture failure with a more interesting headline.


What I'd Tell a Leader Who Wants to Move Fast

Moving fast and building the foundation aren't opposites. The foundation is what lets you keep moving fast in year three. A few things I'd hold to:

  • Domain Separation: Separate the domains before the codebase makes it expensive.
  • Shared Foundation: Extract the generic concerns (auth, tenancy, crypto) once, into reusable libraries.
  • Design for Failure: Assume each layer will fail and design the next so failures are contained.
  • Security as a Layer: Build security in as a layer, not a feature: auth, RBAC, and encryption live in the foundation, not in each service.
  • Async Workloads: Move slow, failure-prone work into queue-driven workers; never in the process a user is waiting on.
  • Failure Containment: Isolate services so a failure in one doesn’t cascade to the rest.
  • Measurability: Build the tools that let you measure your system, not just run it.

Technical debt isn't caused by writing quick code. It's caused by deferring decisions whose cost grows with every customer you add.


So, would it work for 10,000 clients? 100,000? Decide in the first month, not the third year.

Related Posts

© 2026 | Paper Plane Consulting
linkedin facebook pinterest youtube rss twitter instagram facebook-blank rss-blank linkedin-blank pinterest youtube twitter instagram