Start with Postgres
A lot of products get distributed way before they get successful. Search gets its own cluster. Background jobs get their own store. Analytics gets a separate pipeline. Event fan-out gets a broker. Meanwhile the product still has a moving data model and about three actual users.
I keep coming back to the same default: start with Postgres. Not because it's the answer to everything. Just because it handles a much wider slice of early product work than people give it credit for.
Specialized systems are great, actually
I should say upfront that this isn't anti-specialist software. Elasticsearch exists for a reason. Redis exists for a reason. Kafka exists for a reason. Dedicated systems are great when your problem actually matches the thing they were built to do.
The issue is timing. Teams often adopt those tools while they're still solving product questions, not systems questions. At that stage, the main thing you need is a reliable center of gravity. One place where the truth lives. One system the whole team can inspect. Postgres is very good at being that system.
JSON buys you room
Early product models move around a lot. Some parts of the schema
are stable on day one. Others are still trying to reveal what they
want to be. That's exactly where jsonb shines. You can
keep the important parts relational and still give yourself room
for structured data that hasn't settled yet.
create table jobs (
id bigint generated always as identity primary key,
kind text not null,
payload jsonb not null,
created_at timestamptz not null default now()
);
select id
from jobs
where payload @> '{"priority":"high"}';
This isn't an argument for throwing schema design out of the window. It's just a pragmatic middle ground. Not every changing field needs a migration on day one.
Search is usually good enough sooner than people think
A lot of teams reach for a separate search stack the moment someone says the word "search". Sometimes that's right. A lot of times it isn't. PostgreSQL already gives you full-text search, indexes, ranking, and result highlighting. For docs, tickets, internal tools, product catalogs with sane requirements, and a lot of B2B software, that's often enough for quite a while.
Dedicated search earns its cost when relevance tuning, typo tolerance, faceting, language handling, or scale really push beyond what the database should be doing. But that should be a pressure you can point to, not a habit.
Read models without another database
Materialized views are another feature I think people forget about. If you need a denormalized dashboard view or an expensive aggregate that shouldn't be recomputed on every request, you can precompute it and refresh it when it makes sense.
create materialized view team_stats as
select assignee_id, count(*) as open_issues
from issues
where status = 'open'
group by assignee_id;
refresh materialized view concurrently team_stats;
That's not the same as building a whole analytics stack. But it also doesn't need to be. Sometimes a cached query is exactly the right level of sophistication.
Simple signaling stays close to the data
LISTEN and NOTIFY are great for the class
of problems that are really just "something changed; take a look."
Wake a worker. Nudge a websocket broadcaster. Invalidate a cache.
Tell another process to re-read something. Not every internal event
needs a broker on day one.
Once you need replay, long retention, independent consumer groups, or heavy fan-out, that's where a dedicated messaging system starts earning its keep. But a surprising number of first versions don't need any of that yet.
Security where the data actually is
Row-level security is another big reason I like Postgres as the center of a system. Authorization rules close to the tables are harder to forget than rules scattered across handlers and services. The farther access control drifts from the data, the easier it is to create weird edge cases by accident.
The most common alternative is appending
WHERE tenant_id = $TENANT_ID to every query in every
handler. That works until someone forgets one. And since queries are
permissive by default, forgetting a filter doesn't fail loudly. It
just quietly returns rows from other tenants. The more endpoints you
have, the more places that filter needs to exist, and the harder it
becomes to be sure you never missed one.
RLS flips the default. Once enabled, the table returns nothing unless a policy explicitly grants access. It's deny-by-default, enforced by the database itself, regardless of which service or query path touches the data.
alter table projects enable row level security;
create policy tenant_isolation on projects
for all
using (tenant_id = current_setting('app.tenant_id')::uuid)
with check (tenant_id = current_setting('app.tenant_id')::uuid);
Now every query against that table is automatically scoped. Your
application code still handles business logic filters, but the
isolation boundary lives in one place instead of forty
WHERE clauses spread across your codebase. It's not a
silver bullet. Complex permission models can outgrow what fits
neatly in a policy expression, and you still need to test
thoroughly. But as a default starting point for tenant isolation,
it's remarkably hard to beat.
The real win is operational focus
Most of the benefit here isn't flashy. It's operational. One backup story. One replication story. One place to inspect state when something goes weird. One query language. One team mental model. That matters more than people admit, especially when the team is still small.
Every extra system adds docs, dashboards, credentials, alerts, failure modes, and "whose problem is this?" moments. Sometimes that trade is worth it. But you should be getting something real back for it.
When I stop doing this
There is a line where this stops being the right default. Search can outgrow the database. Async workloads can outgrow simple signaling. Analytics can outgrow cached aggregates. Different traffic patterns can demand independent scaling. That's fine. Splitting things up later is a normal part of growth.
But start with the pressure, not the diagram. You can always add a system when the need becomes real. Pulling five systems back into one is much harder. Start with the database that already knows how to do more than tables.