Skip to main content

Data Ownership in .NET Microservices: Isolation

Data ownership is a foundational principle of microservices: each service owns a logical subset of data and exclusively manages its schema, migrations, and queries. Other services access that data only through the service's public API, never by querying the database directly. This principle prevents tight coupling at the data layer and enables services to evolve independently.

A monolith has one database with tables for all domains: Orders, Payments, Customers, Inventory. A microservice architecture splits this into multiple databases, each owned by one service. The Order Service owns the Orders and OrderLineItems tables; the Inventory Service owns SkuItems and Reservations; the Payment Service owns Transactions. Each database is a separate logical unit, often on a separate server (in production).

Why is this critical? Imagine two services share an Orders table. Both services know the schema: OrderId, CustomerId, Total, Status. If the Order Service adds a Status column (to track pending, confirmed, shipped), the Inventory Service's code breaks if it assumes Status does not exist. Schema migrations conflict: Order Service wants to drop a column; Inventory Service still reads it. Business logic leaks into shared schema: both services assume Total equals the sum of line items, but Order Service changes the calculation to include taxes. The Inventory Service's discount logic now produces incorrect results.

By isolating data ownership, services avoid these entanglements. The Order Service schema is a private implementation detail. The Inventory Service calls the Order Service's API to fetch order details; it does not know the underlying schema.

The Service Owns Its Database

Each service has a dedicated database (or schema within a shared database engine, in practice):

// Order Service database context
public class OrderContext : DbContext
{
public DbSet<Order> Orders { get; set; }
public DbSet<LineItem> LineItems { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(
"Server=tcp:order-db.database.windows.net;Database=OrderDb;..."
);
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Status).HasConversion<string>();
entity.HasMany(e => e.Items).WithOne().HasForeignKey(li => li.OrderId);
});
}
}

// Inventory Service database context (separate database)
public class InventoryContext : DbContext
{
public DbSet<SkuItem> Items { get; set; }
public DbSet<Reservation> Reservations { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(
"Server=tcp:inventory-db.database.windows.net;Database=InventoryDb;..."
);
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<SkuItem>(entity =>
{
entity.HasKey(e => e.Sku);
entity.Property(e => e.QuantityOnHand);
entity.HasMany(e => e.Reservations).WithOne().HasForeignKey(r => r.Sku);
});
}
}

Notice: distinct connection strings to separate databases. Order Service has no access to Inventory's schema; Inventory has no access to Order's schema. Each service is free to evolve its schema independently.

Querying Other Services' Data

How does Inventory Service know if an order exists? It calls the Order Service API:

// In InventoryService
public class InventoryService
{
private readonly HttpClient _httpClient;
private readonly InventoryContext _context;

public InventoryService(HttpClient httpClient, InventoryContext context)
{
_httpClient = httpClient;
_context = context;
}

public async Task<bool> OrderExistsAsync(int orderId)
{
try
{
var response = await _httpClient.GetAsync(
$"https://order-service/api/orders/{orderId}"
);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
{
// Order Service is down; assume order does not exist
return false;
}
}

public async Task<Order?> GetOrderDetailsAsync(int orderId)
{
var response = await _httpClient.GetAsync(
$"https://order-service/api/orders/{orderId}"
);

if (!response.IsSuccessStatusCode)
return null;

var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Order>(json);
}
}

The Inventory Service calls the Order Service's REST API and deserializes the response. This is slightly slower (network round-trip) than querying a shared database, but it maintains ownership: Order Service can refactor its schema without affecting Inventory.

Handling Denormalization and Caching

Since querying another service's data is slower, use caching and denormalization strategically:

public class InventoryService
{
private readonly OrderServiceClient _orderClient;
private readonly IMemoryCache _cache;
private readonly InventoryContext _context;

public async Task<OrderSummary> GetOrderSummaryAsync(int orderId)
{
const string cacheKey = $"order:{orderId}";

if (_cache.TryGetValue(cacheKey, out OrderSummary cached))
{
return cached;
}

// Fetch from Order Service
var order = await _orderClient.GetOrderAsync(orderId);

var summary = new OrderSummary
{
OrderId = order.Id,
CustomerId = order.CustomerId,
Total = order.Total
};

// Cache for 5 minutes
_cache.Set(cacheKey, summary, TimeSpan.FromMinutes(5));

return summary;
}
}

Caching reduces network calls. Trade-off: cached data may be stale (5 minutes old in this example). For critical operations (payment processing), query directly; for read-heavy operations (dashboards), cache aggressively.

Alternatively, denormalize data within your service. When an order is created, publish an event; Inventory Service subscribes and stores a local copy of order summary data:

// InventoryService subscribes to OrderCreatedEvent
public class OrderCreatedEventConsumer : IConsumer<OrderCreatedEvent>
{
private readonly InventoryContext _context;

public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
{
// Denormalize: store a copy of order summary locally
var orderSummary = new OrderSummary
{
OrderId = context.Message.OrderId,
CustomerId = context.Message.CustomerId,
Total = context.Message.Total
};

await _context.OrderSummaries.AddAsync(orderSummary);
await _context.SaveChangesAsync();
}
}

Now Inventory Service can query OrderSummaries table (owned by Inventory Service, populated from events) without calling the Order Service. Denormalization trades consistency (Inventory's copy may lag behind Order's source) for performance (local queries are fast).

Event Sourcing for Cross-Service Consistency

In complex scenarios, use event sourcing: services publish all state changes as immutable events. Other services consume these events and build read models (denormalized views of data):

// Order Service publishes events
public class OrderCreatedEvent
{
public int OrderId { get; init; }
public int CustomerId { get; init; }
public decimal Total { get; init; }
}

public class OrderCancelledEvent
{
public int OrderId { get; init; }
public string Reason { get; init; }
}

// Inventory Service builds a read model from events
public class OrderReadModel
{
public int OrderId { get; set; }
public int CustomerId { get; set; }
public decimal Total { get; set; }
public bool IsCancelled { get; set; }
}

public class OrderCreatedEventConsumer : IConsumer<OrderCreatedEvent>
{
private readonly InventoryContext _context;

public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
{
var readModel = new OrderReadModel
{
OrderId = context.Message.OrderId,
CustomerId = context.Message.CustomerId,
Total = context.Message.Total,
IsCancelled = false
};

await _context.OrderReadModels.AddAsync(readModel);
await _context.SaveChangesAsync();
}
}

public class OrderCancelledEventConsumer : IConsumer<OrderCancelledEvent>
{
private readonly InventoryContext _context;

public async Task Consume(ConsumeContext<OrderCancelledEvent> context)
{
var readModel = await _context.OrderReadModels
.FirstOrDefaultAsync(m => m.OrderId == context.Message.OrderId);

if (readModel != null)
{
readModel.IsCancelled = true;
await _context.SaveChangesAsync();
}
}
}

Inventory Service's read model reflects Order Service's state, derived from events. Order Service can refactor its implementation without changing events (which are the contract). Inventory Service is always eventually consistent with Order Service.

Shared Reference Data

Some data is truly shared: product catalogs, tax rates, feature flags. Options:

  1. Replicate: Each service keeps a local copy, synced via events or scheduled jobs.
  2. Reference Service: A single Reference Service owns shared data; others query it (with caching).
  3. Configuration Server: Centralized configuration service (e.g., Consul, etcd) for feature flags and settings.

For a small dataset (product names, tax rates), replicate with eventual consistency. For large, frequently-accessed data, use a caching layer. For configuration, use a config server.

Key Takeaways

  • Each service owns a logical subset of data (tables, collections); no sharing.
  • Other services access that data via the service's API, never by direct database queries.
  • Use caching and denormalization (read models, event sourcing) to reduce network calls and improve performance.
  • Event sourcing decouples services and provides an audit trail of all state changes.
  • Shared reference data can be replicated across services with eventual consistency.

Frequently Asked Questions

What if I need to query across multiple services' data?

Define an API that aggregates data from multiple services, or build a read model (in a dedicated read database) populated by events from all services. The aggregation service calls downstream services; the read model service subscribes to events. Both approaches maintain ownership boundaries.

Can I use a shared cache (Redis) across services?

Yes, but be careful. Using shared Redis is not data ownership; it is a performance layer. Each service still owns its source database. Use shared Redis for non-critical caching (product names, exchange rates); never use it as the system of record.

How do I ensure eventual consistency across services?

Monitor event lag (time from publish to consumption). Set retention policies on message brokers (e.g., RabbitMQ keeps events for 24 hours). In critical scenarios, query the source service directly; in analytics/dashboards, cache with known staleness windows (e.g., 5 minutes old is acceptable).

What if a service's API is slow?

Cache aggressively, denormalize read-heavy data, or switch to event-driven (publish events; subscribers build local copies). Measure and optimize the critical path: if payment processing waits on order details, query synchronously with a short timeout; if a dashboard waits, cache for minutes.

How do I migrate data between services?

Plan migrations carefully. If moving Customers from Order Service to a new Customer Service: (1) Customer Service reads from Order Service's database directly (temporary), (2) Data is synced nightly, (3) Clients gradually switch to Customer Service's API, (4) Order Service stops writing Customers. This takes weeks in production; use a measured approach.

Further Reading