Why this ADR exists
Many teams do not plan to build microservices. They arrive there by accumulation: a feature gets isolated, a database table gets copied, a queue gets added, and suddenly the system has service-shaped parts with none of the operational discipline. The result is not architecture. It is distributed monolith behavior with extra steps.
This ADR template is designed to stop that drift early. It is short enough to complete during design review, but strict enough to expose hidden coupling. The point is not to block change. The point is to make the team answer five questions before a boundary hardens.
If the answer is unclear, that is useful signal. It means the boundary is still speculative and should stay inside the monolith, module, or bounded context until the design is stronger.
When to use it
Use this ADR when a team is considering:
- extracting a module into a new service
- adding a new deployable with its own data store
- introducing async messaging to avoid direct calls
- assigning different teams to parts of the same domain
- splitting reads and writes into separate paths
Do not use it as a generic architecture essay. Use it as a decision record that names a boundary and records why it is real.
Copy-pasteable ADR template
# ADR: [Decision Title]
Date: [YYYY-MM-DD]
Status: [Proposed | Accepted | Rejected | Superseded]
Owners: [Team / people]
Scope: [Module / domain / system]
## Context
Describe the problem, the current shape of the system, and why this decision is being considered now.
## Decision
We will [do / not do] [service boundary / deployment split / data split].
## Questions to answer before we split
### 1) What is the boundary, exactly?
- Yes looks like: The boundary matches a business capability, has a single owner, and can be described without naming implementation details.
- Not yet looks like: The boundary is a folder, a team preference, or a list of endpoints.
- Consequence of getting it wrong: You create a service boundary that cannot survive refactoring and ends up depending on internal knowledge.
### 2) What data does the boundary own?
- Yes looks like: The boundary owns its data model and can answer requests without reaching into another service's tables.
- Not yet looks like: The boundary reads another team's database, shares schemas, or needs frequent cross-service joins.
- Consequence of getting it wrong: Every change becomes a coordination exercise, and your deployment independence is fake.
### 3) What failure mode is acceptable at the boundary?
- Yes looks like: You can name timeouts, retries, idempotency, and fallback behavior in one paragraph.
- Not yet looks like: The design assumes the network is always fast and always correct.
- Consequence of getting it wrong: The first production incident teaches the system what the architecture should have said up front.
### 4) What can be deployed independently without breaking the domain?
- Yes looks like: A change in one part can ship without a synchronized release in another part.
- Not yet looks like: Two deployables still require shared version bumps, flag choreography, or manual sequence control.
- Consequence of getting it wrong: You have multiple deployables, but you still operate them like one codebase.
### 5) What evidence says this should be a service now?
- Yes looks like: Clear operational need, real ownership boundary, measurable scaling or release pressure, and a tested integration contract.
- Not yet looks like: The reason is "cleaner code," "future scale," or "we might need it later."
- Consequence of getting it wrong: You pay distributed-systems cost before the domain has earned it.
Decision notes
- If any answer is unclear, keep the design in-process.
- If data ownership is not clear, do not split the database.
- If the integration contract is not tested, do not claim independence.
- If the team cannot describe the failure mode, the architecture is not ready.
Implementation guardrails
- Prefer modules inside one deployable until the boundary is proven.
- Use explicit interfaces and dependency injection in-process before introducing remote calls.
- In .NET 8+, register services with Microsoft.Extensions.DependencyInjection and keep contracts narrow.
- Treat cross-boundary calls as a cost, not a default.
- Record the rollback path before the split.
Example guardrail in .NET 8
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddScoped<IOrderApplicationService, OrderApplicationService>();
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<IOrderBoundary, OrderBoundary>();
// Scoped services resolve inside a scope.
// Resolving IOrderBoundary from the root provider throws InvalidOperationException.
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var boundary = scope.ServiceProvider.GetRequiredService<IOrderBoundary>();
var result = await boundary.PlaceAsync(new PlaceOrderRequest("order-42"), CancellationToken.None);
Console.WriteLine($"placed: {result.OrderId}");
public interface IOrderBoundary
{
Task<OrderResult> PlaceAsync(PlaceOrderRequest request, CancellationToken cancellationToken);
}
public sealed class OrderBoundary(IOrderApplicationService app) : IOrderBoundary
{
public Task<OrderResult> PlaceAsync(PlaceOrderRequest request, CancellationToken cancellationToken)
=> app.PlaceAsync(request, cancellationToken);
}
public interface IOrderApplicationService
{
Task<OrderResult> PlaceAsync(PlaceOrderRequest request, CancellationToken cancellationToken);
}
public interface IOrderRepository
{
Task SaveAsync(OrderResult result, CancellationToken cancellationToken);
}
public sealed class OrderApplicationService(IOrderRepository repository) : IOrderApplicationService
{
public async Task<OrderResult> PlaceAsync(PlaceOrderRequest request, CancellationToken cancellationToken)
{
var result = new OrderResult(request.OrderId, "placed");
await repository.SaveAsync(result, cancellationToken);
return result;
}
}
public sealed class SqlOrderRepository : IOrderRepository
{
public Task SaveAsync(OrderResult result, CancellationToken cancellationToken) => Task.CompletedTask;
}
public sealed record PlaceOrderRequest(string OrderId);
public sealed record OrderResult(string OrderId, string Status);
Scoped services resolve inside a scope: pulling IOrderBoundary out of the root provider throws InvalidOperationException — "Cannot resolve scoped service 'IOrderBoundary' from root provider". That is exactly the guardrail this post is about. This keeps the boundary explicit while the system still runs in one process. That is often the right place to stay until the ADR answers are all solid.
What to look for in review
- Can someone explain the boundary in one sentence?
- Can someone show where the data lives?
- Can the system tolerate one side being late or down?
- Can each side ship separately?
- Would the team still choose this split if the code were harder to navigate but the runtime simpler?
If any answer is no, the split is probably premature.
Conclusion
Use this ADR to make accidental microservices harder to justify. The decision can still be to split, but only after the boundary is real, the data ownership is clear, and the failure modes are understood.
Sources
- Documenting Architecture Decisions — Michael Nygard, Cognitect
- Decomposing monoliths into microservices — AWS Prescriptive Guidance
If your team is about to split a module into a service, paste this ADR into the review and answer the five questions before you add the first network hop. Discuss service boundaries → techbuzzz.ru



