Building Microservices with Spring Boot

Data Management Per Service

18 min Lesson 6 of 12

Data Management Per Service

One of the most consequential decisions in a microservices architecture is also one of the most misunderstood: each service must own its data exclusively. This principle — sometimes called Database per Service — means a service's tables, schema, and datastore are entirely private. No other service reads them directly; all data access goes through the service's own API. This lesson explains why that rule exists, how to implement it with Spring Boot 3, and what trade-offs you accept when you follow it.

Why a Shared Database Is an Anti-Pattern

Sharing a single database across multiple services seems tempting: you avoid duplication, joins are trivial, and you already know the schema. In practice it creates tight coupling at the worst possible layer:

  • Schema coupling: A column rename in the orders table breaks every service that queries it, even ones maintained by a different team.
  • Deployment coupling: You cannot roll out a new version of the inventory service if the order service still expects the old schema.
  • Scaling coupling: You cannot run the read-heavy catalog service on a read replica while the write-heavy checkout service uses the primary; they share the same connection pool.
  • Technology coupling: One team wants PostgreSQL with JSONB; another wants a document store. A shared database forces a single engine on everyone.
The rule: If two services share a database and you cannot deploy one without checking on the other, you have not actually built microservices — you have built a distributed monolith with all the complexity of distribution and none of the autonomy.

Polyglot Persistence

Database-per-service frees each team to choose the right storage technology for their problem:

  • Order service — relational (PostgreSQL) for ACID transactions.
  • Catalog service — document store (MongoDB) for flexible product attributes.
  • Cart service — key-value store (Redis) for ephemeral, low-latency session data.
  • Search service — search engine (Elasticsearch) for full-text queries.

Spring Boot's auto-configuration supports all of these. You declare a dependency and configure a connection URL; Spring wires the rest.

Configuring Isolated Datastores in Spring Boot 3

Each service has its own application.yml with its own datasource. The order-service might look like this:

# order-service/src/main/resources/application.yml spring: datasource: url: jdbc:postgresql://orders-db:5432/orders username: ${DB_USER} password: ${DB_PASS} jpa: hibernate: ddl-auto: validate # never auto-create in production open-in-view: false # avoid lazy-loading footguns flyway: locations: classpath:db/migration enabled: true

The catalog-service uses a completely separate MongoDB instance:

# catalog-service/src/main/resources/application.yml spring: data: mongodb: uri: mongodb://catalog-db:27017/catalog auto-index-creation: false
Never use ddl-auto: create or create-drop in production. Always use a migration tool — Flyway or Liquibase — so schema changes are versioned, auditable, and reversible. Flyway is the default auto-configured option in Spring Boot.

Schema Migration with Flyway

Each service carries its own migration scripts under src/main/resources/db/migration. The naming convention is V{version}__{description}.sql:

-- order-service/src/main/resources/db/migration/V1__create_orders.sql CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'PENDING', total_cents INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_orders_customer ON orders (customer_id);

Flyway runs automatically on application startup (before the HTTP port opens), applies any pending migrations in version order, and records each applied script in a flyway_schema_history table. If a migration fails, the application refuses to start — which is exactly what you want: a half-migrated schema is worse than no schema.

The Cost: No Cross-Service Joins

Once each service owns its data, a query like "get the order together with the customer's name and the product's current price" can no longer be a SQL join. You have three options:

  1. API Composition — the caller (or a dedicated aggregation service) fetches from multiple services and stitches the result in memory. Simple but adds latency and creates a caller-side dependency on multiple APIs.
  2. Event-Driven Denormalization — services publish events when data changes; consumers maintain their own local read-optimized copies. Eventual consistency, but read performance is excellent.
  3. CQRS + Read Models — a dedicated read service subscribes to domain events and materializes a joined view in its own store (e.g., a denormalized Elasticsearch index).

The most common starting point is API composition for simple queries plus event-driven updates for data that changes frequently. Here is a typical API composition in the order-service:

@Service @RequiredArgsConstructor public class OrderDetailsService { private final OrderRepository orderRepository; private final CustomerClient customerClient; // OpenFeign / WebClient private final CatalogClient catalogClient; public OrderDetailsDto getDetails(Long orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); CustomerDto customer = customerClient.findById(order.getCustomerId()); List<ProductDto> products = catalogClient.findByIds(order.getProductIds()); return OrderDetailsDto.of(order, customer, products); } }
Never inject another service's Spring bean or JPA repository into your service. If OrderService imports CustomerRepository from the customer-service module, you have recreated the shared-database problem in code — now both services must be deployed together. Cross-service data access always goes through the network API.

Handling Distributed Consistency

Without a shared database, a multi-service operation — such as "create an order and deduct inventory and charge the customer" — cannot be wrapped in a single ACID transaction. You must choose a consistency strategy:

  • Saga pattern (choreography): Each service publishes a domain event after its local transaction commits. Downstream services react to that event and publish their own. Compensating transactions roll back on failure.
  • Saga pattern (orchestration): A dedicated saga orchestrator issues commands to each service and tracks state. Easier to reason about but adds a coordinator component.
  • Eventual consistency + idempotency: Accept that different services will be briefly out of sync. Design every operation to be idempotent (safe to replay) so retries after network failures do not duplicate effects.

The key insight is that even traditional databases are eventually consistent across replicas. Microservices just make that trade-off explicit.

Security Implications

Private databases improve the security posture of the overall system:

  • A compromised catalog-service cannot read orders or payments tables — they are on separate servers with separate credentials.
  • Each service runs with a database user that has only the permissions it needs (least-privilege principle). The catalog-service user cannot DROP TABLE in the orders schema even if it somehow learned the connection string.
  • Sensitive data (PII, payment info) can be isolated in a service whose database is in a higher-security network segment, encrypted at rest with its own key, and audited independently.
Store credentials in a secrets manager (HashiCorp Vault, AWS Secrets Manager, Kubernetes Secrets) and inject them at runtime via environment variables. Never embed passwords in application.yml or commit them to git. Spring Cloud Vault and Spring Cloud Config both support this pattern out of the box.

Summary

Database-per-service is the cornerstone of true service autonomy. Each Spring Boot service declares its own datasource in its own application.yml, manages its schema with Flyway migrations that live alongside the service code, and exposes its data only through its REST or messaging API. The cost is that cross-service queries become API composition or event-driven read models, and multi-service transactions require sagas rather than ACID commits. These trade-offs are real, but they are the price of independent deployability, technology freedom, and isolated failure domains — the core promises of the microservices style.