Database Per Service
Database per service gives each microservice exclusive ownership of its persistence — no other service reads or writes its tables directly.
Introduction
Database per service gives each microservice exclusive ownership of its persistence — no other service reads or writes its tables directly. Uber's trip, pricing, and payment services each run dedicated datastore instances so schema migrations never require cross-team deploy locks.
Real production story
Uber's early microservice split left Trip and Payment services sharing one PostgreSQL cluster with cross-schema foreign keys. A payment team's index migration locked the trips table during Black Friday — ride matching stalled in 12 cities for 23 minutes.
The incident ADR mandated database-per-service: Trip service owns trip_db, Payment owns payment_db, integration only via APIs and events. Trip stores payment_id reference, not a FK. Payment emits PaymentCaptured; Trip updates status idempotently. Schema migrations became zero-coordination — each team's Flyway runs against their own instance.
Business problem
Business pressure: Uber operates 24/7 globally; database migration locks on shared schemas create city-wide outages during peak ride demand.
- Revenue at risk: Trip matching stall during surge pricing loses both rider conversions and driver utilization.
- Engineering velocity: Shared DB meant migration windows negotiated across 8 teams — weekly schema freeze calendar.
- Compliance / trust: Payment data isolation requires separate encrypted stores — shared cluster violates PCI segmentation.
Architecture overview
Each service's database is private — accessed only by that service's infrastructure layer. Other services reference IDs, replicate read models, or call query APIs.
- Definition: One logical database (instance or schema with strict access control) per service — no shared tables.
- When to adopt: Microservice decomposition with proven service boundaries.
- When to defer: Modular monolith phase — schema-per-module in one instance is acceptable intermediate.
- Operability: Per-DB backup, replication lag alerts, and migration runbooks owned by service team.
Architecture motivation
Why architects care: Database-per-service is the persistence expression of service autonomy — without it, microservices are processes sharing a monolith database.
- Force: Independent schema evolution and failure isolation per service.
- Constraint: Cross-service queries that JOIN tables must become API calls or materialized views.
- Outcome: Team-owned migrations, backup policies, and scaling per datastore type.
Internal architecture
Uber trip + payment isolation — separate stores, reference by ID:
┌──────────────┐ ┌──────────────┐│ Trip Service │ │Payment Service│└──────┬───────┘ └──────┬───────┘│ private │ private▼ ▼┌──────────────┐ ┌──────────────┐│ trip_db │ │ payment_db ││ trips │ │ payments ││ trip_events │ │ ledger ││ status │ │ refunds │└──────────────┘ └──────────────┘│ ││ payment_id (UUID) ││◀─── no FK ────────────▶││ │└──────── Kafka ─────────┘PaymentCapturedPaymentFailedQuery "trip with payment status":→ Trip API joins local status cache (updated by events)→ NOT cross-database JOIN
Data flow
Write path: Trip service creates trip in trip_db with status PENDING_PAYMENT → calls Payment API → Payment writes payment_db → emits PaymentCaptured → Trip consumer updates trip status idempotently.
- Write path: Saga or orchestration coordinates cross-service writes; each step commits locally.
- Read path: Trip service maintains denormalized payment_status column fed by events — avoids sync Payment call on read.
- Async path: Outbox in each service ensures event publish is transactional with local write.
System design diagram
Two diagrams show the Database Per Service topology and the primary request/event path used in production at scale.
Production code example
Saga + outbox with database-per-service — Uber payment/trip pattern:
// Trip service — trip_db onlyasync function completeTrip(tripId: string, fare: Money) {const trip = await tripRepo.find(tripId);trip.markAwaitingPayment();await tripRepo.save(trip); // trip_db transactionconst paymentId = await paymentClient.createPayment({tripId, amount: fare, idempotencyKey: trip.idempotencyKey,});trip.assignPaymentId(paymentId);await tripRepo.save(trip);}// Payment service — payment_db only@Transactional("paymentDb")async function capturePayment(cmd: CaptureCommand) {const existing = await paymentRepo.findByIdempotency(cmd.key);if (existing) return existing;const payment = Payment.capture(cmd);await paymentRepo.save(payment);await outbox.publish(paymentDb, new PaymentCaptured(payment.id, cmd.tripId));return payment;}// Trip consumer — updates trip_db from eventasync function onPaymentCaptured(evt: PaymentCaptured) {await tripRepo.updateStatusIdempotent(evt.tripId, "PAID", evt.eventId);}
Enterprise case study
Uber trip/payment split (2016–2018): Database-per-service eliminated cross-team migration locks and reduced payment-related trip outages to zero in 12 months.
- Before: Shared PostgreSQL; Black Friday lock incident; weekly schema freeze.
- Decision: Dedicated DB per service, reference-by-ID, event-driven status sync, saga for money flows.
- After: Independent migration velocity 5×; PCI audit scope limited to payment_db cluster.
Trade-offs
- Autonomy vs query convenience: No JOINs across services — invest in CQRS read models and APIs.
- Consistency vs availability: Eventual consistency between trip_db and payment_db — UX must tolerate brief lag.
- Cost vs isolation: N databases vs one cluster — managed RDS per service adds baseline cost; justified at Uber scale.
Security considerations
Security is architectural: payment_db credentials exist only in Payment service vault; Trip service has no DB path to card data.
- Identity: IAM/database roles per service — Trip role cannot SELECT payment tables.
- Data: Encryption keys per database; cross-service data via tokenized IDs only.
- Supply chain: Separate backup and restore drills per service — blast radius of bad restore contained.
Scalability analysis
Scale dimensions: Uber shards trip_db by city_id; payment_db by region for data residency — independent scaling policies.
- Horizontal scale: Read replicas per service DB; Trip uses Cassandra for high-write location streams separately.
- Hot spots: Mega-city shard — trip_db partition tuning without touching payment_db.
- Cost: Right-size DB per access pattern — payment gets strong consistency Postgres; trip history gets cheaper cold storage.
Failure scenarios
What breaks: Dual-write without saga leaves trip PAID in trip_db but payment FAILED in payment_db.
- Orphan references: Trip stores payment_id that never existed — validate via Payment API before commit or saga compensating action.
- Event lag: PaymentCaptured delayed — rider sees stale status; define SLA on read model freshness.
- Stealth shared DB: Team adds "read-only" cross-schema access — erosion begins; block in CI with DB permission audits.
Staff engineer insights
- Database-per-service is non-negotiable for real microservices — if you share tables, you share fate.
- Uber staff interview: always ask how cross-service queries work — "we JOIN in the API gateway" is an instant red flag.
- Invest in event-driven read model sync early — teams that skip it recreate distributed monolith via synchronous read chains.
Interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1AdvancedQuestionHow do you query data that spans multiple services without shared database JOINs?+
Answer
Follow-up
2AdvancedQuestionHow does database-per-service affect distributed transactions?+
Answer
Follow-up
3AdvancedQuestionIs schema-per-service in one PostgreSQL instance "good enough"?+
Answer
Follow-up
Architecture review questions
- Does each service have exclusive write access to its database?
- Are cross-service references by ID only — no foreign keys across databases?
- Are cross-service consistency patterns documented (saga, outbox, idempotency)?
- Do read paths use local materialized data or APIs, not cross-DB queries?
- Are DB credentials scoped per service with IAM/network isolation?
- Does each team own backup, migration, and scaling for their database independently?
Summary
Database per service at Uber scale means each microservice owns its datastore exclusively, integrates via IDs and events, and never JOINs across boundaries. Staff architects design sagas and read model sync before extraction, treating shared-database access as decomposition failure.