.NET Service Boundaries: Domain-Driven Design
Service boundaries define where one microservice ends and another begins. Drawing them incorrectly creates false microservices that remain deeply coupled—sharing domain logic, databases, or APIs—and fail to deliver the independence benefits of microservices. Domain-Driven Design (DDD) provides a systematic approach: identify bounded contexts (explicit boundaries around a domain model), map them to services, and define explicit contracts between services.
Bounded contexts are the core concept. Each context owns a cohesive set of domain rules and a ubiquitous language (terms and concepts used consistently within that context). The Order context uses terms like Order, LineItem, Status. The Inventory context uses SKU, QuantityOnHand, ReorderLevel. Critically, the same term may mean different things across contexts: an Order in the Order context is different from an Order in the Warehouse context (one models customer orders; the other, internal fulfillment orders). By respecting these boundaries, you avoid entangling unrelated logic.
How to Identify Boundaries
Start with your business domain, not your code. Talk to domain experts (product managers, business analysts). What are the core business capabilities? For an e-commerce platform:
- Order Management — customers place, view, and manage orders.
- Payment Processing — accept payments, handle refunds, fraud detection.
- Inventory — track stock, reserve items, update availability.
- Shipping — pick, pack, ship, and track orders.
- Customer — manage customer profiles, preferences, loyalty.
- Notifications — send emails, SMS, push notifications.
Each capability is a candidate service. A good boundary:
- Encapsulates a cohesive set of operations. The Inventory Service handles stock checks, reservations, and deductions—a unified workflow.
- Minimizes dependencies on other services. Inventory rarely calls Order; Order calls Inventory to check availability before creating an order.
- Can be owned by a single team. The Inventory team understands stock rules and business logic without needing deep knowledge of shipping rules.
- Evolves independently. Changes to inventory forecasting do not require order service updates.
The Shared Database Anti-Pattern
A critical mistake: using a shared database for multiple services. If Order and Inventory services both write to the same Orders table, they are not microservices—they are two deployable units of a single monolith.
Why is shared data coupling? Both services know the schema. If Inventory needs to add a reserved_quantity column, it must coordinate with Order. Schema migrations become a bottleneck. More subtly, implicit domain rules live in the shared schema: if Order and Inventory both assume an order's total is the sum of line items, and one service changes this rule, the other breaks silently.
The .NET solution: Each service owns a separate database.
// OrderContext — only the Order Service uses this
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=order-db;Database=orders;");
}
}
// InventoryContext — only the Inventory Service uses this
public class InventoryContext : DbContext
{
public DbSet<InventoryItem> Items { get; set; }
public DbSet<Reservation> Reservations { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("Server=inventory-db;Database=inventory;");
}
}
Now Order and Inventory can evolve independently. If Inventory adds a LastAuditDate column, Order is unaffected.
Communicating Across Boundaries
Services must communicate to coordinate work. When Order Service creates an order, it needs Inventory to reserve items. There are two patterns:
Synchronous: Direct HTTP call. Order Service calls Inventory Service REST API synchronously:
public class OrderService
{
private readonly HttpClient _httpClient;
private readonly ILogger<OrderService> _logger;
public OrderService(HttpClient httpClient, ILogger<OrderService> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<Order> CreateOrderAsync(CreateOrderRequest req)
{
// Reserve inventory before committing the order
var reserveResponse = await _httpClient.PostAsJsonAsync(
"https://inventory-service/api/reservations",
new { items = req.Items }
);
if (!reserveResponse.IsSuccessStatusCode)
{
_logger.LogWarning("Inventory reservation failed");
throw new InsufficientInventoryException();
}
var reservation = await reserveResponse.Content.ReadAsAsync<ReservationDto>();
// Now persist the order
var order = new Order
{
CustomerId = req.CustomerId,
Items = req.Items,
ReservationId = reservation.Id,
CreatedAt = DateTime.UtcNow
};
// ... save to database and return
return order;
}
}
This is simple and synchronous. If Inventory is down, order creation fails immediately. The risk: Order and Inventory become tightly coupled—they must coordinate in real time.
Asynchronous: Event-driven. Order Service publishes an event; Inventory Service subscribes and reserves items asynchronously:
// Order Service publishes an event
public class OrderService
{
private readonly IEventPublisher _publisher;
public async Task<Order> CreateOrderAsync(CreateOrderRequest req)
{
var order = new Order
{
CustomerId = req.CustomerId,
Items = req.Items,
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow
};
// Save order in Pending state
await _dbContext.Orders.AddAsync(order);
await _dbContext.SaveChangesAsync();
// Publish event; Inventory Service subscribes
await _publisher.PublishAsync(new OrderCreatedEvent
{
OrderId = order.Id,
Items = order.Items
});
return order;
}
}
// Inventory Service subscribes
public class OrderCreatedEventHandler : IEventHandler<OrderCreatedEvent>
{
private readonly InventoryService _service;
public async Task HandleAsync(OrderCreatedEvent evt)
{
try
{
await _service.ReserveItemsAsync(evt.OrderId, evt.Items);
}
catch (InsufficientInventoryException)
{
// Publish OrderCancelled event or update order status
await _publisher.PublishAsync(new OrderCancelledEvent
{
OrderId = evt.OrderId,
Reason = "Insufficient inventory"
});
}
}
}
This decouples Order and Inventory. If Inventory is slow, orders are still created. If Inventory is down, orders queue in the event broker and are processed when Inventory recovers. The trade-off: eventual consistency—order status may not immediately reflect inventory availability.
Using Bounded Context Maps
A bounded context map visualizes boundaries and relationships. Here is a simplified e-commerce map:
| Context | Responsibility | Key Entities | Upstream | Downstream |
|---|---|---|---|---|
| Order | Order creation, history, fulfillment | Order, LineItem, Status | Customer, Inventory | Payment, Shipping, Notification |
| Inventory | Stock tracking, reservations | SKU, QuantityOnHand, Reservation | — | Order, Shipping |
| Payment | Payment authorization, refunds | Transaction, AuthorizationResult | Order | — |
| Shipping | Packing, label generation, delivery tracking | Shipment, TrackingNumber, CarrierInfo | Order, Inventory | Notification |
| Notification | Emails, SMS, push notifications | Message, Template, Recipient | Order, Payment, Shipping | — |
Each row is a bounded context. The Upstream and Downstream columns show dependencies: Order depends on Customer (needs to validate customer exists) and Inventory (must reserve items). Payment depends on Order (needs order total). This map reveals which services call which, helping you identify critical paths and potential bottlenecks.
Anti-Corruption Layer
Sometimes, two services use different domain models for overlapping concepts. The Order context models an Order; the Warehouse context (for internal fulfillment) models a PickList. The anti-corruption layer translates between them:
// Anti-corruption layer in Order Service
public class WarehouseServiceAdapter
{
private readonly HttpClient _httpClient;
public async Task<string> RequestPickListAsync(Order order)
{
var warehouse = await _httpClient.PostAsJsonAsync(
"https://warehouse-service/api/picklists",
new
{
order_id = order.Id, // Warehouse uses snake_case
items = order.Items.Select(li => new
{
sku = li.ProductId,
qty = li.Quantity
})
}
);
var warehouseModel = await warehouse.Content.ReadAsAsync<WarehousePickListDto>();
return warehouseModel.id; // Translate back to Order's model
}
}
The adapter absorbs differences in naming, data types, and API style, protecting the Order Service from Warehouse changes.
Key Takeaways
- Service boundaries should align with business capabilities, not technical layers (do not create a
DatabaseServiceand anAPIService—create anOrder Serviceand aPayment Service). - Use Domain-Driven Design (bounded contexts) to identify natural boundaries and validate them with domain experts.
- Each service must own its database; sharing databases ties services together at the data layer and eliminates the benefits of microservices.
- Choose synchronous communication for critical operations that require immediate feedback; use asynchronous events for decoupled, eventual consistency scenarios.
- Use anti-corruption layers to translate between different domain models when services communicate.
Frequently Asked Questions
How many microservices should I have?
There is no magic number. Start with 3–5 services covering major business capabilities. Each team should own at least one service; a team should not own more than 3–4 (cognitive load). In 2026, a mid-sized SaaS company typically has 10–30 services.
What if two services need to update the same data?
They should not. If two services need to update a record, it belongs in one service's context, and the other service should query it via an API. If genuinely shared, you may have a boundary wrong; reconsider.
Can a service call another service's database directly?
No. Querying another service's database creates implicit coupling and violates the boundary. Always use the service's public API. This allows the service to change its database schema without affecting others.
How do I keep service APIs stable while the internal model evolves?
Use an anti-corruption layer or a facade. Your domain model (how Order Service internally stores orders) can change; your API contract (the DTO returned to callers) stays stable. Version your API (/api/v2/orders) if you must break contracts, and support old versions for a migration period.
What is DDD, and do I need to learn it?
Domain-Driven Design (DDD) is a methodology for modeling software around business domains. For microservices, you need to understand the core concept: bounded contexts (explicit boundaries around domain logic and ubiquitous language). You do not need to learn strategic DDD (context maps, subdomain analysis) to start; apply it incrementally as complexity grows.