Enterprise Architecture Patterns Tutorial 0/65 lessons ~6 min read Lesson 25

    Database Per Service

    Database per service gives each microservice exclusive ownership of its persistence — no other service reads or writes its tables directly.

    Course progress0%
    Focus
    18 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    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:

    text
    ┌──────────────┐ ┌──────────────┐
    │ Trip Service │ │Payment Service│
    └──────┬───────┘ └──────┬───────┘
    │ private │ private
    ▼ ▼
    ┌──────────────┐ ┌──────────────┐
    │ trip_db │ │ payment_db │
    │ trips │ │ payments │
    │ trip_events │ │ ledger │
    │ status │ │ refunds │
    └──────────────┘ └──────────────┘
    │ │
    │ payment_id (UUID) │
    │◀─── no FK ────────────▶│
    │ │
    └──────── Kafka ─────────┘
    PaymentCaptured
    PaymentFailed
    Query "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.

    Database Per Service — system view
    Trip service
    Edge
    trip_db
    Core
    Payment svc
    Data
    payment_db
    Async
    High-level topology for Database Per Service.
    Database Per Service — request / event flow
    Trip creates
    Ingress
    Local commit
    Store
    Payment event
    Store
    Idempotent update
    Emit
    Follow this path when reviewing production designs.

    Production code example

    Saga + outbox with database-per-service — Uber payment/trip pattern:

    typescript
    // Trip service — trip_db only
    async function completeTrip(tripId: string, fare: Money) {
    const trip = await tripRepo.find(tripId);
    trip.markAwaitingPayment();
    await tripRepo.save(trip); // trip_db transaction
    const 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 event
    async 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.

    3 questions
    1AdvancedQuestionHow do you query data that spans multiple services without shared database JOINs?+

    Answer

    Three patterns: (1) API composition at gateway/BFF for low-QPS screens, (2) materialized read models updated by domain events for high-QPS reads, (3) CQRS dedicated query store fed by event stream. Never direct cross-DB access — it recouples services.

    Follow-up

    When would you duplicate data vs call an API?
    2AdvancedQuestionHow does database-per-service affect distributed transactions?+

    Answer

    Two-phase commit across service DBs is avoided — latency, availability, and operational coupling. Use saga (choreography or orchestration) with compensating actions, outbox for reliable events, and idempotent consumers. Accept eventual consistency with explicit UX for in-flight states.

    Follow-up

    Design a saga for trip completion with payment failure compensation.
    3AdvancedQuestionIs schema-per-service in one PostgreSQL instance "good enough"?+

    Answer

    Acceptable modular monolith intermediate with strict IAM (roles cannot cross schemas) — not true microservice isolation. Failure modes (disk, lock, runaway query) remain correlated. Migrate to separate instances when team autonomy or compliance requires physical isolation.

    Follow-up

    What Uber incident proved shared instance was insufficient?

    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.

    Ready to mark this lesson complete?Track your journey across the entire course.